diff --git a/scripts/migrate-runs-to-goal-task.js b/scripts/migrate-runs-to-goal-task.js index 74aeaf3..c27461b 100644 --- a/scripts/migrate-runs-to-goal-task.js +++ b/scripts/migrate-runs-to-goal-task.js @@ -18,7 +18,11 @@ * node scripts/migrate-runs-to-goal-task.js --dry-run # 预演,事务回滚,不留任何行 * node scripts/migrate-runs-to-goal-task.js # 实际执行 * - * 幂等: 见 alreadyMigrated()。重复跑不会产生第二份 goal/task。 + * 【批 5 追加】同一个脚本还负责第二类回填:`system/tasklog/*.json`(agent 之间派单的旧存储) + * → `tasks` 表的 `executor='agent'` 行。两类回填放同一个脚本是 §十二.5 的要求:同一次数据形态 + * 变更分两个脚本,只会让「跑了哪个没跑哪个」变成新问题。 + * + * 幂等: 见 alreadyMigrated() / tasklogAlreadyMigrated()。重复跑不会产生第二份 goal/task。 * 库位置: 走 work-graph-db.getDb()(即 data/graph.sqlite,可被 GRAPH_DB_PATH 覆盖)—— * 必须走它而不是自己 new Database:goals / tasks 两张表的 DDL 就在它的 getDb() 里, * 别的入口连表都建不出来。 @@ -32,7 +36,8 @@ const REPO_ROOT = path.resolve(__dirname, '..'); const DRY = process.argv.includes('--dry-run'); const log = (...a) => console.log(DRY ? '[dry-run]' : '[migrate]', ...a); -const stats = { roots: 0, goals: 0, tasks: 0, runsPatched: 0, skipped: 0, noArtifact: 0 }; +const stats = { roots: 0, goals: 0, tasks: 0, runsPatched: 0, skipped: 0, noArtifact: 0, + tasklogFiles: 0, tasklogGoals: 0, tasklogTasks: 0, tasklogSkipped: 0, tasklogBad: 0 }; /** * root run 的产物目录 —— 形状必须与 graph-engine.runRootDir 逐字一致: @@ -195,6 +200,7 @@ function main() { try { db.transaction(() => { for (const run of roots) migrateRun(db, run); + migrateAllTasklog(db); if (DRY) throw ROLLBACK; })(); } catch (e) { @@ -209,6 +215,115 @@ function main() { else console.log('完成。'); } + +// ════════════════════════════════════════════════════════════════ +// 批 5:system/tasklog/*.json → tasks 表(executor='agent') +// ════════════════════════════════════════════════════════════════ + +const TASKLOG_DIR = path.join(REPO_ROOT, 'system', 'tasklog'); + +/** + * 派单任务落在哪家公司名下。 + * + * tasklog 的行**没有 company 这一维** —— 它记的是「谁派给谁」,而 from/to 都是本体 id。 + * 全局 agent 的会话本来就落在保留公司 id `_global` 下(28 §5.1),这里沿用同一个约定, + * 不去猜某条派单「其实属于哪家公司」:猜错会让它出现在错误的公司任务列表里, + * 而那比「都归到 _global」更难发现。 + */ +const DISPATCH_COMPANY = '_global'; + +/** tasklog 的 status → tasks.status。dispatched = 派出去了还没回话,落 running。 */ +const TASKLOG_STATUS = { + dispatched: 'running', + done: 'completed', + failed: 'failed', + blocked: 'blocked', + cancelled: 'cancelled', +}; + +/** tasklog 的正文把换行写成了
,标题要还原一下才不会糊成一长条 */ +function tasklogTitle(task) { + const plain = String(task || '').replace(//gi, '\n'); + return deriveTitle(plain) || '(无标题派单)'; +} + +/** + * 幂等:这条 tasklog 是不是已经回填过。 + * 判据用 **tasks.id === taskId**(回填时刻意沿用原 taskId,见 migrateTasklog 的理由)。 + */ +function tasklogAlreadyMigrated(db, taskId) { + return Boolean(db.prepare('SELECT 1 AS ok FROM tasks WHERE id = ?').get(taskId)); +} + +function migrateTasklog(db, file) { + let meta; + try { + meta = JSON.parse(fs.readFileSync(path.join(TASKLOG_DIR, file), 'utf-8')); + } catch (e) { + stats.tasklogBad += 1; + log(`⚠️ ${file} 解析失败,跳过: ${e.message}`); + return; + } + const taskId = meta.taskId || path.basename(file, '.json'); + if (tasklogAlreadyMigrated(db, taskId)) { stats.tasklogSkipped += 1; return; } + + const now = new Date().toISOString(); + const createdAt = meta.createdAt || now; + const title = tasklogTitle(meta.task); + + // 每条派单建一个 goal:`tasks.goal_id` 是 NOT NULL 的真外键,而派单没有「人提的需求」那一层。 + // 与 §六.3 给 `POST /:cid/tasks` 设计的兼容路径同一条 —— 模型保持干净,不给 goal_id 开可空的口子。 + const goalId = wdb.genGoalId(); + db.prepare(` + INSERT INTO goals (id, company_id, title, description, status, priority, + okr_node_id, created_by, report_to, created_at, updated_at, dispatched_at) + VALUES (@id, @cid, @title, @desc, @status, @prio, @okr, @by, @to, @at, @at, @at) + `).run({ + id: goalId, cid: DISPATCH_COMPANY, title: `来自 ${meta.from || '?'} 的派单`, + desc: String(meta.task || ''), status: meta.status === 'done' ? 'done' : 'active', + prio: meta.priority || 'P1', okr: meta.nodeId || null, + by: meta.from || null, to: meta.from || null, at: createdAt, + }); + stats.tasklogGoals += 1; + + // **沿用原 taskId 而不是新发一个**:conversations 目录名是 `task-{taskId}`、 + // 汇报链路按 taskId 找回这条派单、summary 里也互相引用。换 id 等于把这些引用全断掉, + // 而回填的目的正是「别让历史消失」。 + db.prepare(` + INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status, + execution_mode, depends_on, attempt, priority, created_by, + executor, assignee_agent_id, parent_task_id, + created_at, updated_at, queued_at, started_at, finished_at) + VALUES (@id, @gid, @cid, 1, @title, @desc, @status, + 'manual', '[]', 1, @prio, @by, + 'agent', @to, @parent, + @at, @at, @at, @at, @done) + `).run({ + id: taskId, gid: goalId, cid: DISPATCH_COMPANY, title, + desc: String(meta.task || ''), status: TASKLOG_STATUS[meta.status] || 'running', + prio: meta.priority || 'P1', by: meta.from || null, + to: meta.to || null, parent: meta.parentTaskId || null, + at: createdAt, done: meta.completedAt || null, + }); + stats.tasklogTasks += 1; +} + +function migrateAllTasklog(db) { + if (!fs.existsSync(TASKLOG_DIR)) { log('system/tasklog/ 不存在,跳过派单回填'); return; } + const files = fs.readdirSync(TASKLOG_DIR).filter((f) => f.endsWith('.json')).sort(); + stats.tasklogFiles = files.length; + log(`system/tasklog/ 共 ${files.length} 条派单`); + // ⚠️ parent_task_id 是**自引用外键**:父派单必须先入库。文件名按 taskId 排序 ≈ 按时间排序 + // (T-{base36 毫秒}),父一定早于子;真有乱序时下面那条 catch 会点名,不会静默丢。 + for (const f of files) { + try { migrateTasklog(db, f); } catch (e) { + stats.tasklogBad += 1; + log(`⚠️ ${f} 落库失败(多半是 parent_task_id 还没入库): ${e.message}`); + } + } +} + if (require.main === module) main(); -module.exports = { runRootDir, readTaskGoal, deriveTitle, TASK_STATUS, GOAL_STATUS }; +module.exports = { runRootDir, readTaskGoal, deriveTitle, TASK_STATUS, GOAL_STATUS, + migrateAllTasklog, tasklogTitle, TASKLOG_STATUS, DISPATCH_COMPANY }; diff --git a/server/tests/unit/tasklog-backfill.test.js b/server/tests/unit/tasklog-backfill.test.js new file mode 100644 index 0000000..9cc2c9d --- /dev/null +++ b/server/tests/unit/tasklog-backfill.test.js @@ -0,0 +1,72 @@ +/** + * 批 5 · `system/tasklog/*.json` → tasks 表(executor='agent')的回填。 + * + * 钉三件事: + * 1. **原 taskId 必须被沿用**。conversations 目录名是 `task-{taskId}`、汇报链路按 taskId + * 找回这条派单、summary 里互相引用。换一个新 id 等于把这些引用全断掉,而回填的目的 + * 恰恰是「别让历史消失」。 + * 2. 幂等 —— 重复跑不产生第二份。 + * 3. 每条派单自带一个 goal(tasks.goal_id 是 NOT NULL 真外键,而派单没有「人提的需求」那一层)。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createRequire } from 'module'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +const require_ = createRequire(import.meta.url); + +describe('tasklog 回填', () => { + let wdb, mig, dir, dbPath; + + beforeEach(() => { + // 见 tasks-executor-migration.test.js 同处注释:不自己拼时间戳,用 mkdtempSync + dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'tasklog-bf-')), 'graph.sqlite'); + process.env.GRAPH_DB_PATH = dbPath; + for (const m of ['../../services/work-graph-db', '../../services/graph-db', '../../../scripts/migrate-runs-to-goal-task']) { + delete require_.cache[require_.resolve(m)]; + } + wdb = require_('../../services/work-graph-db'); + mig = require_('../../../scripts/migrate-runs-to-goal-task'); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tasklog-')); + }); + afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }); + + /** 脚本读的是仓库里的 system/tasklog;这里直接调它的单条逻辑不现实,改为验它对真目录的结果。 + * 所以这条用例走「真目录已被回填过」的幂等面 + 形态断言。 */ + it('回填出来的行形态对:executor=agent、assignee 指向 to、沿用原 taskId、自带 goal', () => { + const db = wdb.getDb(); + mig.migrateAllTasklog(db); + const rows = db.prepare("SELECT * FROM tasks WHERE executor = 'agent'").all(); + if (rows.length === 0) return; // 仓库里没有 tasklog 时这条不适用 + + for (const r of rows) { + expect(r.id).toMatch(/^T-/); // 沿用原 taskId(不是新发的 T-xxx-4hex) + expect(r.assignee_agent_id).toBeTruthy(); // 派给谁必须记下来 + expect(r.company_id).toBe(mig.DISPATCH_COMPANY); + const goal = db.prepare('SELECT * FROM goals WHERE id = ?').get(r.goal_id); + expect(goal).toBeTruthy(); // NOT NULL 真外键,每条派单自带一个 goal + expect(goal.title).toMatch(/来自 .* 的派单/); + } + }); + + it('幂等:连跑两次不产生第二份', () => { + const db = wdb.getDb(); + mig.migrateAllTasklog(db); + const n1 = db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE executor='agent'").get().n; + const g1 = db.prepare('SELECT COUNT(*) AS n FROM goals').get().n; + mig.migrateAllTasklog(db); + expect(db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE executor='agent'").get().n).toBe(n1); + expect(db.prepare('SELECT COUNT(*) AS n FROM goals').get().n).toBe(g1); + }); + + it('标题把
还原成换行再取首行 —— 否则整条正文糊成一长条', () => { + expect(mig.tasklogTitle('第一行
第二行')).toBe('第一行'); + expect(mig.tasklogTitle('第一行
第二行')).toBe('第一行'); + expect(mig.tasklogTitle('')).toBe('(无标题派单)'); + }); + + it('dispatched 落 running —— 派出去了还没回话,不是终态', () => { + expect(mig.TASKLOG_STATUS.dispatched).toBe('running'); + expect(mig.TASKLOG_STATUS.done).toBe('completed'); + }); +}); diff --git a/server/tests/unit/tasks-executor-migration.test.js b/server/tests/unit/tasks-executor-migration.test.js index 45e996a..ea3ad86 100644 --- a/server/tests/unit/tasks-executor-migration.test.js +++ b/server/tests/unit/tasks-executor-migration.test.js @@ -9,6 +9,7 @@ */ import { describe, it, expect } from 'vitest'; import { createRequire } from 'module'; +import fs from 'fs'; import os from 'os'; import path from 'path'; const require_ = createRequire(import.meta.url); @@ -28,7 +29,10 @@ function freshDb(dbPath) { describe('tasks 表的批 5 迁移', () => { it('存量库(没有这三列)重启后能补上,且不炸', () => { - const dbPath = path.join(os.tmpdir(), `b5-mig-${Date.now()}-${Math.floor(process.hrtime()[1] / 1e3)}.sqlite`); + // 用 mkdtempSync 拿唯一目录,而不是自己拿 Date.now() 拼文件名 —— + // 后者会撞 test-id-stability 那条守卫(测试里不许用时间戳拼 id),而且 mkdtempSync + // 本来就是仓库既有写法(graph-engine.test.js 的 runsDir 就是它)。 + const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'b5-mig-')), 'graph.sqlite'); const wdb = freshDb(dbPath); // 退回加列之前的形态:索引先删(它引用了要删的列),再删三列 @@ -59,7 +63,7 @@ describe('tasks 表的批 5 迁移', () => { }); it('新建库直接就是目标形态(三列 + 索引齐全)', () => { - const dbPath = path.join(os.tmpdir(), `b5-new-${Date.now()}-${Math.floor(process.hrtime()[1] / 1e3)}.sqlite`); + const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'b5-new-')), 'graph.sqlite'); const wdb = freshDb(dbPath); const names = wdb.getDb().pragma('table_info(tasks)').map((c) => c.name); for (const c of NEW_COLS) expect(names).toContain(c);