/** * 图执行引擎 · 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/done/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 的**收敛**态:这一次执行有结果了(成了 / 败了 / 被停了 / 挂起等人)。 * * ⚠️ 它**不是**活跃集的补集,别再照着「互补」去推。批 3 给 `run_status` 加了 `queued` * 之后,`queued` 和 `planning` 两个值两边都不在:它们既没占坑,也谈不上有结果。 * 两个集合各管一件事,判据不能互相代替: * · 收敛(本集合)→ 该回写 task / goal 了(task-settle)。 * · **离开活跃集**(`!ACTIVE_ROOT_RUN_STATUS.includes(to)`)→ 坑空了,该看看队里有没有人顶上。 * 用收敛集去判「坑空没空」会漏掉 `queued`;用活跃集的补集去判「要不要回写 task」 * 会把一条刚入队的 run 当成收敛,把 task 写成终态。 */ const ROOT_RUN_SETTLED_STATUS = new Set(['completed', 'failed', 'stopped', 'blocked']); /** 唯一定义处在 work-graph-db(同时是那个唯一索引的 WHERE),这里只是取来用 */ const { ACTIVE_ROOT_RUN_STATUS } = workGraphDb; /** * **run 收敛后的唯一扩展点**(27-P3C §五.1.1)。 * * 为什么挂在这里而不是枚举调用点:`stopRun` 与「预算耗尽 → blocked」这两条最常见的 * 释放路径都不走 `settleRun`,枚举一次漏一次。`transitionRun` 是全仓库 run 状态转换的 * 唯一漏斗(引擎侧一律走它,因为它同时要落 timeline 和推 SSE),挂在这里一个钩子覆盖 * settle / stop / blocked / failed / completed 全部路径。 * * 接在这里的两件事,**次序是硬约束**: * 1. 批 1(F1/F2)—— `task-settle.settleRootRun`:run 终态 → task 回写 → goal 收敛 → OKR / report_to。 * 它是全同步的 DB 写,返回时 task 行的终态已经落库。 * 2. 批 3 调度器 —— `task-scheduler.scheduleTick(company_id)`,语义就是那句话本身 * 「一个 root run 离开了活跃集,去看看队里有没有人能顶上」。必须排在 1 **之后** * (调度器要看到已经收敛的 task 状态,否则 goal 收敛与出队会打架), * 且必须 `setImmediate` 出栈后再跑(scheduleTick 内部做的):tick 会起 run、 * 起 run 又会转换状态、于是又回到这里,同步调用就是无限递归。 * * 两个判据**故意不同**,别合并(见 ROOT_RUN_SETTLED_STATUS 上方那段): * 回写 task 认「收敛」,调度器认「离开活跃集」。 * * ⚠️ **整段绝不 throw**:它挂在状态转换的尾巴上,抛出去会把一次正常的 run 收敛也带崩 * (调用方拿到异常,但状态和 SSE 事件已经发出去了 —— 最难排查的那种半生效)。 * 两件事各自 try/catch:settle 炸了不能连累调度,否则一次回写异常就把整个队列冻住。 */ function afterRunTransition(runId, to) { // 墙钟起止结算 —— 必须在下面那个 early return **之前**:进 `running` 既不是收敛、 // 也不空坑,两个条件都不满足,放在后面等于「只结算停下来、从不开始计时」。 try { // 惰性 require:graph-engine → 本模块,顶层 require 必成环(与下面两处同理) require('./graph-engine').settleWallclock(runId, to); } catch (err) { console.error(`[graphRunSse] 墙钟结算异常 run=${runId} → ${to}: ${err.message}`); } const settled = ROOT_RUN_SETTLED_STATUS.has(to); const freedSlot = !ACTIVE_ROOT_RUN_STATUS.includes(to); if (!settled && !freedSlot) return; let run = null; try { run = workGraphDb.getRun(runId); } catch (err) { console.error(`[graphRunSse] run 收敛钩子读 run 失败 run=${runId} → ${to}: ${err.message}`); return; } // 只处理 root run:子 run 收敛是图内部的事,task / goal 与队列都只跟着 root run 走 if (!run || run.parent_run_id) return; if (settled) { try { // 惰性 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}`); } } if (freedSlot) { try { // 同样惰性:task-scheduler → graph-engine → 本模块,顶层 require 必成环 require('./task-scheduler').scheduleTick(run.company_id); } catch (err) { console.error(`[graphRunSse] 调度钩子异常 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, };