graph-run-force-fail.test.js
7 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
/**
* Unit Tests — POST /api/graph-runs/:id/nodes/:nodeId/force-fail(42 号文档 Phase 1b)
*
* `retry` 端点只接受 status ∈ {fail, blocked} 的节点,管不到 'running'——真实运维/ceo 遇到
* "这个节点已经不可能再自己完成了"这种情形,此前唯一的恢复手段是直接调内部函数,绕过了
* 任何 REST 接口。这条测试钉住新端点的可用条件与转移语义,不重复测 routeFailure 内部选边
* 逻辑(那部分已经在 graph-engine.test.js / graph-engine-orphan-sweep.test.js 里覆盖过)。
*
* 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,与 graph-run-route-io.test.js 同法。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import express from 'express';
const TEST_DB_PATH = '/tmp/test-graph-run-force-fail.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
'../../services/graph-db',
'../../services/graph-service',
'../../services/org-graph-service',
'../../services/work-graph-db',
'../../services/company-paths',
'../../services/graph-run-sse',
'../../services/graph-engine',
'../../routes/graph-run',
];
describe('POST /api/graph-runs/:id/nodes/:nodeId/force-fail', () => {
let wdb;
let srv;
let base;
let runsDir;
let seq = 0;
// 每个用例一个独立 company:一家公司同时只有一条活跃 root run(run-slot 约束),
// 同一个 company 起第二条 run 会撞 RunSlotBusyError。
const nextCompany = () => `co-force-fail-${++seq}`;
beforeAll(async () => {
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'force-fail-runs-'));
process.env.GRAPH_RUNS_DIR = runsDir;
for (const m of MODULES) delete require.cache[require.resolve(m)];
wdb = require('../../services/work-graph-db');
const app = express();
app.use(express.json());
app.use('/api/graph-runs', require('../../routes/graph-run'));
await new Promise((resolve) => {
srv = app.listen(0, '127.0.0.1', () => {
base = `http://127.0.0.1:${srv.address().port}`;
resolve();
});
});
});
afterAll(async () => {
if (srv) await new Promise((r) => srv.close(r));
const { removeTestCompany } = require('../helpers/ensure-test-company');
for (let i = 1; i <= seq; i++) removeTestCompany(`co-force-fail-${i}`);
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
delete process.env.GRAPH_DB_PATH;
try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
delete process.env.GRAPH_RUNS_DIR;
for (const m of MODULES) delete require.cache[require.resolve(m)];
});
function ensureAgents(ids) {
const db = wdb.getDb();
const now = new Date().toISOString();
const ins = db.prepare(`
INSERT OR IGNORE INTO system_agents
(id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
`);
for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
}
/** 建一条只有一个节点的 work run,直接把节点摁成指定状态(不关心怎么走到这个状态,
* 只关心端点在这个状态下的行为——执行语义已经在别的测试文件覆盖过)。 */
function buildRunWithNodeStatus(status) {
const companyId = nextCompany();
require('../helpers/ensure-test-company').ensureTestCompany(companyId);
ensureAgents(['w']);
const g = wdb.createWorkGraph({ companyId, nodes: ['w'], edges: [] });
expect(g.errors || []).toEqual([]);
const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
require('../../services/graph-engine').markRunReady(run.id);
const node = wdb.getRunState(run.id).nodes[0];
const now = new Date().toISOString();
wdb.getDb()
.prepare(`UPDATE graph_run_nodes SET status = ?, started_at = ?, updated_at = ? WHERE id = ?`)
.run(status, now, now, node.id);
return { runId: run.id, nodeRunId: node.id };
}
it('running 节点调用成功,转 fail 后交给既有失败路由(无边可走 → blocked,可被 retry 接上)', async () => {
const { runId, nodeRunId } = buildRunWithNodeStatus('running');
const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/force-fail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(r.status).toBe(200);
const body = await r.json();
expect(body.ok).toBe(true);
// 单节点图没有 fail 边可走 → blockNode,落在人工可 retry 的终点
expect(body.nodeStatus).toBe('blocked');
// 验证确实接得上 retry 端点(这才是「可被 retry 端点接上」这条验收的真实含义)
const retryRes = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/retry`, { method: 'POST' });
expect(retryRes.status).toBe(200);
const retryBody = await retryRes.json();
expect(retryBody.ok).toBe(true);
expect(retryBody.nodeStatus).toBe('ready');
});
it('自定义 failReason 会原样落进 fail_reason(走常规选边,不是 infra_flake 特判)', async () => {
const { runId, nodeRunId } = buildRunWithNodeStatus('running');
const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/force-fail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ failReason: 'stuck_no_progress' }),
});
expect(r.status).toBe(200);
// 单节点图没有 fail 边,无论什么 fail_reason 都落 blocked——这里只确认端点把自定义原因
// 传下去了没炸,选边逻辑本身已经在别处测过
const body = await r.json();
expect(body.ok).toBe(true);
expect(body.nodeStatus).toBe('blocked');
});
for (const status of ['fail', 'blocked', 'pending', 'ready', 'success']) {
it(`节点 status='${status}' 时调用返回 400(不是 running 就不该走这个端点)`, async () => {
const { runId, nodeRunId } = buildRunWithNodeStatus(status);
const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/force-fail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(r.status).toBe(400);
const body = await r.json();
expect(body.op).toBe('force-fail');
expect(body.nodeStatus).toBe(status);
expect(body.allowed).toEqual(['running']);
});
}
it('节点不存在返回 404', async () => {
const { runId } = buildRunWithNodeStatus('running');
const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/nope/force-fail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(r.status).toBe(404);
});
});