migrate-runs-to-goal-task.js
9.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
#!/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 };