graph-run-sse.js
17.3 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
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
/**
* 图执行引擎 · graphRunSse —— 按 run_id 的 SSE 广播 + 审计 timeline + 状态转换三合一
*
* 形态照抄 server/services/devloop/sse-broker.js(每通道一个广播集合、emit 同时落盘 + 广播、
* 25s 心跳、Last-Event-ID 断线重放),但补上 DevLoop 的那个缺口:
*
* DevLoop 里 timeline-logger 的审计事件和 sse-broker 的实时推送是两条独立的轨,
* loop-controller 往 timeline 追加一行不会顺带 emit SSE。本引擎明确要求
* **状态转换、timeline 写入、SSE 广播三者在同一次原子操作里一起发生**(设计文档 §三.3 末段)。
* 所以对外只暴露组合函数 transitionNode / transitionRun / emitTransition,
* 调用方(graph-engine.js)调一次,三件事一起做完;不提供「只改状态不发事件」的路径。
*
* 通道粒度:**按 root run 建通道、按 run_id 过滤投递**。
* 一棵 run 树(root → org node 子 run → 嵌套子图)共用一份 stream.log 和一条 id 序列,
* 这样订阅 root 的控制台能看到整棵树的事件,订阅某个子 run 的下钻视图只收自己那一份,
* 而两者的 Last-Event-ID 重放走同一条序列、不会错位。
*
* 落盘位置(设计文档 §四.A.5 的 run 目录):
* data/companies/{companyId}/runs/{rootRunId}/stream.log SSE 帧,供断线重放
* data/companies/{companyId}/runs/{rootRunId}/timeline.ndjson 审计事件,append-only
*
* 设计:docs/todo/system-design/27-p3-p4-work-graph-console.md §三.3 / §四.A.5 / §五 T5
*/
const fs = require('fs');
const path = require('path');
const workGraphDb = require('./work-graph-db');
const HEARTBEAT_MS = 25_000;
const SUMMARY_MAX = 280; // 与 devloop/timeline-logger 一致
const ROOT_DATA_DIR = path.resolve(__dirname, '../../data');
/**
* run 目录的根覆盖:覆盖时直接以它为 {rootRunId} 的父目录。
*
* **每次调用时读,不在模块加载时快照**(27-P3C 缺陷 F8 / 教训出处见 `company-paths.js` 文件头)。
* 快照的形态要求所有调用方「先设 env 再 require」:只要本模块(或 require 它的 graph-engine)
* 先被加载、之后才设 `GRAPH_RUNS_DIR`(脚本、REPL、没先设 env 的用例),写侧就落在
* `$GRAPH_RUNS_DIR/...`、读侧去 `data/companies/...` 找 —— `describeTask` 拿到 null 后
* **静默退回兜底文案「执行图节点任务」**,agent 收不到真实任务描述却照样烧预算,且不报错。
*/
function runsRootOverride() {
return process.env.GRAPH_RUNS_DIR || null;
}
// rootRunId → Set<{ res, runId, heartbeatTimer }>;runId 是订阅者关心的那一层
const subscriptions = new Map();
// rootRunId → 已用到的最大事件 id(每棵 run 树一条序列)
const counters = new Map();
// runId → { rootRunId, companyId },避免每条事件都查一次库
const runMeta = new Map();
// ── 路径 ────────────────────────────────────────────────────────────
/**
* run 所属的 { rootRunId, companyId };查不到库时退化成「自己就是 root」。
*
* ⚠️ **只缓存查得到的权威结果,退化值一律不落缓存**(R4-F2)。
* 缓存退化值会把一个错误状态永久固化:此后这个 run 的所有事件都写到
* data/companies/_unknown/runs/{runId}/ 并广播到错误的 root 频道,
* 订阅真 root 的控制台从此静默收不到它——而 graph_runs 那一行明明已经建好了。
* 省下的那次 SELECT 换来一个没有任何失效路径的错误缓存,不划算。
*/
function resolveMeta(runId) {
const hit = runMeta.get(runId);
if (hit) return hit;
try {
const run = workGraphDb.getRun(runId);
if (run) {
const meta = { rootRunId: run.root_run_id || runId, companyId: run.company_id };
runMeta.set(runId, meta);
return meta;
}
} catch {
// 库还没建好(如纯单测场景)时不阻塞广播,按退化值走
}
// 退化值:每次重算,下一次调用会重新试一遍库,行建好之后自动恢复
return { rootRunId: runId, companyId: '_unknown' };
}
/** data/companies/{cid}/runs/{rootRunId}/ */
function runDir(runId) {
const { rootRunId, companyId } = resolveMeta(runId);
const runsRoot = runsRootOverride();
if (runsRoot) return path.join(runsRoot, rootRunId);
return path.join(ROOT_DATA_DIR, 'companies', companyId, 'runs', rootRunId);
}
function streamLogPath(runId) {
return path.join(runDir(runId), 'stream.log');
}
function timelinePath(runId) {
return path.join(runDir(runId), 'timeline.ndjson');
}
// ── 事件 id 序列 ─────────────────────────────────────────────────────
function nextEventId(rootRunId, logPath) {
if (!counters.has(rootRunId)) {
// 进程重启后从 stream.log 现有最大 id 续号,避免客户端 Last-Event-ID 判断错乱
let max = 0;
if (fs.existsSync(logPath)) {
const matches = fs.readFileSync(logPath, 'utf8').match(/^id: (\d+)$/gm) || [];
for (const m of matches) {
const n = parseInt(m.slice(4), 10);
if (n > max) max = n;
}
}
counters.set(rootRunId, max);
}
const id = counters.get(rootRunId) + 1;
counters.set(rootRunId, id);
return id;
}
// ── 订阅 ────────────────────────────────────────────────────────────
/**
* 把 HTTP res 注册为 runId 这一层的订阅者。调用方应已 writeHead(200, SSE headers)。
* @param {string} runId - 订阅哪个 run;传 root run id 则收整棵 run 树的事件
* @param {import('http').ServerResponse} res
* @param {{ replayFromOffset?: string|number }} opts - replayFromOffset 取自 Last-Event-ID
*/
function subscribe(runId, res, opts = {}) {
const { rootRunId } = resolveMeta(runId);
if (opts.replayFromOffset != null) {
replayTo(res, runId, parseInt(opts.replayFromOffset, 10) || 0);
}
const heartbeatTimer = setInterval(() => {
try { res.write(':keepalive\n\n'); } catch { /* ignore broken pipe */ }
}, HEARTBEAT_MS);
if (!subscriptions.has(rootRunId)) subscriptions.set(rootRunId, new Set());
const entry = { res, runId, heartbeatTimer };
subscriptions.get(rootRunId).add(entry);
res.on('close', () => {
clearInterval(heartbeatTimer);
subscriptions.get(rootRunId)?.delete(entry);
if (subscriptions.get(rootRunId)?.size === 0) subscriptions.delete(rootRunId);
});
return entry;
}
/** 订阅 root 的看整棵树,订阅子 run 的只看自己那一层 */
function wants(entry, eventRunId) {
const { rootRunId } = resolveMeta(entry.runId);
return entry.runId === rootRunId || entry.runId === eventRunId;
}
function replayTo(res, runId, fromId) {
const p = streamLogPath(runId);
if (!fs.existsSync(p)) return;
const { rootRunId } = resolveMeta(runId);
// stream.log 每帧形如 `id: N\nevent: X\ndata: {...}\n\n`
for (const block of fs.readFileSync(p, 'utf8').split('\n\n').filter(Boolean)) {
const idMatch = block.match(/^id: (\d+)/m);
if (!idMatch || parseInt(idMatch[1], 10) <= fromId) continue;
// 同一份 log 混着整棵树的事件,重放时按订阅层级过滤
if (runId !== rootRunId) {
const dataMatch = block.match(/^data: (.*)$/m);
let evRunId = null;
try { evRunId = JSON.parse(dataMatch?.[1] || '{}').run_id; } catch { /* 坏行跳过过滤 */ }
if (evRunId && evRunId !== runId) continue;
}
try { res.write(block + '\n\n'); } catch { return; }
}
}
// ── 落盘 + 广播 ──────────────────────────────────────────────────────
function truncate(s, n) {
const str = typeof s === 'string' ? s : String(s);
return str.length <= n ? str : str.slice(0, n - 1) + '…';
}
/**
* 广播一条事件 + 落盘 stream.log(不写 timeline;审计轨见 appendTimeline / emitTransition)。
* @param {string} runId - 事件属于哪个 run
* @param {string} event - 事件名,如 'node.status' / 'run.status' / 'loop.registered'
* @param {object} data - 会自动补 run_id / root_run_id / ts 三个字段
*/
function emit(runId, event, data) {
const { rootRunId } = resolveMeta(runId);
const p = streamLogPath(runId);
const id = nextEventId(rootRunId, p);
const payload = {
...(data == null ? {} : data),
run_id: runId,
root_run_id: rootRunId,
ts: new Date().toISOString(),
};
const line = `id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.appendFileSync(p, line);
for (const entry of subscriptions.get(rootRunId) || []) {
if (!wants(entry, runId)) continue;
try { entry.res.write(line); } catch { /* ignore broken pipe */ }
}
return { id, event, data: payload };
}
/**
* 只写审计轨(append-only NDJSON)。正常路径请用 emitTransition —— 它会连 SSE 一起发。
* @param {string} runId
* @param {{ source?: string, kind: string, summary: string, ref?: string, payload?: object }} ev
*/
function appendTimeline(runId, ev) {
if (!ev || !ev.kind || !ev.summary) {
throw new Error('graphRunSse.appendTimeline: kind/summary required');
}
const { rootRunId } = resolveMeta(runId);
const event = {
ts: new Date().toISOString(),
run_id: runId,
root_run_id: rootRunId,
source: ev.source || 'graph-engine',
kind: ev.kind,
ref: ev.ref || null,
summary: truncate(ev.summary, SUMMARY_MAX),
};
if (ev.payload !== undefined) event.payload = ev.payload;
const p = timelinePath(runId);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.appendFileSync(p, JSON.stringify(event) + '\n');
return event;
}
// ── 三合一:状态转换 + timeline + SSE ────────────────────────────────
/**
* 原子三合一。整个函数体是同步的、中间没有 await,所以在 Node 单线程里
* 「状态改了但事件没发」只可能因为进程崩溃,不会因为并发交错。
*
* apply() 必须是条件更新(`UPDATE ... WHERE status = @from`)并返回受影响行数:
* 返回 0 表示这次转换已被别人抢先做掉(§四.A.6 幂等语义),此时**不写 timeline、不广播**,
* 返回 null 让调用方据此判断「不是我推进的」。
*
* @param {string} runId
* @param {object} ev
* @param {string} ev.event - SSE 事件名
* @param {string} [ev.kind] - timeline kind,默认取 ev.event
* @param {string} ev.summary - 人类可读一句话
* @param {object} [ev.data] - 同时进 SSE data 和 timeline payload
* @param {string} [ev.ref] - timeline ref(如 node run id / edge id)
* @param {string} [ev.source]
* @param {() => number} apply - 条件更新,返回受影响行数
* @returns {{ changes: number, event: object, timeline: object } | null}
*/
function emitTransition(runId, ev, apply) {
const changes = typeof apply === 'function' ? apply() : 1;
if (!changes) return null; // 幂等 no-op:状态没变,就不该有事件
const timeline = appendTimeline(runId, {
source: ev.source,
kind: ev.kind || ev.event,
ref: ev.ref,
summary: ev.summary,
payload: ev.data,
});
const sent = emit(runId, ev.event, ev.data);
return { changes, event: sent, timeline };
}
/**
* 节点状态转换(graph_run_nodes)+ timeline + SSE,一次调用做完。
* @param {string} runId
* @param {string} nodeRunId
* @param {string|null} from - null = 用当前状态做期望值
* @param {string} to
* @param {object} [patch] - 透传给 workGraphDb.updateNodeStatus 的补丁列
* @param {object} [extra] - { nodeName?, failReason?, attempt?, summary?, ... } 附在事件里
*/
function transitionNode(runId, nodeRunId, from, to, patch = {}, extra = {}) {
const data = {
node_run_id: nodeRunId,
from: from ?? null,
to,
...patch,
...extra,
};
return emitTransition(
runId,
{
event: 'node.status',
ref: nodeRunId,
summary: extra.summary || `node ${extra.nodeName || nodeRunId}: ${from ?? '*'} → ${to}`,
data,
},
() => workGraphDb.updateNodeStatus(nodeRunId, from, to, patch),
);
}
/**
* root run 离开活跃集的那几个状态。取值域与 `company.js` 的 `ACTIVE_ROOT_RUN_STATUS`
* (ready / running / paused)互补:不在活跃集里 = 这次执行结束了。
*/
const ROOT_RUN_SETTLED_STATUS = new Set(['completed', 'failed', 'stopped', 'blocked']);
/**
* **run 收敛后的唯一扩展点**(27-P3C §五.1.1)。
*
* 为什么挂在这里而不是枚举调用点:`stopRun` 与「预算耗尽 → blocked」这两条最常见的
* 释放路径都不走 `settleRun`,枚举一次漏一次。`transitionRun` 是全仓库 run 状态转换的
* 唯一漏斗(引擎侧一律走它,因为它同时要落 timeline 和推 SSE),挂在这里一个钩子覆盖
* settle / stop / blocked / failed / completed 全部路径。
*
* 已经接在这里的:
* · 批 1(F1/F2)—— `task-settle.settleRootRun`:run 终态 → task 回写 → goal 收敛 → OKR / report_to。
* 将要接在这里的:
* · 批 3 调度器 —— `require('./task-scheduler').tick(run.company_id)`,语义就是那句话本身
* 「一个 root run 离开了活跃集,去看看队里有没有人能顶上」。它必须排在 settle **之后**
* (调度器要看到已经收敛的 task 状态),且必须 `setImmediate` 出栈后再跑:tick 会起 run、
* 起 run 又会转换状态、于是又回到这里,同步调用就是无限递归。
*
* ⚠️ **整段绝不 throw**:它挂在状态转换的尾巴上,抛出去会把一次正常的 run 收敛也带崩
* (调用方拿到异常,但状态和 SSE 事件已经发出去了 —— 最难排查的那种半生效)。
*/
function afterRunTransition(runId, to) {
if (!ROOT_RUN_SETTLED_STATUS.has(to)) return;
try {
const run = workGraphDb.getRun(runId);
// 只处理 root run:子 run 收敛是图内部的事,task / goal 只跟着 root run 走
if (!run || run.parent_run_id) return;
// 惰性 require:task-settle → work-graph-db / okr-service,顶层 require 会与本模块成环
require('./task-settle').settleRootRun(run, to);
} catch (err) {
console.error(`[graphRunSse] run 收敛钩子异常 run=${runId} → ${to}: ${err.message}`);
}
}
/**
* run 状态转换(graph_runs)+ timeline + SSE,一次调用做完。
* 语义与 transitionNode 完全一致,外加收敛后的 afterRunTransition 钩子。
*/
function transitionRun(runId, from, to, patch = {}, extra = {}) {
const data = { from: from ?? null, to, ...patch, ...extra };
const result = emitTransition(
runId,
{
event: 'run.status',
ref: runId,
summary: extra.summary || `run ${runId}: ${from ?? '*'} → ${to}`,
data,
},
() => workGraphDb.updateRunStatus(runId, from, to, patch),
);
// 只在**真的推进了**之后才跑钩子:result 为 null 表示这次转换被别人抢先做掉了(幂等 no-op),
// 那一次的钩子由抢先的那一方跑过了,这里再跑一遍就是重复副作用。
if (result) afterRunTransition(runId, to);
return result;
}
/**
* 非状态机类事件的三合一入口(回退备案 / 预算扣减 / agent 调用起止 …):
* 没有条件更新要做时 apply 传 null,事件照样同时进 timeline 和 SSE。
*/
function emitEvent(runId, event, summary, data = {}, opts = {}) {
return emitTransition(runId, { event, summary, data, ref: opts.ref, kind: opts.kind, source: opts.source }, null);
}
// ── 收尾 ────────────────────────────────────────────────────────────
/**
* run 跑完:给**订阅这一层**的连接发 close 并关掉它们。
* 子 run 结束不会掐断订阅 root 的控制台连接(它还要继续看整棵树);
* 只有 close(rootRunId) 才会清空整个通道。
*/
function close(runId) {
const { rootRunId } = resolveMeta(runId);
const subs = subscriptions.get(rootRunId);
if (!subs) return;
for (const entry of subs) {
if (runId !== rootRunId && entry.runId !== runId) continue;
try { entry.res.write(`event: close\ndata: ${JSON.stringify({ run_id: runId })}\n\n`); } catch { /* ignore */ }
try { entry.res.end(); } catch { /* ignore */ }
clearInterval(entry.heartbeatTimer);
subs.delete(entry);
}
if (subs.size === 0) subscriptions.delete(rootRunId);
}
function _resetForTesting() {
for (const subs of subscriptions.values()) {
for (const entry of subs) clearInterval(entry.heartbeatTimer);
}
subscriptions.clear();
counters.clear();
runMeta.clear();
}
function _activeSubscribers(runId) {
return subscriptions.get(resolveMeta(runId).rootRunId)?.size || 0;
}
module.exports = {
// 订阅 / 广播
subscribe,
emit,
close,
// 审计
appendTimeline,
// 三合一(graph-engine 应只用这三个)
emitTransition,
transitionNode,
transitionRun,
emitEvent,
// 路径
runDir,
streamLogPath,
timelinePath,
HEARTBEAT_MS,
_resetForTesting,
_activeSubscribers,
};