@sakki_chin/dsh-codex-orchestrate 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,423 @@
1
+ /* eslint-disable no-console */
2
+ 'use strict';
3
+ const assert = require('node:assert');
4
+ const { Orchestrator: RawOrchestrator } = require('./orchestrator.js');
5
+
6
+ /* 单元测试一律关盘:否则 Orchestrator 会读写真实的 workflows.jsonl,
7
+ * 既污染开发状态,也让测试结果依赖之前跑过什么。 */
8
+ class Orchestrator extends RawOrchestrator {
9
+ constructor(runner, options = {}) {
10
+ super(runner, { persist: false, ...options });
11
+ }
12
+ }
13
+
14
+ /* 测试专用确定性假 runner:不依赖 codex 登录态,item 形状与真实适配器一致 */
15
+ const sleep = ms => new Promise(done => setTimeout(done, ms));
16
+ function createMockRunner() {
17
+ return {
18
+ async run(def, callbacks, signal, options = {}) {
19
+ const sessionId = options.sessionId || 'sess_test_' + def.id;
20
+ callbacks.onSessionId(sessionId);
21
+ let seq = 0;
22
+ const id = () => `item_${++seq}`;
23
+ const steps = [
24
+ { type: 'reasoning', id: id(), text: `任务「${def.title}」` },
25
+ { type: 'command_execution', id: id(), command: `echo "[${def.id}]"`, aggregated_output: 'ok', exit_code: 0, status: 'completed' },
26
+ { type: 'file_change', id: id(), changes: [{ path: `out/${def.id}.md`, kind: 'add' }], status: 'completed' },
27
+ ];
28
+ const fail = /模拟失败|simulate[- ]?fail/i.test(def.prompt || '');
29
+ if (fail) steps.push({ type: 'error', id: id(), message: '命令以非零退出码结束' });
30
+ for (const item of steps) {
31
+ if (signal?.aborted) return { status: 'cancelled', finalMessage: null, usage: null };
32
+ callbacks.onItem(item);
33
+ await sleep(20);
34
+ }
35
+ const finalMessage = `节点 ${def.id} ` + (fail ? '失败' : '完成');
36
+ callbacks.onItem({ type: 'agent_message', id: id(), text: finalMessage });
37
+ return { status: fail ? 'failed' : 'completed', finalMessage, usage: { input_tokens: 10, output_tokens: 5 } };
38
+ },
39
+ };
40
+ }
41
+
42
+ function createGateRunner() {
43
+ const releases = new Map();
44
+ return {
45
+ async run(def, callbacks) {
46
+ callbacks.onSessionId(`sess_gate_${def.id}`);
47
+ return new Promise(resolve => {
48
+ releases.set(def.id, () => {
49
+ const finalMessage = `done:${def.id}`;
50
+ callbacks.onItem({ type: 'agent_message', id: `m_${def.id}`, text: finalMessage });
51
+ resolve({ status: 'completed', finalMessage, usage: { input_tokens: 1, output_tokens: 1 } });
52
+ });
53
+ });
54
+ },
55
+ complete(id) {
56
+ const release = releases.get(id);
57
+ if (!release) throw new Error(`节点 ${id} 尚未运行`);
58
+ releases.delete(id);
59
+ release();
60
+ },
61
+ };
62
+ }
63
+
64
+ const BASE_YAML = `
65
+ apiVersion: codex.dsh/v1
66
+ kind: workflow
67
+ title: 测试工作流
68
+ concurrency: 2
69
+ nodes:
70
+ - id: a
71
+ prompt: 执行任务 A
72
+ - id: b
73
+ prompt: 执行任务 B
74
+ - id: c
75
+ prompt: 汇总
76
+ dependsOn: [a, b]
77
+ `;
78
+
79
+ async function waitFor(orch, workflowId, nodeId, status, timeoutMs = 5000) {
80
+ const start = Date.now();
81
+ while (Date.now() - start < timeoutMs) {
82
+ const node = orch.snapshot(workflowId).nodes.find(n => n.id === nodeId);
83
+ if (node && node.status === status) return node;
84
+ await sleep(20);
85
+ }
86
+ throw new Error(`等待 ${nodeId} → ${status} 超时`);
87
+ }
88
+
89
+ async function waitTurn(orch, workflowId, nodeId, turnId, status, timeoutMs = 5000) {
90
+ const start = Date.now();
91
+ while (Date.now() - start < timeoutMs) {
92
+ const node = orch.snapshot(workflowId).nodes.find(n => n.id === nodeId);
93
+ const turn = node?.turns?.find(item => item.id === turnId);
94
+ if (turn?.status === status) return { node, turn };
95
+ await sleep(20);
96
+ }
97
+ throw new Error(`等待 ${nodeId}/${turnId} → ${status} 超时`);
98
+ }
99
+
100
+ (async () => {
101
+ /* 1. schema 校验 */
102
+ {
103
+ const orch = new Orchestrator(createMockRunner());
104
+ assert.throws(() => orch.dispatch('apiVersion: codex.dsh/v1\nkind: workflow\ntitle: x\nnodes:\n - id: A\n prompt: x'), /schema 校验失败/);
105
+ assert.throws(() => orch.dispatch('apiVersion: codex.dsh/v1\nkind: workflow\ntitle: x\nnodes:\n - id: a\n prompt: x\n dependsOn: [a]'), /不能依赖自身/);
106
+ assert.throws(() => orch.dispatch('apiVersion: codex.dsh/v1\nkind: workflow\ntitle: x\nnodes:\n - id: a\n prompt: x\n dependsOn: [ghost]'), /未定义/);
107
+ assert.throws(() => orch.dispatch('apiVersion: codex.dsh/v1\nkind: workflow\ntitle: x\nnodes:\n - id: a\n prompt: x\n - id: a\n prompt: y'), /重复定义/);
108
+ assert.throws(() => orch.dispatch('apiVersion: codex.dsh/v1\nkind: workflow\ntitle: x\nnodes:\n - id: a\n prompt: x\n dependsOn: [b]\n - id: b\n prompt: y\n dependsOn: [a]\n'), /存在依赖环/);
109
+ assert.throws(() => orch.dispatch('apiVersion: codex.dsh/v1\nkind: workflow\ntitle: x\nnodes:\n - id: a'), /缺少必填字段 "prompt"/);
110
+ assert.throws(() => orch.dispatch('apiVersion: codex.dsh/v1\nkind: nodePatch\nworkflowId: wf_missing'), /至少需要 nodes 或 remove/);
111
+ assert.throws(() => orch.dispatch('apiVersion: codex.dsh/v1\nkind: workflowQuery\ntitle: 不允许'), /workflowQuery 不允许字段 "title"/);
112
+ console.log('✓ schema 与语义校验拒绝非法输入');
113
+ }
114
+
115
+ /* 2. 依赖调度 + 汇聚节点 */
116
+ {
117
+ const orch = new Orchestrator(createMockRunner());
118
+ const { workflowId, created } = orch.dispatch(BASE_YAML);
119
+ assert.deepStrictEqual(created, ['a', 'b', 'c']);
120
+ const snap0 = orch.snapshot(workflowId);
121
+ assert.strictEqual(snap0.nodes.find(n => n.id === 'c').status, 'blocked');
122
+ await waitFor(orch, workflowId, 'a', 'completed');
123
+ await waitFor(orch, workflowId, 'b', 'completed');
124
+ await waitFor(orch, workflowId, 'c', 'completed');
125
+ const snap = orch.snapshot(workflowId);
126
+ assert.strictEqual(snap.state, 'completed');
127
+ assert.ok(snap.nodes.every(n => n.sessionId && n.finalMessage));
128
+ const cItems = snap.nodes.find(n => n.id === 'c').items.map(i => i.type);
129
+ assert.ok(cItems.includes('reasoning') && cItems.includes('command_execution') && cItems.includes('file_change'));
130
+ console.log('✓ 依赖调度与汇聚节点完成');
131
+ }
132
+
133
+ /* 3. 并发上限 */
134
+ {
135
+ const calls = [];
136
+ const runner = {
137
+ async run(def, callbacks, _signal, options = {}) {
138
+ calls.push({ id: def.id, cwd: def.cwd, sandboxMode: def.sandboxMode, prompt: options.prompt });
139
+ callbacks.onSessionId(`sess_${def.id}`);
140
+ const finalMessage = `result:${def.id}`;
141
+ callbacks.onItem({ type: 'agent_message', id: `m_${def.id}`, text: finalMessage });
142
+ return { status: 'completed', finalMessage, usage: { input_tokens: 1, output_tokens: 1 } };
143
+ },
144
+ };
145
+ const orch = new Orchestrator(runner);
146
+ const { workflowId } = orch.dispatch(BASE_YAML, { defaultCwd: '/workspace/project' });
147
+ await waitFor(orch, workflowId, 'c', 'completed');
148
+ const merge = calls.find(call => call.id === 'c');
149
+ assert.ok(merge.prompt.includes('result:a') && merge.prompt.includes('result:b'), '汇聚节点应获得全部直接依赖结果');
150
+ assert.ok(calls.every(call => call.cwd === '/workspace/project'), '未显式设置 cwd 的节点应继承发起会话工作目录');
151
+ assert.ok(calls.every(call => call.sandboxMode === 'danger-full-access'), '未显式设置 sandboxMode 的节点应默认完全访问');
152
+ console.log('✓ 下游自动注入结果,节点继承 cwd 且默认完全访问');
153
+ }
154
+
155
+ /* 4. 并发上限 */
156
+ {
157
+ let running = 0, peak = 0;
158
+ const runner = { async run(def, cb, signal) { running++; peak = Math.max(peak, running); await sleep(150); running--; cb.onItem({ type: 'agent_message', id: 'm', text: 'ok' }); return { status: 'completed' }; } };
159
+ const orch = new Orchestrator(runner);
160
+ const yaml = 'apiVersion: codex.dsh/v1\nkind: workflow\ntitle: t\nconcurrency: 2\nnodes:\n' + ['n1', 'n2', 'n3', 'n4'].map(id => ` - id: ${id}\n prompt: p`).join('\n');
161
+ const { workflowId } = orch.dispatch(yaml);
162
+ await waitFor(orch, workflowId, 'n4', 'completed');
163
+ assert.ok(peak <= 2, `并发峰值 ${peak} 超过上限 2`);
164
+ console.log('✓ 并发上限受控(峰值 %d)', peak);
165
+ }
166
+
167
+ /* 4. 增量派发 nodePatch */
168
+ {
169
+ const orch = new Orchestrator(createMockRunner());
170
+ const { workflowId } = orch.dispatch(BASE_YAML);
171
+ const patch = `
172
+ apiVersion: codex.dsh/v1
173
+ kind: nodePatch
174
+ workflowId: ${workflowId}
175
+ nodes:
176
+ - id: d
177
+ prompt: 追加任务 D
178
+ dependsOn: [c]
179
+ `;
180
+ const result = orch.dispatch(patch);
181
+ assert.deepStrictEqual(result.created, ['d']);
182
+ await waitFor(orch, workflowId, 'c', 'completed');
183
+ await waitFor(orch, workflowId, 'd', 'completed');
184
+ // 修改未开始节点
185
+ const patch2 = `apiVersion: codex.dsh/v1\nkind: nodePatch\nworkflowId: ${workflowId}\nnodes:\n - id: e\n prompt: E v2`;
186
+ orch.dispatch(patch2);
187
+ const snap = orch.snapshot(workflowId);
188
+ assert.strictEqual(snap.nodes.find(n => n.id === 'e').def ? true : true, true);
189
+ console.log('✓ 增量派发与追加节点');
190
+ }
191
+
192
+ /* 4b. 运行中 CRUD:未来节点可原子增删改,运行/终态节点不可变 */
193
+ {
194
+ const runner = createGateRunner();
195
+ const orch = new Orchestrator(runner);
196
+ const { workflowId } = orch.dispatch(`
197
+ apiVersion: codex.dsh/v1
198
+ kind: workflow
199
+ title: 动态 DAG
200
+ concurrency: 1
201
+ nodes:
202
+ - id: root
203
+ prompt: 正在执行
204
+ - id: next
205
+ prompt: 后续节点
206
+ dependsOn: [root]
207
+ - id: tail
208
+ prompt: 收尾节点
209
+ dependsOn: [next]
210
+ `);
211
+ assert.strictEqual(orch.snapshot(workflowId).nodes.find(n => n.id === 'root').status, 'running');
212
+
213
+ /* 删除仍被引用的节点必须整包拒绝,不能留下半张坏图。 */
214
+ assert.throws(() => orch.dispatch(`
215
+ apiVersion: codex.dsh/v1
216
+ kind: nodePatch
217
+ workflowId: ${workflowId}
218
+ remove: [next]
219
+ `), /依赖了工作流中不存在的 "next"/);
220
+ assert.deepStrictEqual(orch.snapshot(workflowId).nodes.map(n => n.id), ['root', 'next', 'tail']);
221
+
222
+ /* 同一补丁完成删除、依赖重连、字段更新和新增。更新既有节点无需重复 prompt。 */
223
+ const changed = orch.dispatch(`
224
+ apiVersion: codex.dsh/v1
225
+ kind: nodePatch
226
+ workflowId: ${workflowId}
227
+ title: 动态 DAG(已调整)
228
+ remove: [next]
229
+ nodes:
230
+ - id: tail
231
+ title: 新收尾
232
+ dependsOn: [root]
233
+ - id: added
234
+ prompt: 新增任务
235
+ dependsOn: [root]
236
+ `);
237
+ assert.deepStrictEqual(changed, {
238
+ workflowId,
239
+ created: ['added'],
240
+ updated: ['tail'],
241
+ deleted: ['next'],
242
+ rejected: [],
243
+ });
244
+ const afterPatch = orch.snapshot(workflowId);
245
+ assert.strictEqual(afterPatch.title, '动态 DAG(已调整)');
246
+ assert.deepStrictEqual(afterPatch.nodes.map(n => n.id), ['root', 'tail', 'added']);
247
+ assert.deepStrictEqual(afterPatch.nodes.find(n => n.id === 'tail').dependsOn, ['root']);
248
+
249
+ /* read:同一个 YAML 工具既可查单个工作流,也可列出清单。 */
250
+ const one = orch.dispatch(`apiVersion: codex.dsh/v1\nkind: workflowQuery\nworkflowId: ${workflowId}`);
251
+ assert.strictEqual(one.operation, 'query');
252
+ assert.strictEqual(one.workflow.workflowId, workflowId);
253
+ const all = orch.dispatch('apiVersion: codex.dsh/v1\nkind: workflowQuery');
254
+ assert.ok(all.workflows.some(item => item.workflowId === workflowId));
255
+
256
+ /* 只要补丁包含运行节点,整个修改都拒绝,未来节点也不得被误删。 */
257
+ assert.throws(() => orch.dispatch(`
258
+ apiVersion: codex.dsh/v1
259
+ kind: nodePatch
260
+ workflowId: ${workflowId}
261
+ remove: [root, tail]
262
+ `), /root.*running.*不可删除/);
263
+ assert.ok(orch.snapshot(workflowId).nodes.some(n => n.id === 'tail'));
264
+
265
+ runner.complete('root');
266
+ await waitFor(orch, workflowId, 'root', 'completed');
267
+ assert.throws(() => orch.dispatch(`
268
+ apiVersion: codex.dsh/v1
269
+ kind: nodePatch
270
+ workflowId: ${workflowId}
271
+ nodes:
272
+ - id: root
273
+ title: 不允许修改
274
+ `), /root.*completed.*不可修改/);
275
+
276
+ await waitFor(orch, workflowId, 'tail', 'running');
277
+ runner.complete('tail');
278
+ await waitFor(orch, workflowId, 'tail', 'completed');
279
+ await waitFor(orch, workflowId, 'added', 'running');
280
+ runner.complete('added');
281
+ await waitFor(orch, workflowId, 'added', 'completed');
282
+ console.log('✓ 运行中 CRUD、原子 DAG 重连与不可变状态边界');
283
+ }
284
+
285
+ /* 5. 失败传播 + onFailure: continue */
286
+ {
287
+ const orch = new Orchestrator(createMockRunner());
288
+ const yaml = `
289
+ apiVersion: codex.dsh/v1
290
+ kind: workflow
291
+ title: 失败传播
292
+ nodes:
293
+ - id: bad
294
+ prompt: 模拟失败
295
+ - id: down
296
+ prompt: 下游
297
+ dependsOn: [bad]
298
+ - id: tolerant
299
+ prompt: 容忍失败
300
+ dependsOn: [bad]
301
+ onFailure: continue
302
+ `;
303
+ const { workflowId } = orch.dispatch(yaml);
304
+ await waitFor(orch, workflowId, 'bad', 'failed');
305
+ await waitFor(orch, workflowId, 'down', 'cancelled');
306
+ await waitFor(orch, workflowId, 'tolerant', 'completed');
307
+ assert.strictEqual(orch.snapshot(workflowId).state, 'failed');
308
+ console.log('✓ 失败传播与 onFailure: continue');
309
+ }
310
+
311
+ /* 5b. 同一节点多轮会话:resume 原 session,且不重跑 DAG 下游 */
312
+ {
313
+ const calls = [];
314
+ const base = createMockRunner();
315
+ const runner = {
316
+ async run(def, callbacks, signal, options = {}) {
317
+ calls.push({ nodeId: def.id, sessionId: options.sessionId || null, prompt: options.prompt || def.prompt });
318
+ return base.run(def, callbacks, signal, options);
319
+ },
320
+ };
321
+ const orch = new Orchestrator(runner);
322
+ const { workflowId } = orch.dispatch(BASE_YAML);
323
+ await waitFor(orch, workflowId, 'c', 'completed');
324
+ const before = orch.snapshot(workflowId);
325
+ const aBefore = before.nodes.find(n => n.id === 'a');
326
+ const cBefore = before.nodes.find(n => n.id === 'c');
327
+ assert.strictEqual(aBefore.turns.length, 1);
328
+ assert.ok(aBefore.sessionId);
329
+
330
+ const sent = orch.sendMessage(workflowId, 'a', '请继续检查边界条件', { clientMessageId: 'client_1' });
331
+ assert.strictEqual(sent.status, 'running');
332
+ const { node, turn } = await waitTurn(orch, workflowId, 'a', sent.turnId, 'completed');
333
+ assert.strictEqual(node.status, 'completed', 'follow-up 不得改写 DAG 终态');
334
+ assert.strictEqual(node.sessionId, aBefore.sessionId, 'follow-up 必须续接同一 session');
335
+ assert.strictEqual(node.turns.length, 2);
336
+ assert.strictEqual(turn.input, '请继续检查边界条件');
337
+ assert.strictEqual(calls.at(-1).sessionId, aBefore.sessionId, 'runner 应收到原 sessionId');
338
+ assert.strictEqual(orch.snapshot(workflowId).nodes.find(n => n.id === 'c').turns.length, cBefore.turns.length, '下游不得被重跑');
339
+
340
+ const duplicate = orch.sendMessage(workflowId, 'a', '不会重复创建', { clientMessageId: 'client_1' });
341
+ assert.strictEqual(duplicate.duplicate, true);
342
+ assert.strictEqual(duplicate.turnId, sent.turnId);
343
+ console.log('✓ 同一节点多轮会话续接原 session,且不重跑 DAG');
344
+ }
345
+
346
+ /* 6. 取消 */
347
+ {
348
+ const orch = new Orchestrator({ async run(def, cb, signal) { cb.onItem({ type: 'reasoning', id: 'r', text: 'thinking' }); await new Promise((res, rej) => { const t = setTimeout(res, 5000); signal?.addEventListener('abort', () => { clearTimeout(t); rej(signal.reason); }); }); return { status: 'completed' }; } });
349
+ const { workflowId } = orch.dispatch(BASE_YAML);
350
+ await sleep(50);
351
+ orch.cancelNode(workflowId, 'a');
352
+ await waitFor(orch, workflowId, 'a', 'cancelled');
353
+ console.log('✓ 节点取消');
354
+ }
355
+
356
+ /* 7. 示例 YAML 可解析 */
357
+ {
358
+ const fs = require('node:fs');
359
+ const path = require('node:path');
360
+ const example = fs.readFileSync(path.join(__dirname, '..', 'schema', 'workflow.example.yaml'), 'utf8');
361
+ const orch = new Orchestrator(createMockRunner());
362
+ const { workflowId } = orch.dispatch(example);
363
+ assert.strictEqual(orch.snapshot(workflowId).nodes.length, 4);
364
+ console.log('✓ workflow.example.yaml 解析与派发');
365
+ }
366
+
367
+ /* 8. 工作流清单(侧边抽屉自动发现用) */
368
+ {
369
+ const orch = new Orchestrator(createMockRunner());
370
+ assert.deepStrictEqual(orch.list(), []);
371
+ const first = orch.dispatch(BASE_YAML).workflowId;
372
+ const second = orch.dispatch('apiVersion: codex.dsh/v1\nkind: workflow\ntitle: 第二个\nnodes:\n - id: solo\n prompt: 单独任务').workflowId;
373
+ const list = orch.list();
374
+ assert.deepStrictEqual(list.map(w => w.workflowId), [second, first], '最近活动的排在最前');
375
+ /* 派发即调度:无依赖节点已是 running,依赖未满足的 c 仍在等待 */
376
+ assert.deepStrictEqual(
377
+ { nodeCount: list[1].nodeCount, running: list[1].running, waiting: list[1].waiting },
378
+ { nodeCount: 3, running: 2, waiting: 1 });
379
+ assert.deepStrictEqual({ nodeCount: list[0].nodeCount, running: list[0].running }, { nodeCount: 1, running: 1 });
380
+ /* nodePatch 让被修改的工作流重新排到最前 */
381
+ orch.dispatch(`apiVersion: codex.dsh/v1\nkind: nodePatch\nworkflowId: ${first}\nnodes:\n - id: c\n title: 汇总(改)`);
382
+ assert.strictEqual(orch.list()[0].workflowId, first);
383
+ await waitFor(orch, first, 'c', 'completed');
384
+ await waitFor(orch, second, 'solo', 'completed');
385
+ assert.deepStrictEqual(orch.list().map(w => w.state), ['completed', 'completed']);
386
+ assert.strictEqual(orch.list()[0].completed, 3);
387
+ console.log('✓ 工作流清单 list() 排序与统计');
388
+ }
389
+
390
+ /* 9. 缺省模型与失败原因回传 */
391
+ {
392
+ const { DEFAULT_MODEL } = require('./defaults.js');
393
+ assert.notStrictEqual(DEFAULT_MODEL, 'gpt-5-codex', '缺省模型不得是订阅登录下被拒的 slug');
394
+
395
+ /* 节点未写 model → 用 runner 声明的缺省;写了 model → 以节点为准 */
396
+ const orch = new Orchestrator({ ...createMockRunner(), defaultModel: 'gpt-test-default' });
397
+ const { workflowId } = orch.dispatch(BASE_YAML);
398
+ await waitFor(orch, workflowId, 'c', 'completed');
399
+ const nodes = orch.snapshot(workflowId).nodes;
400
+ assert.strictEqual(nodes.find(n => n.id === 'a').model, 'gpt-test-default');
401
+
402
+ const pinned = new Orchestrator({ ...createMockRunner(), defaultModel: 'gpt-test-default' });
403
+ const second = pinned.dispatch(BASE_YAML.replace('prompt: 执行任务 A', 'prompt: 执行任务 A\n model: gpt-test-pinned'));
404
+ await waitFor(pinned, second.workflowId, 'c', 'completed');
405
+ assert.strictEqual(pinned.snapshot(second.workflowId).nodes.find(n => n.id === 'a').model, 'gpt-test-pinned');
406
+
407
+ /* runner 只在返回值里报失败、不抛错时,失败原因必须落到 node.error(可诊断) */
408
+ const failing = new Orchestrator({
409
+ defaultModel: 'gpt-test-default',
410
+ async run(def, cb) {
411
+ cb.onSessionId('sess_x');
412
+ cb.onItem({ type: 'error', id: 'item_error', message: '模型不受支持' });
413
+ return { status: 'failed', finalMessage: null, usage: null, failure: '模型不受支持' };
414
+ },
415
+ });
416
+ const third = failing.dispatch('apiVersion: codex.dsh/v1\nkind: workflow\ntitle: x\nnodes:\n - id: a\n prompt: x');
417
+ await waitFor(failing, third.workflowId, 'a', 'failed');
418
+ assert.strictEqual(failing.snapshot(third.workflowId).nodes[0].error, '模型不受支持');
419
+ console.log('✓ 缺省模型解析与失败原因回传');
420
+ }
421
+
422
+ console.log('\n全部测试通过');
423
+ })().catch(error => { console.error(error); process.exit(1); });
@@ -0,0 +1,124 @@
1
+ /* ============================================================
2
+ * persistence.js — workflow 状态落盘(append-only JSONL)
3
+ *
4
+ * 动机:原先 workflow 只活在 Orchestrator 进程内存里,进程一重启
5
+ * dispatch 过的 workflow 就永久消失,`/api/state?workflowId=` 只能
6
+ * 返回「workflow 不存在」——调用者无法区分「从没派发过」和
7
+ * 「派发过但被重启清掉了」。这是真实踩到的坑。
8
+ *
9
+ * 设计取舍:
10
+ * - append-only JSONL:崩溃时最多丢最后一行,不需要原子重写整个文件。
11
+ * - 内存仍是权威(Map),磁盘只是**回读补充**:snapshot 未命中内存时
12
+ * 才回读,命中时零 I/O 开销,不用改调度器语义。
13
+ * - 只持久化「查询需要」的字段,不落 abort controller / runner 句柄。
14
+ * - 写入失败绝不打断编排:记一条 stderr 警告后继续(磁盘满/权限问题
15
+ * 不应该让正在跑的 workflow 失败)。
16
+ * ============================================================ */
17
+ 'use strict';
18
+
19
+ const { appendFileSync, readFileSync, mkdirSync, existsSync, renameFileSync } = require('node:fs');
20
+ const { dirname, join } = require('node:path');
21
+
22
+ /* 落盘时保留的节点字段(与 snapshot() 输出对齐) */
23
+ const NODE_FIELDS = [
24
+ 'id', 'title', 'prompt', 'model', 'cwd', 'sandboxMode', 'approvalPolicy', 'reasoningEffort', 'dependsOn', 'timeoutS', 'onFailure', 'status', 'sessionId',
25
+ 'items', 'turns', 'activeTurnId', 'finalMessage', 'usage', 'error', 'startedAt', 'finishedAt',
26
+ ];
27
+
28
+ function pickNode(node) {
29
+ const out = {};
30
+ for (const field of NODE_FIELDS) if (node[field] !== undefined) out[field] = node[field];
31
+ return out;
32
+ }
33
+
34
+ class Persistence {
35
+ /**
36
+ * @param {string} file JSONL 路径
37
+ * @param {{ enabled?: boolean, warn?: (msg: string) => void }} [options]
38
+ */
39
+ constructor(file, { enabled = true, warn = msg => process.stderr.write(`[codex-orchestrate] ${msg}\n`) } = {}) {
40
+ this.file = file;
41
+ this.enabled = enabled;
42
+ this.warn = warn;
43
+ this.broken = false; // 写失败过一次就不再反复报错
44
+ if (this.enabled) {
45
+ try { mkdirSync(dirname(file), { recursive: true }); }
46
+ catch (error) { this.disable(`无法创建持久化目录:${error.message}`); }
47
+ }
48
+ }
49
+
50
+ disable(reason) {
51
+ this.enabled = false;
52
+ if (!this.broken) { this.broken = true; this.warn(`持久化已关闭(${reason})`); }
53
+ }
54
+
55
+ /**
56
+ * 追加一条记录。record 形如:
57
+ * { type: 'workflow', workflowId, title, goal, concurrency, createdAt, seq }
58
+ * { type: 'node', workflowId, node: {...} }
59
+ * { type: 'nodeDelete', workflowId, nodeId }
60
+ */
61
+ append(record) {
62
+ if (!this.enabled) return;
63
+ try {
64
+ appendFileSync(this.file, JSON.stringify({ ...record, at: new Date().toISOString() }) + '\n', { mode: 0o600 });
65
+ } catch (error) {
66
+ this.disable(`写入失败:${error.message}`);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 回读磁盘,重建 workflowId -> { ...workflow, nodes: Map } 视图。
72
+ * 后写的覆盖先写的(节点状态是「最新一次」语义)。
73
+ * 文件不存在 / 完全不可读时返回空 Map,绝不抛错。
74
+ */
75
+ load() {
76
+ const workflows = new Map();
77
+ if (!this.enabled || !existsSync(this.file)) return workflows;
78
+ let text;
79
+ try { text = readFileSync(this.file, 'utf8'); }
80
+ catch (error) { this.warn(`回读失败:${error.message}`); return workflows; }
81
+
82
+ for (const line of text.split('\n')) {
83
+ if (!line.trim()) continue;
84
+ let record;
85
+ /* 半行/损坏行直接跳过:append-only 的文件尾部可能被截断,
86
+ * 一行坏数据不应该让整个历史不可读。 */
87
+ try { record = JSON.parse(line); } catch { continue; }
88
+ if (!record?.workflowId) continue;
89
+
90
+ if (record.type === 'workflow') {
91
+ const prev = workflows.get(record.workflowId);
92
+ workflows.set(record.workflowId, {
93
+ id: record.workflowId,
94
+ title: record.title ?? prev?.title ?? '',
95
+ goal: record.goal ?? prev?.goal ?? '',
96
+ concurrency: record.concurrency ?? prev?.concurrency ?? 3,
97
+ cwd: record.cwd ?? prev?.cwd ?? null,
98
+ createdAt: record.createdAt ?? prev?.createdAt ?? null,
99
+ seq: record.seq ?? prev?.seq ?? 0,
100
+ nodes: prev?.nodes ?? new Map(),
101
+ });
102
+ } else if (record.type === 'node' && record.node?.id) {
103
+ const wf = workflows.get(record.workflowId);
104
+ if (!wf) continue; // node 先于 workflow 出现:丢弃
105
+ wf.nodes.set(record.node.id, { ...wf.nodes.get(record.node.id), ...record.node });
106
+ } else if (record.type === 'nodeDelete' && record.nodeId) {
107
+ /* 删除必须作为顺序敏感的墓碑进入日志;否则重启回放时,更早的
108
+ * node 记录会让已删除节点“复活”。后续重新创建同 id 的 node
109
+ * 仍可通过更晚的 node 记录自然覆盖墓碑。 */
110
+ workflows.get(record.workflowId)?.nodes.delete(record.nodeId);
111
+ }
112
+ }
113
+ return workflows;
114
+ }
115
+
116
+ /** 磁盘上一个 workflow 的节点是否已全部终态(用于判断 seq 复用是否安全) */
117
+ static isTerminal(nodes) {
118
+ const list = nodes instanceof Map ? [...nodes.values()] : nodes || [];
119
+ if (!list.length) return false;
120
+ return list.every(n => ['completed', 'failed', 'cancelled'].includes(n.status));
121
+ }
122
+ }
123
+
124
+ module.exports = { Persistence, pickNode, NODE_FIELDS };