graph-run-force-fail.test.js 7 KB
/**
 * 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);
  });
});