graph-run.js
35.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
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
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
/**
* Graph Run Routes — P3 图执行的 REST 端点(T6)
*
* 挂载: /api/graph-runs(主 router)
* /api/company(companyRouter,只提供 GET /:companyId/graph-runs)
*
* 设计见 docs/done/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 { requireAuthOrInternal } = 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,改的时候容易漏一个)。
// 39 号文档:agent 的自环调用(ceo 心跳查/停/重试 run)没有用户 cookie,改用
// requireAuthOrInternal(放行带对 INTERNAL_TOKEN 的自环调用),与 company.js 同一处理方式
// —— ceo 能读写 goal/task 板却摸不到这里的 run 控制端点,是真实撞出来的缺口,不是预防性加固。
router.use(requireAuthOrInternal);
companyRouter.use(requireAuthOrInternal);
// 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);
// 39 号文档:内部自环调用(req.user.internal)没有"当前登录用户"这个概念,不能对它
// 做归属校验——与 company.js 的 :companyId param 中间件是同一处理方式。
if (req.user?.internal) {
if (!company) return res.status(404).json({ error: `公司 ${companyId} 不存在` });
req.company = company;
return next();
}
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 (req.user?.internal) {
req.run = run;
return next();
}
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 的默认错误处理器:
* 一页 `<!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;
// auto 模式的 run 后台已经挂着 runAuto 驱动循环在自己推进(startRun 起跑时挂的),
// 这里再手动 /next 会和它并发推进同一条 run、两条路径互不知情(T-mtgxcnfz 实锤复现)。
// 想手动单步就先 /pause,落 paused 后 execution_mode 不变但驱动循环已经退出了。
if (run.execution_mode === 'auto') {
return res.status(400).json({ error: 'auto 模式的 run 由后台驱动循环自动推进,不支持手动「下一步」;请先 /pause 再单步' });
}
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/nodes/:nodeId/force-fail — 「强制判失败」(42 号文档 Phase 1b)
* body: { failReason?: string }(默认 'operator_intervention',刻意不是 'infra_flake'——
* 那个值有特殊语义:原地重跑、不烧计数,人工判定不该被误当成一次性抖动)
* 可用条件:节点 status = 'running'
*
* `retry` 端点管不到 running 的节点(那是它自己的既有约定,不是这次要改的)——这个端点补的
* 正是那个缺口:人类或 ceo 判断"这个节点已经不可能再自己完成了"时的手动出口。转 fail 之后
* 交给引擎既有的失败路由(selectFailEdge/handleBackwardEdge),不发明新路径。
*/
router.post('/:id/nodes/:nodeId/force-fail', 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 (nodeRun.status !== 'running') {
return res.status(400).json({
error: `「强制判失败」在节点 status='${nodeRun.status}' 下不可用,仅当节点 status = 'running' 时可用(fail/blocked 请用 retry 端点)`,
op: 'force-fail',
nodeStatus: nodeRun.status,
allowed: ['running'],
});
}
const { failReason } = req.body || {};
res.json(engine.forceFailNode(run.id, nodeRun.id, failReason || undefined));
}));
/**
* POST /api/graph-runs/phase-note — 42 号文档 Phase 4:planning/reflection 轨迹落进 timeline。
* body: { artifact: string, phase?: string, agentId?: string }
*
* `bin/lemon.js task transition` 在打 legacy 的 `/api/agent/:agentId/task/transition`
* 之外,额外(best-effort、不影响主流程)打这一个端点。它不需要 `:id`——调用方(跑在
* work graph 节点里的 agent)不知道自己的 runId,只知道自己刚写的 `--artifact` 绝对路径;
* 这个路径的形状(`.../runs/{rootRunId}/nodes/{nodeRunId}/...`)本身就带着两个 ID
* (节点私有产物目录的既有约定,见 `graph-engine.js` 的 `nodeArtifactDir`/`runRootDir`),
* 从路径里解出来就行,不用改任何 agent.md 的提示词、也不用改 `AgentManager` 往子进程塞
* 新的环境变量。
*
* 解不出来 / 解出来的 rootRunId 不对应真实存在的 run(legacy 会话式 agent 调用这条命令时
* 就是这样——它们的 `--artifact` 压根不是这个路径形状):安静跳过,返回 `{ok:false,
* reason:'not-a-graph-artifact'}`,不是错误——这条端点存在的前提就是"大多数调用方都用不上它"。
*
* 只做**追加**:不碰 `graph_run_nodes`/`graph_runs` 任何一行,不影响 needs/produces 契约、
* 不影响 validator 的判定依据(39 号文档 §七.2 定案的边界——private 产物不能变成契约输入)。
*/
router.post('/phase-note', h((req, res) => {
const { artifact, phase, agentId } = req.body || {};
if (typeof artifact !== 'string' || !artifact) {
return res.json({ ok: false, reason: 'no-artifact' });
}
const m = artifact.match(/[\\/]runs[\\/]([^\\/]+)[\\/]nodes[\\/]([^\\/]+)[\\/]/);
if (!m) return res.json({ ok: false, reason: 'not-a-graph-artifact' });
const [, rootRunId, nodeRunId] = m;
if (!getRun(rootRunId)) return res.json({ ok: false, reason: 'not-a-graph-artifact' });
const sse = require('../services/graph-run-sse');
const event = sse.emitEvent(
rootRunId, 'agent.phase',
`${agentId || '(未知 agent)'} 阶段流转 → ${phase || '(未知阶段)'}`,
{ agentId: agentId || null, phase: phase || null, artifact },
{ ref: nodeRunId, kind: 'agent.phase' }
);
res.json({ ok: true, rootRunId, nodeRunId, timelineEventId: event && event.timeline && event.timeline.ts });
}));
/**
* 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;