#!/usr/bin/env node /** * migrate-runs-to-goal-task.js — 把存量 root run 回填成 goal + task 两级行 * * 依据: docs/todo/system-design/27-p3c-goal-task-model.md §四.8 * * 为什么必须有这个脚本:批 1 建了 goals / tasks 两张表,左栏接下来要改成读 `tasks`。 * 而**既有的 root run 在 tasks 里一行都没有** —— 那一改,所有历史任务会从界面上整个消失。 * 本文档要修的第一个问题(A:历史任务看不见)会被批 1 自己反向放大一次。所以回填不是 * 收尾的可选项,它跟建表是同一件事的两半。 * * 一条 root run 拆回两级: * goals 一条 —— 人当初提的那句需求(描述从 artifacts/task-goal.json 的 task 字段读回) * tasks 一条 —— 那次执行单元,seq=1、depends_on=[]、current_run_id 指回这条 run * graph_runs.goal_id / task_id 回填指向新行(这两列从 P3 起就在写,一直没有表接得住) * * 用法: * node scripts/migrate-runs-to-goal-task.js --dry-run # 预演,事务回滚,不留任何行 * node scripts/migrate-runs-to-goal-task.js # 实际执行 * * 幂等: 见 alreadyMigrated()。重复跑不会产生第二份 goal/task。 * 库位置: 走 work-graph-db.getDb()(即 data/graph.sqlite,可被 GRAPH_DB_PATH 覆盖)—— * 必须走它而不是自己 new Database:goals / tasks 两张表的 DDL 就在它的 getDb() 里, * 别的入口连表都建不出来。 */ const fs = require('fs'); const path = require('path'); const wdb = require('../server/services/work-graph-db'); 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 }; /** * root run 的产物目录 —— 形状必须与 graph-engine.runRootDir 逐字一致: * 覆盖态是扁平的 {GRAPH_RUNS_DIR}/{rootRunId}(**没有** _companies/{cid}/runs 这一段), * 默认态才是 data/companies/{cid}/runs/{rootRunId}。两边算不出同一个字符串的后果不是报错, * 是「读不到 task-goal.json」→ 所有历史任务都退化成 '(历史任务)' 占位标题,而且一声不吭。 * 与 company-paths 一样**每次调用读 env**,不在模块加载时快照:脚本可能被先 require 再设 env。 */ function runRootDir(run) { const runsRoot = process.env.GRAPH_RUNS_DIR || null; return runsRoot ? path.join(runsRoot, run.id) : path.join(REPO_ROOT, 'data', 'companies', run.company_id, 'runs', run.id); } /** 读回当初下达的那句话;读不到返回 null(目录被清理、或 run 老到还没有这个 artifact) */ function readTaskGoal(run) { try { const j = JSON.parse(fs.readFileSync(path.join(runRootDir(run), 'artifacts', 'task-goal.json'), 'utf-8')); const task = typeof j.task === 'string' ? j.task.trim() : ''; return task || null; } catch { return null; } } /** * run 状态 → task 状态。task 的取值域里没有 paused / ready / planning, * 那三种都还挂着一条活的 run,对任务列表来说就是「在跑」。 * stopped 是人按的停,映射成 cancelled 而不是 failed —— 后者会让历史列表凭空多出一批红条。 */ const TASK_STATUS = { completed: 'completed', failed: 'failed', blocked: 'blocked', stopped: 'cancelled', planning: 'running', ready: 'running', running: 'running', paused: 'running', }; /** * run 状态 → goal 状态(§四.8:completed → done,其余 → active / cancelled)。 * 注意 **failed 的 run 对应的 goal 是 active 而不是 failed**:goals.failed 的语义是 * 「goal 级验收没过」(§四.10 的 org-validator 判词),历史 run 从没跑过那道验收, * 硬标 failed 是替一个没发生过的判决下结论。active = 还没收工、可补 task 重下, * 正是这批遗留 run 的真实处境。 */ const GOAL_STATUS = { completed: 'done', stopped: 'cancelled' }; /** 列表用的短标题:取描述第一行,截 60 字 */ function deriveTitle(desc) { const line = String(desc).split('\n')[0].trim(); return line.length > 60 ? `${line.slice(0, 60)}…` : line; } /** * 幂等判据(两条,缺一条都会漏): * 1. 已有 task 的 current_run_id 指着它 —— 本脚本上一次跑出来的行 * 2. run.task_id 已经是 tasks 表里的真行 —— 批 2 之后由新版 POST /:cid/tasks 正常建出来的, * 那种 run 根本不是「存量」,回填只会给它凭空多一个 goal */ function alreadyMigrated(db, run) { const byRun = db.prepare(`SELECT id FROM tasks WHERE current_run_id = ?`).get(run.id); if (byRun) return byRun.id; if (run.task_id) { const byId = db.prepare(`SELECT id FROM tasks WHERE id = ?`).get(run.task_id); if (byId) return byId.id; } return null; } /** 生成一个当前表里没被占用的 id(§四.5 的 4 位 hex 只有 65536 种取值,同毫秒批量建会撞) */ function freshId(db, table, gen) { for (let i = 0; i < 5; i++) { const id = gen(); if (!db.prepare(`SELECT 1 FROM ${table} WHERE id = ?`).get(id)) return id; } throw new Error(`${table} 连续 5 次 id 碰撞,异常,中止`); } function migrateRun(db, run) { const done = alreadyMigrated(db, run); if (done) { log(`跳过(已回填): run=${run.id} → task=${done}`); stats.skipped++; return; } const spoken = readTaskGoal(run); if (!spoken) stats.noArtifact++; const description = spoken || `历史任务 ${run.task_id || run.id}(原始描述已丢失)`; const title = spoken ? deriveTitle(spoken) : '(历史任务)'; const goalId = freshId(db, 'goals', 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 (?, ?, ?, ?, ?, 'P1', ?, 'migration', NULL, ?, ?, ?) `).run( goalId, run.company_id, title, description, GOAL_STATUS[run.run_status] || 'active', // 禁令一(§四.7):okr_node_id 只存在 goals 上。run 上那一份**不清** —— // 存量 run 的这列是历史事实,清空是不可逆的破坏性写,超出「回填」的授权范围。 run.okr_node_id || null, run.created_at, run.updated_at, // 历史任务早就下达过了,dispatched_at 非空 = 描述立即冻结(§四.4)。本来也不该再改。 run.created_at ); stats.goals++; // task id 优先**沿用 run.task_id**:产物目录是 tasks/{taskId}/(company-paths.taskDirRel), // 换一个新 id 等于让已落盘的历史产物全部认不回来。只有 task_id 为空或已被占用才新生成。 let taskId = run.task_id && !db.prepare(`SELECT 1 FROM tasks WHERE id = ?`).get(run.task_id) ? run.task_id : freshId(db, 'tasks', wdb.genTaskId); const terminal = ['completed', 'failed', 'blocked', 'stopped'].includes(run.run_status); db.prepare(` INSERT INTO tasks ( id, goal_id, company_id, seq, title, description, status, execution_mode, depends_on, budget_json, current_run_id, attempt, report_to, created_by, blocked_reason, priority, created_at, updated_at, queued_at, started_at, finished_at ) VALUES (?, ?, ?, 1, ?, ?, ?, ?, '[]', ?, ?, 1, NULL, 'migration', NULL, 'P1', ?, ?, ?, ?, ?) `).run( taskId, goalId, run.company_id, title, description, TASK_STATUS[run.run_status] || 'running', run.execution_mode || 'manual', run.budget_json || null, run.id, run.created_at, run.updated_at, // queued_at / started_at 都取 run.created_at:这条 run 存在本身就是「已入队且已起跑」的证据, // 真实时刻无处可考,用建 run 的时间是唯一不会编造顺序的选择。 run.created_at, run.created_at, terminal ? run.updated_at : null ); stats.tasks++; db.prepare(`UPDATE graph_runs SET goal_id = ?, task_id = ? WHERE id = ?`).run(goalId, taskId, run.id); stats.runsPatched++; log(`回填: run=${run.id} [${run.run_status}] → goal=${goalId} task=${taskId} 「${title}」`); } function main() { log(DRY ? '=== DRY RUN(事务结束回滚,不留任何行)===' : '=== 开始回填 ==='); const db = wdb.getDb(); const roots = db.prepare(` SELECT * FROM graph_runs WHERE parent_run_id IS NULL ORDER BY created_at `).all(); stats.roots = roots.length; log(`root run 共 ${roots.length} 条`); // 预演也走真事务再回滚,不是「if (!DRY) 跳过写」:后者撞不上外键与唯一约束, // 预演报「将建 N 条」而真跑第 3 条就炸,等于没预演。 const ROLLBACK = new Error('dry-run rollback'); try { db.transaction(() => { for (const run of roots) migrateRun(db, run); if (DRY) throw ROLLBACK; })(); } catch (e) { if (e !== ROLLBACK) throw e; } console.log('\n' + (DRY ? '[dry-run] ' : '') + '汇总:', JSON.stringify(stats)); if (stats.noArtifact) { console.log(`注意: ${stats.noArtifact} 条 run 读不到 artifacts/task-goal.json,描述用占位文本。`); } if (DRY) console.log('这是 dry-run,事务已回滚,未做任何改动。去掉 --dry-run 实际执行。'); else console.log('完成。'); } if (require.main === module) main(); module.exports = { runRootDir, readTaskGoal, deriveTitle, TASK_STATUS, GOAL_STATUS };