/** * 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 { requireAuth } = require('../middleware/jwt-auth'); const { CompanyService, canAccessCompany } = require('../services/company-service'); const router = express.Router(); const companyRouter = express.Router(); const companyService = new CompanyService(); // 38 号文档 Phase 1:全站要求登录(两个挂载点都要挂,§六风险 2 —— 这个文件以两种方式 // 挂载到 index.js,改的时候容易漏一个)。 router.use(requireAuth); companyRouter.use(requireAuth); // 38 号文档 Phase 2:公司归属校验。两个挂载点的参数形状不一样,不能抄同一份 companyId 收口: // - companyRouter 的唯一路由是 /:companyId/graph-runs,直接按 companyId 查。 // - 主 router 全部路由都是 /:id/...,:id 指的是 run 而不是公司(GET /:id 吐的是 run 实例, // 见文件头注释)——要先查出这条 run 属于哪家公司,再判断当前用户碰不碰得到。 // 两处判据都调 company-service.js 的 canAccessCompany,不各写一份。 companyRouter.param('companyId', (req, res, next, companyId) => { const company = companyService.get(companyId); if (!canAccessCompany(company, req.user)) { return res.status(404).json({ error: `公司 ${companyId} 不存在` }); } req.company = company; next(); }); router.param('id', (req, res, next, runId) => { const run = getRun(runId); if (!run) return res.status(404).json({ error: `run ${runId} 不存在` }); const company = companyService.get(run.company_id); if (!canAccessCompany(company, req.user)) { // run 存在但公司不是你的:仍然回 run 不存在,不暴露"有这条 run,只是不是你的"。 return res.status(404).json({ error: `run ${runId} 不存在` }); } req.run = run; next(); }); // ──────────────────────────────────────────────────────────────── // 小工具 // ──────────────────────────────────────────────────────────────── /** * handler 包装:抛出来的异常统一落到下面的 sendError —— **同步和异步两条路都要包住**。 * * 原先只有 `Promise.resolve(fn(...)).catch(...)`。那个 `.catch` 是在 `fn` **返回之后**才挂上去的, * fn 同步抛出时这一行根本执行不到,异常直接冒到 express 的默认错误处理器: * 一页 `` 的 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)); })); /** * POST /api/graph-runs/:id/budget — 「追加预算」(运维操作,不在 §四.C 五个操作之列) * body: { total_agent_calls?, total_tokens?, wallclock_ms?, resume?: boolean } * 可用条件:run_status = 'blocked' * * 只在这一个状态开放:预算耗尽 / 墙钟看门狗(`sweepWallclockBudgets`)打到 blocked 的 run * 才需要人工追加额度,其它状态下加预算没有对应的业务场景(想在 running 时顺手提高上限, * 应该是另一个决策要不要做的问题,这里不顺手放开)。 * `resume: true` 时追加完立即调 `/start` 同一条路径拉回 running——两步拆成一个请求, * 免得调用方在这两步之间手动再打一次 `/start`。 */ router.post('/:id/budget', h((req, res) => { const run = loadRun(req, res); if (!run) return; if (!ensureStatus(res, run, '追加预算', ['blocked'])) return; const { total_agent_calls, total_tokens, wallclock_ms, resume } = req.body || {}; let out; try { out = engine.extendBudget(run.id, { total_agent_calls, total_tokens, wallclock_ms }); } catch (err) { return res.status(400).json({ error: err.message }); } if (!resume) return res.json(out); // 复用「启动」同一条路径(admitOrRequeue 的坑满退回队列、清软信号……一样都不重写) const fresh = loadRun(req, res); if (!fresh) return; const { driver, ...startOut } = engine.startRun(fresh.id, {}); res.json({ ...out, resumed: { ...startOut, auto: Boolean(driver) } }); })); // ──────────────────────────────────────────────────────────────── // 只读查询类端点 // ──────────────────────────────────────────────────────────────── /** * 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;