graph-files-index.test.js 15.7 KB
/**
 * Unit Tests — R-19 产物索引(27-P4 §4.6)
 *
 * 锁的是第四次真 CLI 冒烟那个断点的正解。当时 org-planner-prd 的归因原话是
 * 「不是上游没产出,是它的产出**结构上到不了我手上**」—— 边只承载控制流
 * (condition / edge_kind / fail_reason),上游 brd.md 躺在节点私有目录,
 * 下游提示词里没有任何可读路径。
 *
 * 每条用例都按「产生 → 传递 → 落库 → 读回」四环走真入口:
 *   ①  版本链         同路径二次产出标 superseded_by,不删行(落库)
 *   ②  部分唯一索引    「同一键只有一个活的 + 留历史」这个选择本身
 *   ③a 沿边传递        出边声明 kind → 引擎登记实例 → 下游提示词里是绝对路径(全四环)
 *   ③b 任务级共享      acceptance 对**非相邻**节点也可见(R-9.3 planner→(coder)→validator)
 *   ④  缺输入告警      声明了却查不到实例时提示词里出现 ⚠️,不静默省略(R-19.4 读回端)
 *   ⑤  产出侧过滤      attempt-*.json 不登记(引擎自读的归因文件);节点 fail 不登记
 *   ⑥  索引重建        删掉 graph_files 后能从 graph_run_nodes 重建
 *
 * describeInputs / indexNodeOutputs / companyRootDir **故意不导出**(graph-engine.js
 * module.exports 那段注释写明了口径:不为测试新开公开面),所以这里全部通过 advanceOne
 * 这个真入口验 —— 输入侧读 executor 收到的 ctx.task,产出侧读 graph_files 落库行。
 *
 * 隔离手法同 graph-boundary.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
 * 仓库 data/ 全程不被碰;executor 全用注入的假货,不起 Claude CLI。
 * (注入 executor 还顺带关掉了边界守卫 —— boundaryGuardApplicable 只在
 *   agentExecutor === defaultAgentExecutor 时成立,所以这些用例不扫宿主仓库。)
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

describe('R-19 产物索引:graph_files / graph_edge_contracts', () => {
  let wdb;
  let engine;
  let runsDir;
  const TEST_DB_PATH = '/tmp/test-graph-files-index.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-files-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;
    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);
  }

  /** 建图 + 建 run + 置 ready + start,并给出「按节点名拿 defNodeId / 边 id」的取值器 */
  function buildRun({ companyId, taskId, nodes, edges = [] }) {
    ensureAgents(nodes.map((n) => n.systemAgentId || n.nodeName));
    const g = wdb.createWorkGraph({ companyId, taskId, nodes, edges });
    expect(g.errors || []).toEqual([]);
    const def = wdb.readDefGraph('work', g.id);
    const byName = new Map(def.nodes.map((n) => [n.name, n.defNodeId]));
    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId });
    engine.markRunReady(run.id);
    engine.startRun(run.id, { mode: 'manual' });
    return {
      def,
      runId: run.id,
      defNodeId: (name) => byName.get(name),
      edgeId: (from, to) =>
        def.edges.find((e) => e.from === byName.get(from) && e.to === byName.get(to)).id,
    };
  }

  /** 节点私有产物目录 —— 与 graph-engine.nodeArtifactDir 同源(GRAPH_RUNS_DIR 覆盖分支) */
  const nodeDir = (rootRunId, nodeRunId) => path.join(runsDir, rootRunId, 'nodes', nodeRunId);
  /** 公司根 —— 与 graph-engine.companyRootDir 同源(同一个 override 分支) */
  const companyRoot = (companyId) => path.join(runsDir, '_companies', companyId);

  /**
   * 跑 n 步,收集每个节点看到的提示词。writes[nodeName] 给出这个节点要落盘的文件,
   * 落在它自己的私有目录里、outputRef 指向该目录 —— 与 defaultAgentExecutor 的形状一致。
   */
  async function advance(runId, steps, { writes = {}, status = () => 'success' } = {}) {
    const tasks = new Map();
    engine.setAgentExecutor(async (ctx) => {
      tasks.set(ctx.nodeName, ctx.task);
      const files = writes[ctx.nodeName];
      if (!files) return { status: status(ctx.nodeName) };
      const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId);
      fs.mkdirSync(dir, { recursive: true });
      for (const [name, body] of Object.entries(files)) {
        fs.writeFileSync(path.join(dir, name), body);
      }
      return { status: status(ctx.nodeName), outputRef: dir };
    });
    try {
      for (let i = 0; i < steps; i++) await engine.advanceOne(runId);
    } finally {
      engine.setAgentExecutor(null);
    }
    return tasks;
  }

  const allFiles = (companyId) =>
    wdb.getDb()
      .prepare(`SELECT * FROM graph_files WHERE company_id = ? ORDER BY created_at, id`)
      .all(companyId);

  // ── ① 版本链 ─────────────────────────────────────────────────

  it('① 同一 (company, task, relPath) 二次产出:旧行标 superseded_by 而不是被删,findFiles 只给最新那份', () => {
    const companyId = 'co-files-ver';
    const taskId = 'task-ver';
    const rel = 'tasks/task-ver/brd.md';

    const v1 = wdb.recordFile({ companyId, taskId, relPath: rel, kind: 'brd' });
    const v2 = wdb.recordFile({ companyId, taskId, relPath: rel, kind: 'brd' });
    expect(v2).not.toBe(v1);

    const rows = allFiles(companyId);
    // 历史不被删:两行都在
    expect(rows.map((r) => r.id).sort()).toEqual([v1, v2].sort());
    // 旧行指向新行
    expect(rows.find((r) => r.id === v1).superseded_by).toBe(v2);
    // 当前版本恰好一行
    expect(rows.filter((r) => r.superseded_by === null).map((r) => r.id)).toEqual([v2]);

    // 读回:findFiles 只返回最新那份
    const found = wdb.findFiles({ companyId, taskId, kinds: ['brd'] });
    expect(found.map((f) => f.id)).toEqual([v2]);
  });

  // ── ② 部分唯一索引 ───────────────────────────────────────────

  it('② 部分唯一索引:两行同路径且都 superseded_by IS NULL 被拒;历史行同路径可共存', () => {
    const db = wdb.getDb();
    const companyId = 'co-files-idx';
    const rel = 'tasks/task-idx/prd.md';
    const ins = db.prepare(`
      INSERT INTO graph_files (id, company_id, task_id, rel_path, kind, created_at, superseded_by)
      VALUES (?, ?, 'task-idx', ?, 'prd', ?, ?)
    `);
    const now = new Date().toISOString();

    ins.run('gf-live-1', companyId, rel, now, null);
    // 第二个「当前版本」必须被索引挡住 —— 否则 findFiles 会同时返回两份,
    // 下游拿不准该读哪个(这条锁的是「用部分唯一索引」这个选择本身)
    expect(() => ins.run('gf-live-2', companyId, rel, now, null)).toThrow(/UNIQUE/i);
    // 而历史行(superseded_by 非空)同路径可以随便共存 —— 索引是**部分**的,不是全量的
    expect(() => ins.run('gf-old-1', companyId, rel, now, 'gf-live-1')).not.toThrow();
    expect(() => ins.run('gf-old-2', companyId, rel, now, 'gf-live-1')).not.toThrow();
    expect(allFiles(companyId).filter((r) => r.superseded_by === null)).toHaveLength(1);
  });

  // ── ③a 沿边传递(全四环)+ ⑤ attempt-*.json 不登记 ──────────

  it('③a 沿边传递:出边声明 brd → 引擎登记实例 → 下游提示词里是绝对路径;attempt-*.json 不登记', async () => {
    const companyId = 'co-files-edge';
    const taskId = 'task-edge';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
      edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
    });
    // 产生:规划时就在边上声明「这条边传一份 brd」
    wdb.declareEdgeContract({
      edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
    });

    const tasks = await advance(b.runId, 2, {
      writes: {
        'brd-writer': {
          'brd.md': '# BRD',
          // 引擎自读的归因文件,不是给下游的产物
          'attempt-1.json': JSON.stringify({ fail_reason: null }),
        },
      },
    });

    // 落库:只登记 brd.md,kind 取出边声明的 brd(不是兜底的 artifact)
    const rows = allFiles(companyId);
    expect(rows).toHaveLength(1);
    expect(rows[0].kind).toBe('brd');
    expect(path.basename(rows[0].rel_path)).toBe('brd.md');
    // attempt-*.json 一行都不该有
    expect(rows.filter((r) => /attempt-/.test(r.rel_path))).toEqual([]);

    // 传递:入边契约查得到
    const inbound = wdb.inboundContracts('work', b.def.edges, b.defNodeId('prd-writer'));
    expect(inbound.map((c) => c.kind)).toEqual(['brd']);

    // 读回:下游提示词里是能直接 open 的绝对路径
    const abs = path.join(companyRoot(companyId), rows[0].rel_path);
    expect(fs.existsSync(abs)).toBe(true);
    const prompt = tasks.get('prd-writer');
    expect(prompt).toContain('[上游输入');
    expect(prompt).toContain(`- brd:${abs}`);
    // 查得到实例就不该出现缺输入告警(段尾那句「标 ⚠️ 的表示…」是固定说明,不算告警)
    expect(prompt).not.toContain('⚠️ 上游声明会产出');
    // 上游自己那一步没有入边,不该凭空多一段
    expect(tasks.get('brd-writer')).not.toContain('[上游输入');
  });

  // ── ③b 任务级共享:非相邻可见 ────────────────────────────────

  it('③b 任务级共享:planner 产的 acceptance,隔着 coder 的 validator 也查得到(R-9.3 的形状)', async () => {
    const companyId = 'co-files-shared';
    const taskId = 'task-shared';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'planner' }, { nodeName: 'coder' }, { nodeName: 'validator' }],
      edges: [
        { from: 'planner', to: 'coder', condition: 'success' },
        { from: 'coder', to: 'validator', condition: 'success' },
      ],
    });
    // 刻意**不**声明任何边契约:planner→validator 根本没有边,纯边绑定表达不了这条依赖

    const tasks = await advance(b.runId, 3, {
      writes: { planner: { 'acceptance.json': JSON.stringify({ checks: ['a'] }) } },
    });

    const rows = allFiles(companyId);
    expect(rows).toHaveLength(1);
    expect(rows[0].kind).toBe('acceptance'); // 无声明时按文件名定 kind
    const abs = path.join(companyRoot(companyId), rows[0].rel_path);

    // 非相邻的 validator 拿到了路径 —— 这才是「两类数据流都要」的理由
    const prompt = tasks.get('validator');
    expect(prompt).toContain('[上游输入');
    expect(prompt).toContain(`- acceptance(任务级共享):${abs}`);
    // 相邻的 coder 同样看得到(任务级共享不受相邻性限制)
    expect(tasks.get('coder')).toContain(abs);
    // planner 跑的时候索引里还没有实例,不该有段落
    expect(tasks.get('planner')).not.toContain('[上游输入');
  });

  // ── ④ 缺输入告警(R-19.4 读回端)────────────────────────────

  it('④ 声明了 required 输入却查不到实例:提示词里出现 ⚠️,不静默省略', async () => {
    const companyId = 'co-files-missing';
    const taskId = 'task-missing';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'sdd-writer' }, { nodeName: 'impl' }],
      edges: [{ from: 'sdd-writer', to: 'impl', condition: 'success' }],
    });
    wdb.declareEdgeContract({
      edgeLayer: 'work', edgeId: b.edgeId('sdd-writer', 'impl'), kind: 'sdd', required: true,
    });

    // 上游什么都不写 → 声明在、实例不在
    const tasks = await advance(b.runId, 2);
    expect(allFiles(companyId)).toEqual([]);

    const prompt = tasks.get('impl');
    expect(prompt).toContain('[上游输入');
    expect(prompt).toContain('- sdd:⚠️');
    expect(prompt).toContain('索引里查不到实例');
  });

  // ── ⑤ 节点 fail 不登记 ──────────────────────────────────────

  it('⑤ 节点 fail 时落盘的半成品不进索引(下游不该把它当输入读)', async () => {
    const companyId = 'co-files-fail';
    const taskId = 'task-fail';
    const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'flaky' }] });

    await advance(b.runId, 1, {
      writes: { flaky: { 'brd.md': '# 半成品' } },
      status: () => 'fail',
    });

    // 文件真的落盘了(不是没写),但索引里一行都没有
    const state = wdb.getRunState(b.runId);
    // 图上没有任何 fail 边,失败节点最终落 blocked(fail → blocked 是 FSM 的终态转换)
    expect(state.nodes[0].status).toBe('blocked');
    expect(fs.existsSync(path.join(state.nodes[0].outputRef, 'brd.md'))).toBe(true);
    expect(allFiles(companyId)).toEqual([]);
  });

  // ── ⑥ 索引重建 ─────────────────────────────────────────────

  it('⑥ graph_files 被清掉后能从 graph_run_nodes 重建(有损:kind 退成 artifact)', async () => {
    const companyId = 'co-files-rebuild';
    const taskId = 'task-rebuild';
    const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'brd-writer' }] });

    await advance(b.runId, 1, { writes: { 'brd-writer': { 'brd.md': '# BRD' } } });
    expect(allFiles(companyId)).toHaveLength(1);

    // 索引坏了 / 被清空
    wdb.getDb().prepare(`DELETE FROM graph_files WHERE company_id = ?`).run(companyId);
    expect(wdb.findFiles({ companyId, taskId })).toEqual([]);

    // 节点行还在 → 能重建出「这个节点产出过哪个目录」
    expect(wdb.rebuildFilesFromNodes(companyId)).toBe(1);
    const rebuilt = wdb.findFiles({ companyId, taskId });
    expect(rebuilt).toHaveLength(1);
    expect(rebuilt[0].kind).toBe('artifact'); // 重建恢复不了语义,只恢复位置
    expect(rebuilt[0].rel_path).toBe(wdb.getRunState(b.runId).nodes[0].outputRef);
  });
});