graph-run.js 26.9 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 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545
/**
 * Graph Run Routes — P3 图执行的 REST 端点(T6)
 *
 * 挂载: /api/graph-runs(主 router)
 *      /api/company(companyRouter,只提供 GET /:companyId/graph-runs)
 *
 * 设计见 docs/todo/system-design/27-p3-p4-work-graph-console.md
 *   §四.C  五个控制操作的可用条件与语义(本文件只做「条件判断 + 转调引擎」,不含推进逻辑)
 *   §三.2  两处不能照抄 pipeline 的坑:
 *          ① 定义层与运行时层分开 → GET /:id 吐的是 **run 实例**(run + nodes + edges + 回退记录),
 *             不是图定义,前端不会再出现 CompanyDashboard.vue 那种「拿定义画运行时高亮」的空引用;
 *          ② 「重试」必须是独立、参数自解释的端点 → POST /:id/nodes/:nodeId/retry,
 *             要重试哪个节点全写在 URL 里,不需要调用方拼隐藏参数组合
 *             (pipeline 的 retryPipelineRun 调 /advance 但不传 stepIndex,点了必定 400)。
 *
 * 错误约定(与 routes/company.js 的 org-graph 端点、routes/devloop.js 保持一致):
 *   404 { error }                                     run / 节点不存在
 *   400 { error, op, runStatus, allowed }             操作在当前 run_status 下不可用(非法状态转换,明确说清)
 *   400 { error, scope, from, to }                    引擎抛 InvalidTransitionError
 *   400 { error }                                     入参不合法
 *   409 { error, reason:'run-slot-busy', active, max, holders, hint } 公司的执行位已满(引擎抛 RunSlotBusyError)
 *   500 { error }                                     其它
 */

const express = require('express');

const engine = require('../services/graph-engine');
const {
  getRun,
  getRunState,
  getRunNode,
  getRunNodeByDefId,
  listChildRuns,
  listCompanyRuns,
  listLoopRecords,
  edgeContracts,
  findFiles,
  listActiveRootRunIds,
  maxActiveRootRuns,
  InvalidTransitionError,
  RunSlotBusyError,
} = require('../services/work-graph-db');
const { companyRootDir } = require('../services/company-paths');

const router = express.Router();
const companyRouter = express.Router();

// ────────────────────────────────────────────────────────────────
// 小工具
// ────────────────────────────────────────────────────────────────

/**
 * handler 包装:抛出来的异常统一落到下面的 sendError —— **同步和异步两条路都要包住**。
 *
 * 原先只有 `Promise.resolve(fn(...)).catch(...)`。那个 `.catch` 是在 `fn` **返回之后**才挂上去的,
 * fn 同步抛出时这一行根本执行不到,异常直接冒到 express 的默认错误处理器:
 * 一页 `<!DOCTYPE html>` 的 500,前端 `await r.json()` 当场炸在「Unexpected token '<'」上,
 * 连服务端写的那句错误文案都看不到。
 *
 * 而本文件里 `/start` `/pause` `/stop` `/nodes/:nodeId/retry` **全是同步 handler**,
 * 引擎在它们里面抛的 InvalidTransitionError / RunSlotBusyError 是最常见的两种错 ——
 * 也就是说「异常统一落到 sendError」这句注释此前对四个端点里的绝大多数错误都不成立。
 * (27-P3C 批 3 对抗复核:注释承诺的防线必须真的存在。)
 */
const h = (fn) => (req, res) => {
  try {
    return Promise.resolve(fn(req, res)).catch((err) => sendError(res, err));
  } catch (err) {
    return sendError(res, err);
  }
};

function sendError(res, err) {
  if (err instanceof InvalidTransitionError || err.name === 'InvalidTransitionError') {
    return res.status(400).json({ error: err.message, scope: err.scope, from: err.from, to: err.to });
  }

  // 「坑满」是 27-P3C 批 3 之后的**正常业务拒绝**,不是服务器错误。
  // 500 的含义是「服务端坏了」——调用方(人、外部 agent、前端)会去重试或报故障,
  // 而这里唯一有用的动作是「等占坑那条收敛,或者去停掉它」。所以:409 + 点名谁占着坑。
  //
  // 用 instanceof 而不是 `err.name === 'RunSlotBusyError'`:字符串比较对任何随手把 name
  // 设成同样字面量的错误都成立,而且它会让数据层导出的这个类没有任何调用方
  // (「导出了、零人使用」是这个仓库反复立案的惯犯)。全仓 work-graph-db 只有相对路径
  // require,同一个模块实例,instanceof 成立。
  if (err instanceof RunSlotBusyError) {
    const slots = slotState(err.companyId, err);
    return res.status(409).json({
      error: err.message,
      reason: 'run-slot-busy',
      companyId: err.companyId,
      runId: err.runId, // 想进活跃集却被挡住的那条(= 本次操作的对象)
      active: slots.used,
      max: slots.max,
      // 「挡我的是谁」:没有它,用户只知道「坑满」,无从判断该等谁、该停谁。
      // 数组而不是单数:批 4 放开并行之后这里真的会有多条(max = 该公司的
      // company.json.max_parallel_tasks),形状从批 3 起就按多条写好了,这次没动。
      holders: slots.holders,
      hint: slotBusyHint(slots),
    });
  }

  res.status(500).json({ error: err.message });
}

/**
 * 「这家公司此刻的执行位」→ `{ max, used, free, holders }`。**这个形状全仓库只有这一份**,
 * `routes/company.js` 的 409 也从这里取(它以前自己 `.find()` 一条 active run 再拼三个
 * 单数字段,那就是第二个持有者:批 4 之后两处会各自指向不同的占位者,因为
 * `activeRootRun` 取 created_at **DESC** 的第一条、准入事务给的 heldBy 是 **ASC**)。
 *
 * 三样东西各有唯一来源,这里一样都不重算:
 *   · 取值域(哪些 run_status 算占位)= `work-graph-db.listActiveRootRunIds` 那条 WHERE。
 *     本文件以前抄过一份 `new Set([...])` 做回落查询 —— 那正是 canDispatch 事故的形状,已删;
 *   · 额度 = `work-graph-db.maxActiveRootRuns(companyId)`(读 company.json 的
 *     max_parallel_tasks,缺省 1)。**不在这里写第二个默认值**;
 *   · 拒绝当下的实况优先用 `err.heldBy / err.active / err.max` —— 那是准入事务在
 *     BEGIN IMMEDIATE 里**当场**数出来的,比事后再查一次准(等 HTTP 响应写出去时,
 *     占位的那条可能已经换人了)。只有 heldBy 空 / 那几行已经不在时才回落到查询,
 *     于是「执行位刚刚才空出来」这条 hint 仍然给得出来。
 *
 * @param {string} companyId
 * @param {RunSlotBusyError} [err] 有它就用它当场测的那一份
 */
function slotState(companyId, err = null) {
  const view = (id) => {
    const r = getRun(id);
    return r ? { runId: r.id, taskId: r.task_id, goalId: r.goal_id, runStatus: r.run_status } : null;
  };
  const named = (err && Array.isArray(err.heldBy) ? err.heldBy : []).map(view).filter(Boolean);
  const holders = named.length || !companyId
    ? named
    : listActiveRootRunIds(companyId).map(view).filter(Boolean);
  const max = err && Number.isInteger(err.max) ? err.max : maxActiveRootRuns(companyId);
  const used = err && Number.isInteger(err.active) ? err.active : holders.length;
  return { max, used, free: Math.max(0, max - used), holders };
}

/**
 * 执行位满时那句「现在该干什么」。
 *
 * 两条规矩没变,只是从「唯一那条占位者」推广到 N 条:
 *   · 「先停掉它」只在**它此刻真的停得掉**时才给 —— ready 的 run 不在 STOPPABLE_RUN_STATUS 里,
 *     对它写 /stop 就是又一条指向不存在行为的提示(打过去 400);
 *   · 一条都停不掉时明说原因,别让人照着一条必然失败的路走一遍。
 * N 条时列出**所有**停得掉的那几条:只点名第一条,用户停掉它发现还是起不来(其余仍占着位)。
 */
function slotBusyHint({ used, max, holders }) {
  if (!holders.length) return '执行位刚刚才空出来(占位的那条已经收敛),直接重试本次操作即可';
  const who = holders
    .map((h) => `run ${h.runId}${h.taskId ? `(task ${h.taskId})` : ''}`)
    .join('、');
  const stoppable = holders.filter((h) => STOPPABLE_RUN_STATUS.includes(h.runStatus));
  const head = `执行位已满(${used}/${max}),占位的是 ${who},等其中任一条收敛后重试;`;
  return head + (stoppable.length
    ? `不想等就先停掉其中一条:${stoppable.map((h) => `POST /api/graph-runs/${h.runId}/stop`).join(' 或 ')}`
    : `它们现在都还停不掉(/stop 只认 ${STOPPABLE_RUN_STATUS.join(' / ')}),马上就会开跑`);
}

/** 取 run,不存在直接回 404 并返回 null(调用方 return 掉即可) */
function loadRun(req, res) {
  const run = getRun(req.params.id);
  if (!run) {
    res.status(404).json({ error: `graph run 不存在: ${req.params.id}` });
    return null;
  }
  return run;
}

/**
 * §四.C 的「可用条件」那一列。不可用时返回明确错误——说清是哪个操作、
 * 当前是什么 run_status、什么状态下才可用,不闷掉。
 *
 * @param {boolean} [alsoOk] 状态之外的额外放行条件(「启动」用,见下);
 *                           allowed 只用于报错文案,所以额外条件也要在里面写清楚。
 */
function ensureStatus(res, run, op, allowed, alsoOk = false) {
  if (alsoOk || allowed.includes(run.run_status)) return true;
  res.status(400).json({
    error: `「${op}」在 run_status='${run.run_status}' 下不可用,仅当 run_status ∈ {${allowed.join(', ')}} 时可用`,
    op,
    runStatus: run.run_status,
    allowed,
  });
  return false;
}

/**
 * advanceOne 的 ok:false 分两类:
 *   - 「操作本身被状态挡住」→ 400,用户需要先改状态(先启动 / 先恢复);
 *   - 「这一步没推进但不算错」(没有 ready 节点 = 可能已收敛、被别人抢走、在等子 run)→ 200 原样返回。
 */
const BLOCKED_REASONS = {
  'run-not-found': '该 run 不存在',
  'run-not-running': 'run 不在 running 状态,先调 /start',
  'pause-requested': 'run 已请求暂停,先调 /start 恢复',
  'stop-requested': 'run 已请求停止,不能再推进',
};

function sendAdvance(res, out) {
  if (!out.ok && BLOCKED_REASONS[out.reason]) {
    return res.status(400).json({
      error: BLOCKED_REASONS[out.reason],
      op: 'next',
      reason: out.reason,
      runStatus: out.runStatus,
    });
  }
  res.json(out);
}

// ────────────────────────────────────────────────────────────────
// 控制类端点(§四.C 五个操作)
// ────────────────────────────────────────────────────────────────

/**
 * POST /api/graph-runs/:id/start — 「启动」
 * body: { mode?: 'manual' | 'auto' }(不传沿用 run 上已存的 execution_mode)
 * 可用条件:run_status ∈ {ready, paused, blocked},**或 running 且 pause_requested=1**
 *
 * 后半条不是可选的宽松(R6-F3 问题 1):「暂停」是软信号,running → paused 要等节点边界
 * (landPause 手头有 running 节点就不落)。而 org 层每个节点都是 subgraph,子 run 在飞时
 * 父节点恒为 running —— root run 于是**永久停在 `running` + `pause_requested=1`**,
 * 只看 run_status 的话「启动」永远 400,用户只能一步步点「下一步」,验收 4 的后半不成立。
 * 引擎侧本来就对:startRun → startRunSync 会清全树软信号并把级联 paused 的后代拉回 running,
 * 这里放行的正是「撤回暂停意图」。已经在 stop_requested 的 run 不在此列(它正在收摊)。
 *
 * auto 模式**不等**引擎跑完就返回:一次自动跑到底是分钟~小时级的,
 * 绝不能像 DevLoop 那样把整个循环塞进一次 HTTP 请求-响应周期(§三.1)。
 */
router.post('/:id/start', h((req, res) => {
  const run = loadRun(req, res);
  if (!run) return;
  const pausing = run.run_status === 'running' && Boolean(run.pause_requested) && !run.stop_requested;
  if (!ensureStatus(res, run, '启动', ['ready', 'paused', 'blocked', 'running(pause_requested=1)'], pausing)) return;

  const { mode } = req.body || {};
  if (mode !== undefined && mode !== 'manual' && mode !== 'auto') {
    return res.status(400).json({ error: `mode 只能是 'manual' 或 'auto',收到: ${mode}` });
  }

  // driver 是 auto 循环的 promise,不可序列化也不该 await —— 丢掉,让它自己在后台跑
  const { driver, ...out } = engine.startRun(run.id, mode ? { mode } : {});
  res.json({ ...out, executionMode: mode || run.execution_mode, auto: Boolean(driver) });
}));

/**
 * POST /api/graph-runs/:id/next — 「下一步」
 * body: { task?: string }(可选,透传给节点 executor 作任务描述)
 * 可用条件:run_status ∈ {running, paused}
 *
 * 只推进**一个** ready 节点,完成后把满足条件的下游置 ready 就返回,不继续往下跑。
 * 会真正发起一次 agent 调用,所以这个请求本身可能是秒~分钟级的(§四.C 表格最后一列)。
 */
router.post('/:id/next', h(async (req, res) => {
  const run = loadRun(req, res);
  if (!run) return;
  if (!ensureStatus(res, run, '下一步', ['running', 'paused'])) return;

  const out = await engine.nextStep(run.id, req.body && req.body.task ? { task: req.body.task } : {});
  sendAdvance(res, out);
}));

/**
 * POST /api/graph-runs/:id/pause — 「暂停」
 * 可用条件:run_status = 'running'
 * 软信号:不打断正在 running 的节点,在下一个节点边界生效。
 */
router.post('/:id/pause', h((req, res) => {
  const run = loadRun(req, res);
  if (!run) return;
  if (!ensureStatus(res, run, '暂停', ['running'])) return;
  res.json({ ...engine.pauseRun(run.id), runStatus: (getRun(run.id) || {}).run_status });
}));

/**
 * `POST /:id/stop` 的可用条件 —— **这是全仓唯一的持有者**。
 *
 * 导出它不是为了好看:`routes/company.js` 的几处 409 hint 要写「先停它:POST /api/graph-runs/x/stop」,
 * 而这条路对 `queued` 的 run 根本不成立(排队中的 run 没开跑)。此前那些 hint 把取值域
 * 默认成「有 run 就能停」,于是对一条排队中 / 根本没有 run 的 task 给出的下一步是
 * `POST /api/graph-runs/null/stop` —— 指向不存在行为的提示,用户照做一次失败一次。
 * 判据只留这一份,company.js 照抄结果。
 */
const STOPPABLE_RUN_STATUS = ['running', 'paused', 'blocked'];

/**
 * POST /api/graph-runs/:id/stop — 「停止」
 * 可用条件:run_status ∈ STOPPABLE_RUN_STATUS
 * 仍 pending/ready 的节点批量 skipped,run → stopped。正在 running 的节点能否硬取消见 T9。
 */
router.post('/:id/stop', h((req, res) => {
  const run = loadRun(req, res);
  if (!run) return;
  if (!ensureStatus(res, run, '停止', STOPPABLE_RUN_STATUS)) return;
  res.json(engine.stopRun(run.id));
}));

/**
 * POST /api/graph-runs/:id/nodes/:nodeId/retry — 「重试」
 * 可用条件:节点 status ∈ {fail, blocked}
 *
 * §三.2 坑 2 的正解:重试的对象写在 URL 里,端点参数自解释,body 可以是空的。
 * nodeId 优先按 graph_run_nodes.id 解析;解析不到再按 def_node_id 在本 run 内找一次
 * ——画布上拿到的可能是定义层节点 id,两种都能直接重试,不用调用方自己换算。
 */
router.post('/:id/nodes/:nodeId/retry', h((req, res) => {
  const run = loadRun(req, res);
  if (!run) return;

  const { nodeId } = req.params;
  let nodeRun = getRunNode(nodeId);
  if (!nodeRun || nodeRun.run_id !== run.id) nodeRun = getRunNodeByDefId(run.id, nodeId);
  if (!nodeRun) {
    return res.status(404).json({ error: `节点 ${nodeId} 不属于 run ${run.id}` });
  }

  if (!['fail', 'blocked'].includes(nodeRun.status)) {
    return res.status(400).json({
      error: `「重试」在节点 status='${nodeRun.status}' 下不可用,仅当节点 status ∈ {fail, blocked} 时可用`,
      op: 'retry',
      nodeStatus: nodeRun.status,
      allowed: ['fail', 'blocked'],
    });
  }

  // ok:false / taken-by-other 是并发下的正常结果(§四.A.6 幂等:别人已经拿走就返回当前状态,不报错)
  res.json(engine.retryNode(run.id, nodeRun.id));
}));

// ────────────────────────────────────────────────────────────────
// 只读查询类端点
// ────────────────────────────────────────────────────────────────

/**
 * GET /api/graph-runs/:id — 一次 run 的全量状态
 * 返回 { run, nodes, edges, loopRecords, childRuns, budget }
 *   nodes  = 定义层(name / systemAgentId / nodeKind)与运行时(status / attempt / childRunId …)合并后的节点
 *   edges  = 定义层原样(含 condition / failReason / edgeKind),画布靠 edgeKind='backward' 画虚线
 *   loopRecords = 回退账本,画布上 `N/3` 徽标的数据来源(§四.B.2)
 *   budget = root run 的 budget_json 解析结果,页头预算条用(§四.B.4);非 root run 也回这个值
 */
/**
 * 35 号文档提案 B:控制台"这份验收有没有证据支撑"要读的数据。
 *
 * 只读 `goal-contract.js` 已经落盘的判决索引 + 判决全文(`verdicts/index.ndjson` +
 * `verdicts/{id}.json`),不重新计算——判决在 `runAcceptance`/`goal-contract.js run`
 * 那一刻就已经算好了,这里只是把 `sections.benchmark.dimensions[]`(含提案 D 新加的
 * `evidence_required`)摘出来给前端渲染。读不到 / 解析不出就跳过那一条,不拖垮整个
 * 响应——这是纯展示数据,读盘失败不该让 GET /:id 报错。
 */
function collectBenchmarkVerdicts(rootRun) {
  if (!rootRun) return [];
  let gc;
  try {
    gc = require('../services/goal-contract');
  } catch {
    return [];
  }
  const runDir = gc.resolveRunDir(rootRun.company_id, rootRun.root_run_id || rootRun.id);
  let index;
  try {
    index = gc.listVerdicts(runDir, {});
  } catch {
    return [];
  }
  const out = [];
  for (const entry of index) {
    let full;
    try {
      full = gc.readVerdict(runDir, entry.verdict_id);
    } catch {
      full = null;
    }
    const dims = full && full.sections && full.sections.benchmark && full.sections.benchmark.dimensions;
    if (!Array.isArray(dims)) continue;
    out.push({
      verdictId: entry.verdict_id,
      nodeRunId: entry.node_run_id || null,
      ok: entry.ok,
      dimensions: dims.map((d) => ({
        name: d.name, ok: d.ok, score: d.score,
        evidenceRequired: Boolean(d.evidence_required),
      })),
    });
  }
  return out;
}

router.get('/:id', h((req, res) => {
  const state = getRunState(req.params.id);
  if (!state) return res.status(404).json({ error: `graph run 不存在: ${req.params.id}` });

  const rootRun = state.run.root_run_id === state.run.id ? state.run : getRun(state.run.root_run_id);
  let budget = null;
  try {
    budget = rootRun && rootRun.budget_json ? JSON.parse(rootRun.budget_json) : null;
  } catch {
    budget = null; // 预算条坏掉不该让整个 run 查不出来
  }

  // R-19 输入输出面板的三个数据源。没有它们,控制台的「输入输出」tab 整块降级 ——
  // 契约声明与产物索引都在库里,界面上却看不见,等于又一次「实现在、零调用方」。
  //   edges[].contracts = 边上声明的 kind(哪一层的边查哪一层的契约)
  //   companyRoot       = 库里只存相对路径,根由代码推导(R-19.3),前端靠它拼绝对路径
  //   taskFiles         = 本 task 已登记的产物实例;契约 × 实例的 join 在前端做
  const layer = state.run.graph_kind === 'org' ? 'org' : 'work';
  const edges = (state.edges || []).map((e) => {
    const contracts = e.id ? edgeContracts(layer, e.id) : [];
    return contracts.length ? { ...e, contracts } : e;
  });

  res.json({
    ...state,
    edges,
    companyRoot: companyRootDir(state.run.company_id),
    taskFiles: findFiles({ companyId: state.run.company_id, taskId: state.run.task_id }),
    // 控制台「产物」面板要区分两类产出:graph_files 只索引各节点私有目录下的东西
    // (规划笔记、日志、契约快照……),真实交付物(代码)写在这个任务共享的项目根里
    // (见 graph-engine.js「W2 · 每个 root run 一个项目根」那段),两者不是同一份数据。
    // readProjectRootRecord 按 root_run_id 查,任一层 run 传进去结果相同(同一棵树共享一个项目根)。
    projectRoot: engine.readProjectRootRecord(state.run),
    // 35 号文档提案 B:每个 benchmark 判决按维度标了有没有证据支撑,前端拿去在
    // 节点详情上画一个小标签——不改变任何判定逻辑,纯展示。
    benchmarkVerdicts: collectBenchmarkVerdicts(rootRun),
    loopRecords: listLoopRecords(state.run.id),
    childRuns: listChildRuns(state.run.id),
    budget,
  });
}));

/**
 * GET /api/graph-runs/:id/nodes/:nodeRunId/transcript — 这个节点那次调用的 LLM 原文
 *
 * 引擎按设计不存逐字输出(见 agent-transcript.js 顶部),这里**惰性读** Claude CLI
 * 自己的 transcript 文件:不进 SSE、不进节点行、不落第二份库,只是多一个读取入口。
 *
 * ⚠️ transcript 按 (公司, 角色) 分,**不按节点分**。同一角色跑过多个节点时只能按
 * started_at / completed_at 截时间窗口 —— 近似而非精确。响应里原样带上
 * `window` 与 `windowed`,让前端把这个前提显式写给用户,而不是假装是节点级归属。
 *
 * 一次性拉取(前端不轮询它):整份会话是几百 KB 量级,跟着 run 状态轮询会很贵。
 */
router.get('/:id/nodes/:nodeRunId/transcript', h((req, res) => {
  const run = getRun(req.params.id);
  if (!run) return res.status(404).json({ error: `graph run 不存在: ${req.params.id}` });

  const node = getRunNode(req.params.nodeRunId);
  if (!node || node.run_id !== run.id) {
    return res.status(404).json({ error: `节点 ${req.params.nodeRunId} 不属于 run ${run.id}` });
  }

  // 本体(角色名)绑在**定义层**节点上,graph_run_nodes 里没有这一列 ——
  // 运行时行只有 def_node_id,systemAgentId 是 getRunState 合并定义层之后才有的。
  const merged = (getRunState(run.id) || { nodes: [] }).nodes.find((n) => n.id === node.id);
  const role = merged && merged.systemAgentId;
  if (!role) {
    // subgraph 节点自己不调 agent(它的活由子 run 里的节点做),没有可读的原文
    return res.json({
      ok: false, reason: 'no-agent', role: null,
      hint: '这个节点没有绑定本体(subgraph 节点自己不调 agent)——原文在子图各节点上。',
    });
  }

  const transcript = require('../services/agent-transcript');
  // completed_at 为空 = 还在跑:until 留空,这样正在写入的内容也能看到
  const since = node.started_at || null;
  const until = node.completed_at || null;

  let out;
  try {
    out = transcript.readEntries({ companyId: run.company_id, role, since, until });
  } catch (err) {
    return res.status(500).json({ error: `读 transcript 失败: ${err.message}` });
  }

  res.json({
    ...out,
    role,
    window: { since, until },
    // 目录路径原样回出去:找不到文件时,用户能自己去看那个路径存不存在
    // (比一句「暂无数据」有用得多 —— 后者分不清「没说过话」和「我们没找到」)
    attemptHint: `本节点 attempt #${(node.attempt || 0) + 1}`,
  });
}));

/**
 * GET /api/graph-runs/:id/stream — SSE,实时推该 run 的状态转换事件
 * 客户端可带 Last-Event-ID header(或 ?lastEventId=)断线重放,语义与 devloop 的 /stream 一致。
 *
 * graph-run-sse.js 由 T5 并行落地;还没到位时回 503 而不是让整个 server 起不来。
 */
router.get('/:id/stream', (req, res) => {
  const run = getRun(req.params.id);
  if (!run) return res.status(404).json({ error: `graph run 不存在: ${req.params.id}` });

  let sse;
  try {
    sse = require('../services/graph-run-sse');
  } catch (err) {
    return res.status(503).json({ error: `SSE 通道未就绪(graph-run-sse 未加载): ${err.message}` });
  }

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders?.();

  const lastEventId = req.headers['last-event-id'] || req.query.lastEventId || null;
  sse.subscribe(run.id, res, { replayFromOffset: lastEventId != null ? lastEventId : undefined });
});

/**
 * GET /api/company/:companyId/graph-runs — 某公司的 run 列表
 * query: rootOnly=1 只列 root run
 * 公司无 run 时返回空数组,不报错(与 GET /api/company/:cid/org-graph 的处理一致)。
 *
 * ⚠️ **批 4 删掉了 `canDispatch` / `activeRootRunId` 两个字段**,没有换成 `slots`。
 * 理由(§九.4quater 的规矩:零消费方的字段要么接上要么删掉):
 *   · 两个字段自批 2 起就**零消费方** —— 全仓只剩注释在引用它们当反面教材,
 *     `GraphConsole.vue` 的 loadRuns 只取 `j.runs`,用例一条都没断言过它们;
 *   · 它们的判据在批 4 之后必然**算错**:`canDispatch: !activeRoot` 的含义是「一个都不许有」,
 *     而 max=3 的公司占了 1 个位照样能下达。留着 = 一个没人读、还回错值的字段;
 *   · 换成 `slots:{max,used,free}` 就是把同一个事实**第三次**摊到 HTTP 上
 *     (409 的 `{active,max,holders}` 已经是权威形状),而且同样零消费方。
 *     真要给界面画并发,落点是随 taskView 一起给的那一份(与 409 同源,见 slotState),
 *     不是在这个「列 run」的端点上把 root run 再数一遍。
 */
companyRouter.get('/:companyId/graph-runs', h((req, res) => {
  const rootOnly = req.query.rootOnly === '1' || req.query.rootOnly === 'true';
  res.json({
    companyId: req.params.companyId,
    runs: listCompanyRuns(req.params.companyId, { rootOnly }),
  });
}));

module.exports = router;
module.exports.companyRouter = companyRouter;
// company.js 的 409 hint 要照它判「这条 run 现在停不停得掉」(理由见常量注释)
module.exports.STOPPABLE_RUN_STATUS = STOPPABLE_RUN_STATUS;
// 「执行位」的形状与那句下一步:company.js 的 POST /tasks/:id/start 与 startNextIfSlotFree
// 都用这两个,别在那边再写一份(批 4 之前那边是 `.find(active)` + 三个单数字段,
// 与这里的 holders[] 各说各话)。
module.exports.slotState = slotState;
module.exports.slotBusyHint = slotBusyHint;