graph-run-sse.js 14.1 KB
/**
 * 图执行引擎 · 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');
const RUNS_ROOT = process.env.GRAPH_RUNS_DIR || null; // 覆盖时直接以它为 {rootRunId} 的父目录

// 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);
  if (RUNS_ROOT) return path.join(RUNS_ROOT, 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),
  );
}

/**
 * run 状态转换(graph_runs)+ timeline + SSE,一次调用做完。
 * 语义与 transitionNode 完全一致。
 */
function transitionRun(runId, from, to, patch = {}, extra = {}) {
  const data = { from: from ?? null, to, ...patch, ...extra };
  return emitTransition(
    runId,
    {
      event: 'run.status',
      ref: runId,
      summary: extra.summary || `run ${runId}: ${from ?? '*'} → ${to}`,
      data,
    },
    () => workGraphDb.updateRunStatus(runId, from, to, patch),
  );
}

/**
 * 非状态机类事件的三合一入口(回退备案 / 预算扣减 / 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,
};