/** * 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, source_kind, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, 'global_agent', ?, '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); }); 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([]); }); });