graph-boundary.test.js 7.93 KB
/**
 * Unit Tests — graph-engine 的项目根与边界守卫(27-P3B W2/W3)
 *
 * 验的是「产生 → 传递 → 落库 → 读回」四环:
 *   W2-1 起一个 run,节点收到的任务描述里带着项目根的绝对路径(传递)
 *   W2-2 项目根落到 runs/{rootRunId}/artifacts/project-root.json(落库)
 *   W2-3 同一个 root run 的第二个节点读回**同一个**项目根,不重新分配(读回,幂等)
 *   W3-1 diffRepoStatus 只报「本次调用期间新出现」的那几行(守卫的判据本身)
 *
 * 隔离手法同 graph-engine.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
 * 仓库 data/ 全程不被碰;agent 调用走注入的假 executor,不起 Claude CLI。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

describe('graph-engine 项目根与边界守卫', () => {
  let wdb;
  let engine;
  let runsDir;
  const TEST_DB_PATH = '/tmp/test-graph-boundary.sqlite';

  beforeAll(() => {
    for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
      try { fs.unlinkSync(f); } catch {}
    }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-boundary-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
    for (const m of [
      '../../services/graph-db',
      '../../services/graph-service',
      '../../services/org-graph-service',
      '../../services/work-graph-db',
      '../../services/graph-run-sse',
      '../../services/graph-engine',
    ]) {
      delete require.cache[require.resolve(m)];
    }
    wdb = require('../../services/work-graph-db');
    engine = require('../../services/graph-engine');
  });

  afterAll(() => {
    for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
      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;
    // 同一个进程里后续文件再 require 时要拿回没有 override 的那份
    for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) {
      delete require.cache[require.resolve(m)];
    }
  });

  function ensureAgents(ids) {
    const db = wdb.getDb();
    const ts = 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}`, ts, ts);
  }

  it('W2:项目根进提示词、落库到 artifacts/project-root.json,第二个节点读回同一个', async () => {
    const companyId = 'co-boundary-1';
    ensureAgents(['impl', 'check']);
    const g = wdb.createWorkGraph({
      companyId,
      nodes: [
        { nodeName: 'impl', systemAgentId: 'impl' },
        { nodeName: 'check', systemAgentId: 'check' },
      ],
      edges: [{ from: 'impl', to: 'check', condition: 'success' }],
    });
    expect(g.errors || []).toEqual([]);
    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId });
    engine.markRunReady(run.id);

    const tasks = [];
    engine.setAgentExecutor(async (ctx) => {
      tasks.push({ node: ctx.nodeName, task: ctx.task, projectRoot: ctx.projectRoot });
      return { status: 'success' };
    });

    engine.startRun(run.id, { mode: 'manual' });
    await engine.advanceOne(run.id);
    await engine.advanceOne(run.id);
    engine.setAgentExecutor(null);

    // ① 落库
    const recFile = path.join(runsDir, run.id, 'artifacts', 'project-root.json');
    expect(fs.existsSync(recFile)).toBe(true);
    const rec = JSON.parse(fs.readFileSync(recFile, 'utf8'));
    expect(rec.root_path).toBeTruthy();
    expect(fs.existsSync(rec.root_path)).toBe(true); // 目录真的被建出来了

    // ② 传递:两个节点都拿到了项目根,且提示词里是绝对路径
    expect(tasks.length).toBe(2);
    for (const t of tasks) {
      expect(t.projectRoot).toBe(rec.root_path);
      expect(t.task).toContain(rec.root_path);
      expect(t.task).toContain('[写入边界');
    }

    // ③ 读回幂等:第二个节点没有另分配一个根
    expect(tasks[0].projectRoot).toBe(tasks[1].projectRoot);
  });

  /**
   * 真 CLI 冒烟的断点:招聘一个模板库里没有的角色会走 hired_from_base,
   * 自动流水线起 agent-creator,而它的 CLI workDir 就是 system/agents/agent-creator/。
   * 它在里面重写自己的 todo.md、删一条 scheduled_tasks —— 这些改动被整仓 git diff 扫到,
   * 归属算到了当时正在跑的那个 work 节点头上,一个**真正成功**的节点被判 fail。
   * 只要这些运行态文件还被 git 跟踪,每次含 hired_from_base 的招聘都会稳定复现。
   */
  it('W3:agent 运行态文件不算越界(真跑冒烟的断点)', () => {
    const { escapes, unattributable } = engine.splitUnattributable([
      'M system/agents/agent-creator/todo.md',
      'M system/agents/agent-creator/.claude/scheduled_tasks.json',
      '?? server/routes/echo.js',
      'M server/index.js',
    ]);
    // 真越界的两条要留下判罚
    expect(escapes).toEqual(['?? server/routes/echo.js', 'M server/index.js']);
    // 运行态那两条摘出去,只记录不判罚
    expect(unattributable).toEqual([
      'M system/agents/agent-creator/todo.md',
      'M system/agents/agent-creator/.claude/scheduled_tasks.json',
    ]);
  });

  it('W3:路径提取按空白切,不按固定偏移(修改类只有 1 个状态字符)', () => {
    // 固定 slice(3) 会把 "M system/..." 切成 "stem/...",恰好对**修改类**失效 ——
    // 而 agent-creator 改自己 todo.md 正是修改类,这条锁的就是那个回归
    const { unattributable } = engine.splitUnattributable(['M system/agents/x/plan.md']);
    expect(unattributable).toEqual(['M system/agents/x/plan.md']);
  });

  it('W3(28 修订):本体定义文件也归 unattributable,判罚留给纯源码目录', () => {
    // 这条原来断言「agent.md / config.json 仍算越界」,前提是本体 = 纯源码。
    // 28 号之后 system/agents/ 是**注册表**:agent-creator 在运行期就往新本体里写
    // agent.md / soul.md,hireFromBaseMetaAgent 直接新建整个目录 —— 守卫无从归属
    // (同期可能有别的招聘在跑),再判罚就是冤杀触发招聘的那个节点。
    // 「摘除范围不能过宽」这层意思仍然锁着,只是判据挪到了树的边界上(见下面两条)。
    const { escapes, unattributable } = engine.splitUnattributable([
      'M system/agents/agent-creator/agent.md',
      'M system/agents/agent-creator/config.json',
    ]);
    expect(escapes).toEqual([]);
    expect(unattributable).toHaveLength(2);
  });

  it('W3:摘除范围不过宽 —— 纯源码目录与同前缀的邻居目录仍判越界', () => {
    const { escapes, unattributable } = engine.splitUnattributable([
      'M server/services/graph-engine.js',
      '?? web/src/views/X.vue',
      '?? system/agents-backup/x.md',   // 只是前缀像,不在注册表树里
      '?? system/templates/y/agent.md',
    ]);
    expect(escapes).toHaveLength(4);
    expect(unattributable).toEqual([]);
  });

  it('W3:diffRepoStatus 只报本次调用期间新出现的仓库改动', () => {
    const before = ' M server/services/graph-engine.js\n?? scratch/a.txt\n';
    const after = ' M server/services/graph-engine.js\n?? scratch/a.txt\n?? server/routes/echo.js\n M server/index.js\n';
    expect(engine.diffRepoStatus(before, after)).toEqual([
      '?? server/routes/echo.js',
      'M server/index.js',
    ]);
    // 没有新增就是没有越界;空快照也不该炸
    expect(engine.diffRepoStatus(after, after)).toEqual([]);
    expect(engine.diffRepoStatus(null, '')).toEqual([]);
  });
});