graph-run.js
13 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
/**
* 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 } 入参不合法
* 500 { error } 其它
*/
const express = require('express');
const engine = require('../services/graph-engine');
const {
getRun,
getRunState,
getRunNode,
getRunNodeByDefId,
listChildRuns,
listCompanyRuns,
listLoopRecords,
InvalidTransitionError,
} = require('../services/work-graph-db');
const router = express.Router();
const companyRouter = express.Router();
// ────────────────────────────────────────────────────────────────
// 小工具
// ────────────────────────────────────────────────────────────────
/** async handler 包装:抛出来的异常统一落到下面的 sendError */
const h = (fn) => (req, res) => Promise.resolve(fn(req, res)).catch((err) => 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 });
}
res.status(500).json({ error: err.message });
}
/** 取 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 /api/graph-runs/:id/stop — 「停止」
* 可用条件:run_status ∈ {running, paused, blocked}
* 仍 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, '停止', ['running', 'paused', 'blocked'])) 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 查不出来
}
res.json({
...state,
loopRecords: listLoopRecords(state.run.id),
childRuns: listChildRuns(state.run.id),
budget,
});
}));
/**
* 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(v1 一家公司同时只有一个 active root run,见 §一非目标)
* 公司无 run 时返回空数组,不报错(与 GET /api/company/:cid/org-graph 的处理一致)。
*/
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;