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,
};