migrate-runs-to-goal-task.js 9.34 KB
#!/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 };