graph-run.js
24.6 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
/**
* 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 也回这个值
*/
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 }),
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;