/** * 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); }); });