graph-run-phase-note.test.js 6.32 KB
/**
 * 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');
  });
});