/** * Unit Tests — POST /api/graph-runs/phase-note(42 号文档 Phase 4) * * `bin/lemon.js task transition` 额外打这个端点,把 agent 的阶段流转(RESEARCH/PLAN/ * EXECUTE/VERIFY 之类)落进 timeline.ndjson——org-coder 等角色调 legacy 的 * `/api/agent/:agentId/task/transition` 时,work graph 节点场景下那个端点的 sessions * Map 找不到会话,静默跳过;这条端点从 --artifact 路径本身(节点私有产物目录的既有形状 * `.../runs/{rootRunId}/nodes/{nodeRunId}/...`)解出两个 ID,不需要任何新的环境变量或 * agent.md 改动。 * * 钉住的边界(39 号文档 §七.2 定案):这条端点只追加 timeline,绝不碰 * graph_run_nodes/graph_runs 任何一行——不影响 needs/produces 契约、不影响 validator * 判定依据。 * * 走真入口:把 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-phase-note.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/phase-note', () => { let wdb; let sse; let srv; let base; let runsDir; const companyId = 'co-phase-note'; beforeAll(async () => { require('../helpers/ensure-test-company').ensureTestCompany(companyId); 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(), 'phase-note-runs-')); process.env.GRAPH_RUNS_DIR = runsDir; for (const m of MODULES) delete require.cache[require.resolve(m)]; wdb = require('../../services/work-graph-db'); sse = require('../../services/graph-run-sse'); 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)); require('../helpers/ensure-test-company').removeTestCompany(companyId); for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} delete process.env.GRAPH_DB_PATH; 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); } function makeRun() { ensureAgents(['org-coder']); const g = wdb.createWorkGraph({ companyId, nodes: ['org-coder'], 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]; return { runId: run.id, nodeRunId: node.id }; } async function post(body) { const r = await fetch(`${base}/api/graph-runs/phase-note`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return [r.status, await r.json()]; } it('合法的节点产物路径 → 写进 timeline.ndjson,节点状态不受影响', async () => { const { runId, nodeRunId } = makeRun(); const beforeStatus = wdb.getRunState(runId).nodes[0].status; const artifact = `/Users/x/lemonboss/data/companies/${companyId}/runs/${runId}/nodes/${nodeRunId}/_plan.md`; const [status, body] = await post({ artifact, phase: 'plan', agentId: `${companyId}:org-coder` }); expect(status).toBe(200); expect(body.ok).toBe(true); expect(body.rootRunId).toBe(runId); expect(body.nodeRunId).toBe(nodeRunId); // timeline.ndjson 里真的能查到这条记录 const raw = fs.readFileSync(sse.timelinePath(runId), 'utf8'); const lines = raw.trim().split('\n').map((l) => JSON.parse(l)); const mine = lines.find((l) => l.kind === 'agent.phase' && l.ref === nodeRunId); expect(mine).toBeTruthy(); expect(mine.payload.phase).toBe('plan'); expect(mine.payload.artifact).toBe(artifact); expect(mine.payload.agentId).toBe(`${companyId}:org-coder`); expect(mine.root_run_id).toBe(runId); // 不改契约面:节点状态原样,没有因为这条追加日志被结算或改动 expect(wdb.getRunState(runId).nodes[0].status).toBe(beforeStatus); }); it('artifact 路径不是节点产物目录形状(legacy 会话式 agent 的调用) → ok:false,安静跳过', async () => { const [status, body] = await post({ artifact: '/Users/x/lemonboss/system/agents/some-agent/co1/task-abc/_plan.md', phase: 'plan', agentId: 'co1:some-agent', }); expect(status).toBe(200); expect(body.ok).toBe(false); expect(body.reason).toBe('not-a-graph-artifact'); }); it('artifact 路径形状对,但 rootRunId 不对应真实存在的 run → ok:false', async () => { const [status, body] = await post({ artifact: `/Users/x/lemonboss/data/companies/${companyId}/runs/run_does_not_exist/nodes/rn_does_not_exist/_plan.md`, phase: 'plan', }); expect(status).toBe(200); expect(body.ok).toBe(false); expect(body.reason).toBe('not-a-graph-artifact'); }); it('没传 artifact → ok:false,reason:no-artifact', async () => { const [status, body] = await post({ phase: 'plan' }); expect(status).toBe(200); expect(body.ok).toBe(false); expect(body.reason).toBe('no-artifact'); }); });