migrate-runs-to-goal-task.js
15.6 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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
#!/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 # 实际执行
*
* 【批 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() 里,
* 别的入口连表都建不出来。
*/
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,
tasklogFiles: 0, tasklogGoals: 0, tasklogTasks: 0, tasklogSkipped: 0, tasklogBad: 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);
migrateAllTasklog(db);
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('完成。');
}
// ════════════════════════════════════════════════════════════════
// 批 5:system/tasklog/*.json → tasks 表(executor='agent')
// ════════════════════════════════════════════════════════════════
/**
* 派单旧存储的位置。
*
* 批 5 把它统一进 `tasks` 表之后,目录**改名**为 `system/tasklog.bak-unify/` 而不是直接删
* (§十二.5:跑顺一个版本再清)。所以这里两处都认:先看原位置(还没改名的库),
* 再看备份位置(改过名的)。**顺序不能反** —— 万一有人从备份恢复出一份新的 tasklog,
* 那份才是要回填的。
*/
function tasklogDir() {
for (const d of ['tasklog', 'tasklog.bak-unify']) {
const full = path.join(REPO_ROOT, 'system', d);
if (fs.existsSync(full)) return full;
}
return path.join(REPO_ROOT, 'system', 'tasklog'); // 都不在时返回原位置,下面 existsSync 会跳过
}
/**
* 派单任务落在哪家公司名下。
*
* 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 的正文把换行写成了 <br>,标题要还原一下才不会糊成一长条 */
function tasklogTitle(task) {
const plain = String(task || '').replace(/<br\s*\/?>/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(tasklogDir(), 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) {
const TASKLOG_DIR = tasklogDir();
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,
migrateAllTasklog, tasklogTitle, TASKLOG_STATUS, DISPATCH_COMPANY };