graph-edge-contracts.test.js 18.5 KB
/**
 * Unit Tests — R-19.A 边上的数据契约:声明 → 落库 → 读回(27-P4 §4.6)
 *
 * 补的是 925d9db 之后**还空着的那一环**:`graph_files` / `graph_edge_contracts` 两张表、
 * work-graph-db 的六个 API、graph-engine 两侧(describeInputs / indexNodeOutputs)都在了,
 * 但**没有任何地方声明边上的契约** —— `declareEdgeContract` 零调用方,于是
 * `inboundContracts` 恒返回空,「沿边传递」这一整类数据流实际不生效(地基铺好了没人踩)。
 *
 * 所以本文件锁的是三件事,缺一条这一环就又空了:
 *   ① 规划侧写在边上的 `produces` / `needs`,经 createWorkGraph / writeGraph **真的落库**
 *      (两层都验:work 边 id 是 TEXT、org 边 id 是 INTEGER,contracts 表里都存字符串形式)
 *   ② 下游节点的 `inboundContracts` **真的非空**(这是 describeInputs 拼路径的唯一依据)
 *   ③ R-19.4 的结构校验:声明要吃 kind=X 而全图没人产 X → 写入前就拒,一行都不落库
 *
 * 为什么 `produces` / `needs` 是**两个**键(这层非对称是 ③ 的前提):
 * 合成一个键的话,同一行契约既是「上游产出」又是「下游要吃」,
 * 「声明要吃 X 但没人产 X」永远自洽 —— 校验会变成一条永远不报错的空跑规则。
 *
 * 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 全程不碰;
 * 全部走服务层,不起 Claude CLI。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

const ROOT = path.resolve(__dirname, '../../..');
const TEST_DB_PATH = '/tmp/test-graph-edge-contracts.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/graph-run-sse',
  '../../services/graph-engine',
];

describe('R-19.A 边上的数据契约:声明 → 落库 → 读回', () => {
  let wdb;
  let org;
  let engine;
  let runsDir;

  beforeAll(() => {
    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(), 'graph-contracts-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
    wdb = require('../../services/work-graph-db');
    org = require('../../services/org-graph-service');
    engine = require('../../services/graph-engine');
  });

  afterAll(() => {
    for (const f of DB_FILES) { 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 MODULES) delete require.cache[require.resolve(m)];
  });

  /** work_graph_nodes.system_agent_id 有外键指向 system_agents,先把本体行补上 */
  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);
  }

  /** org 层:按 roleName 取 active 节点 id(org 边的 from/to 是数字 id) */
  const orgNodeId = (companyId, roleName) =>
    wdb.getDb()
      .prepare(`SELECT id FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'`)
      .get(companyId, roleName).id;

  /** org 层:把 getGraph 的边补上行 id,好交给 inboundContracts(它按 e.id 查契约) */
  const orgEdges = (companyId) =>
    wdb.getDb()
      .prepare(`
        SELECT id, from_node_id AS "from", to_node_id AS "to", edge_kind AS edgeKind
          FROM org_graph_edges WHERE company_id = ? AND status = 'active' ORDER BY id
      `)
      .all(companyId);

  const kindsOf = (rows) => rows.map((r) => r.kind).sort();

  // ── ① + ② work 层(全链路)────────────────────────────────────

  it('① work 层:边上的 produces 落进 graph_edge_contracts,下游 inboundContracts 非空', () => {
    const companyId = 'co-contract-work';
    ensureAgents(['brd-planning', 'prd-planning', 'sdd-planning']);
    const g = wdb.createWorkGraph({
      companyId,
      taskId: 'T-work',
      nodes: [
        { nodeName: 'p-brd', systemAgentId: 'brd-planning' },
        { nodeName: 'p-prd', systemAgentId: 'prd-planning' },
        { nodeName: 'p-sdd', systemAgentId: 'sdd-planning' },
      ],
      edges: [
        // 字符串写法 + 数组写法都要收(模板里两种都可能出现)
        { from: 'p-brd', to: 'p-prd', condition: 'success', produces: 'brd' },
        // needs:sdd 还要吃 brd,而 brd 的产出方**不是**紧邻上游(跨节点中继)
        { from: 'p-prd', to: 'p-sdd', condition: 'success', produces: ['prd'], needs: ['brd'] },
      ],
    });
    expect(g.errors || []).toEqual([]);
    expect(g.ok).toBe(true);

    const def = wdb.readDefGraph('work', g.id);
    const byName = new Map(def.nodes.map((n) => [n.name, n.defNodeId]));
    const edgeId = (from, to) =>
      def.edges.find((e) => e.from === byName.get(from) && e.to === byName.get(to)).id;

    // 落库:契约挂在对应的那条边上(work 边 id 是 TEXT)
    expect(kindsOf(wdb.edgeContracts('work', edgeId('p-brd', 'p-prd')))).toEqual(['brd']);
    expect(kindsOf(wdb.edgeContracts('work', edgeId('p-prd', 'p-sdd')))).toEqual(['brd', 'prd']);

    // 读回:这是 describeInputs 拼上游路径的唯一依据,非空才算这一环通了
    const inPrd = wdb.inboundContracts('work', def.edges, byName.get('p-prd'));
    expect(inPrd.map((c) => c.kind)).toEqual(['brd']);
    expect(inPrd[0].fromNodeId).toBe(byName.get('p-brd'));
    expect(inPrd[0].required).toBe(1);
    expect(kindsOf(wdb.inboundContracts('work', def.edges, byName.get('p-sdd')))).toEqual(['brd', 'prd']);
    // 入口节点没有入边 → 没有契约(不该凭空多出输入)
    expect(wdb.inboundContracts('work', def.edges, byName.get('p-brd'))).toEqual([]);
  });

  it('① required:false 落成 0(describeInputs 靠它决定缺实例时报不报 ⚠️)', () => {
    const companyId = 'co-contract-optional';
    ensureAgents(['coder', 'coder-validator']);
    const g = wdb.createWorkGraph({
      companyId,
      taskId: 'T-optional',
      nodes: [
        { nodeName: 'c-impl', systemAgentId: 'coder' },
        { nodeName: 'c-val', systemAgentId: 'coder-validator' },
      ],
      edges: [
        {
          from: 'c-impl', to: 'c-val', condition: 'success',
          // 产出方在上一层 org graph / artifacts 里,本图管不着 → 可选
          needs: [{ kind: 'acceptance', required: false }],
        },
      ],
    });
    expect(g.errors || []).toEqual([]);
    const def = wdb.readDefGraph('work', g.id);
    const rows = wdb.edgeContracts('work', def.edges[0].id);
    expect(rows.map((r) => [r.kind, r.required])).toEqual([['acceptance', 0]]);
  });

  it('① kind 大小写/空格被归一("  PRD " 与 "prd" 是同一个契约,不产生两行)', () => {
    const companyId = 'co-contract-case';
    ensureAgents(['prd-planning', 'sdd-planning']);
    const g = wdb.createWorkGraph({
      companyId,
      taskId: 'T-case',
      nodes: [
        { nodeName: 'k-prd', systemAgentId: 'prd-planning' },
        { nodeName: 'k-sdd', systemAgentId: 'sdd-planning' },
      ],
      edges: [{ from: 'k-prd', to: 'k-sdd', condition: 'success', produces: ['  PRD ', 'prd'] }],
    });
    expect(g.errors || []).toEqual([]);
    const def = wdb.readDefGraph('work', g.id);
    expect(kindsOf(wdb.edgeContracts('work', def.edges[0].id))).toEqual(['prd']);
  });

  // ── ② 全四环(这一条才证明「规划侧声明」真的接到了引擎输入侧)──

  it('② 四环闭合:createWorkGraph 声明 → 引擎登记实例 → 下游提示词里是上游产物的绝对路径', async () => {
    const companyId = 'co-contract-e2e';
    const taskId = 'T-e2e';
    ensureAgents(['brd-planning', 'prd-planning']);
    // ① 产生:规划侧只写契约(不写文件名——此刻文件还不存在)
    const g = wdb.createWorkGraph({
      companyId,
      taskId,
      nodes: [
        { nodeName: 'e-brd', systemAgentId: 'brd-planning' },
        { nodeName: 'e-prd', systemAgentId: 'prd-planning' },
      ],
      edges: [{ from: 'e-brd', to: 'e-prd', condition: 'success', produces: ['brd'] }],
    });
    expect(g.errors || []).toEqual([]);

    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId });
    engine.markRunReady(run.id);
    engine.startRun(run.id, { mode: 'manual' });

    // 上游节点落盘一份 brd.md(形状与 defaultAgentExecutor 一致:产物在节点私有目录,outputRef 指向它)
    const prompts = new Map();
    engine.setAgentExecutor(async (ctx) => {
      prompts.set(ctx.nodeName, ctx.task);
      if (ctx.nodeName !== 'e-brd') return { status: 'success' };
      const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId);
      fs.mkdirSync(dir, { recursive: true });
      fs.writeFileSync(path.join(dir, 'brd.md'), '# BRD');
      return { status: 'success', outputRef: dir };
    });
    try {
      await engine.advanceOne(run.id); // e-brd
      await engine.advanceOne(run.id); // e-prd
    } finally {
      engine.setAgentExecutor(null);
    }

    // ③ 落库:kind 取自**出边上的声明**(不是文件名兜底的 artifact)
    const rows = wdb.findFiles({ companyId, taskId, kinds: ['brd'] });
    expect(rows).toHaveLength(1);
    expect(path.basename(rows[0].rel_path)).toBe('brd.md');

    // ④ 读回:下游提示词里是能直接 open 的绝对路径,且没有「查不到实例」的告警
    const downstream = prompts.get('e-prd');
    expect(downstream).toBeTruthy();
    expect(downstream).toContain('[上游输入');
    const abs = path.join(runsDir, '_companies', companyId, rows[0].rel_path);
    expect(fs.existsSync(abs)).toBe(true);
    expect(downstream).toContain(`- brd:${abs}`);
    expect(downstream).not.toContain('⚠️ 上游声明会产出');
    // 上游自己没有入边,不该凭空多一段输入
    expect(prompts.get('e-brd')).not.toContain('[上游输入');
  });

  // ── ① + ② org 层 ─────────────────────────────────────────────

  it('① org 层:writeGraph 的边声明同样落库,inboundContracts 对 org 节点非空', () => {
    const companyId = 'co-contract-org';
    const res = org.writeGraph(companyId, {
      nodes: ['org-planner', 'org-coder', 'org-validator'],
      edges: [
        { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['prd', 'acceptance'] },
        // R-9.3 的非相邻依赖:acceptance 由 planner 产、validator 吃,中间隔着 coder
        { from: 'org-coder', to: 'org-validator', condition: 'success', needs: ['acceptance'] },
        { from: 'org-validator', to: 'org-coder', condition: 'fail', edgeKind: 'backward' },
      ],
    });
    expect(res.errors || []).toEqual([]);
    expect(res.ok).toBe(true);

    const edges = orgEdges(companyId);
    const find = (from, to) =>
      edges.find((e) => e.from === orgNodeId(companyId, from) && e.to === orgNodeId(companyId, to));

    // org 边 id 是 INTEGER,contracts 表里存字符串形式 —— 两种类型都要能查回来
    const planned = find('org-planner', 'org-coder');
    expect(kindsOf(wdb.edgeContracts('org', planned.id))).toEqual(['acceptance', 'prd']);
    expect(kindsOf(wdb.edgeContracts('org', String(planned.id)))).toEqual(['acceptance', 'prd']);
    // backward 边上没有契约(规则不允许声明,见 ③)
    expect(wdb.edgeContracts('org', find('org-validator', 'org-coder').id)).toEqual([]);

    const inbound = wdb.inboundContracts('org', edges, orgNodeId(companyId, 'org-validator'));
    expect(kindsOf(inbound)).toEqual(['acceptance']);
    // 入边是 org-coder 那条 forward 边;backward 入边不算数据来源
    expect(String(inbound[0].fromNodeId)).toBe(String(orgNodeId(companyId, 'org-coder')));
  });

  it('① 同一 payload 里重复边被去重键吞掉时,契约仍挂到同一条边上(不静默蒸发)', () => {
    const companyId = 'co-contract-dupe';
    const res = org.writeGraph(companyId, {
      nodes: ['org-planner', 'org-coder'],
      edges: [
        { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['prd'] },
        // 完全同一条边(撞 idx_org_graph_edges_dedupe_active,插入被 DO NOTHING 吞掉)
        { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['sdd'] },
      ],
    });
    expect(res.ok).toBe(true);
    expect(res.edgeCount).toBe(1); // 实际落库行数,不是入参长度
    const edges = orgEdges(companyId);
    expect(edges).toHaveLength(1);
    expect(kindsOf(wdb.edgeContracts('org', edges[0].id))).toEqual(['prd', 'sdd']);
  });

  // ── ③ R-19.4 写入前校验 ──────────────────────────────────────

  it('③ 声明要吃 brd 但全图没人产 brd → 两层都拒,rule=data-contract 且点名缺哪个 kind', () => {
    const nodes = [{ nodeName: 'w-impl' }, { nodeName: 'w-val' }];
    const edges = [{ from: 'w-impl', to: 'w-val', condition: 'success', needs: ['brd'] }];

    const workVerdict = wdb.validateWorkGraph(nodes, edges);
    expect(workVerdict.ok).toBe(false);
    expect(workVerdict.errors.map((e) => e.rule)).toContain('data-contract');
    expect(workVerdict.errors.find((e) => e.rule === 'data-contract').detail).toContain('brd');

    const orgVerdict = org.validateGraph(
      [{ roleName: 'w-impl' }, { roleName: 'w-val' }],
      edges,
    );
    expect(orgVerdict.ok).toBe(false);
    expect(orgVerdict.errors.map((e) => e.rule)).toContain('data-contract');
  });

  it('③ 被拒的图一行都不落库(work / org 两条写入通道)', () => {
    const companyId = 'co-contract-rejected';
    ensureAgents(['coder', 'coder-validator']);
    const created = wdb.createWorkGraph({
      companyId,
      taskId: 'T-rejected',
      nodes: [
        { nodeName: 'r-impl', systemAgentId: 'coder' },
        { nodeName: 'r-val', systemAgentId: 'coder-validator' },
      ],
      edges: [{ from: 'r-impl', to: 'r-val', condition: 'success', needs: ['sdd'] }],
    });
    expect(created.ok).toBe(false);
    expect(created.errors.map((e) => e.rule)).toContain('data-contract');
    expect(
      wdb.getDb().prepare(`SELECT COUNT(*) AS n FROM work_graphs WHERE company_id = ?`).get(companyId).n,
    ).toBe(0);

    const written = org.writeGraph(companyId, {
      nodes: ['org-planner', 'org-coder'],
      edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success', needs: ['sdd'] }],
    });
    expect(written.ok).toBe(false);
    expect(written.errors.map((e) => e.rule)).toContain('data-contract');
    expect(orgEdges(companyId)).toEqual([]);
    expect(
      wdb.getDb()
        .prepare(`SELECT COUNT(*) AS n FROM org_graph_nodes WHERE company_id = ? AND status = 'active'`)
        .get(companyId).n,
    ).toBe(0);
  });

  it('③ required:false 的 needs 不要求本图内有产出方(显式逃生口,不是漏网)', () => {
    const verdict = wdb.validateWorkGraph(
      [{ nodeName: 'o-impl' }, { nodeName: 'o-val' }],
      [{
        from: 'o-impl', to: 'o-val', condition: 'success',
        needs: [{ kind: 'acceptance', required: false }],
      }],
    );
    expect(verdict).toEqual({ ok: true, errors: [] });
  });

  it('③ produces 自带产出方,正常链式声明不该被规则 6 误伤', () => {
    const verdict = wdb.validateWorkGraph(
      [{ nodeName: 'a-brd' }, { nodeName: 'a-prd' }, { nodeName: 'a-sdd' }],
      [
        { from: 'a-brd', to: 'a-prd', condition: 'success', produces: ['brd'] },
        { from: 'a-prd', to: 'a-sdd', condition: 'success', produces: ['prd'], needs: ['brd'] },
      ],
    );
    expect(verdict).toEqual({ ok: true, errors: [] });
  });

  it('③ 非法 kind 与「写在 backward 边上」都按 schema 拒(后者是静默失效的坑)', () => {
    const illegal = wdb.validateWorkGraph(
      [{ nodeName: 's-a' }, { nodeName: 's-b' }],
      [{ from: 's-a', to: 's-b', condition: 'success', produces: ['blueprint'] }],
    );
    expect(illegal.ok).toBe(false);
    expect(illegal.errors[0].rule).toBe('schema');
    expect(illegal.errors[0].detail).toContain('blueprint');
    // 取值域要在报错里给出来,agent 才改得动
    for (const kind of org.ARTIFACT_KINDS) expect(illegal.errors[0].detail).toContain(kind);

    const onBackward = wdb.validateWorkGraph(
      [{ nodeName: 's-a' }, { nodeName: 's-b' }],
      [
        { from: 's-a', to: 's-b', condition: 'success' },
        { from: 's-b', to: 's-a', condition: 'fail', edgeKind: 'backward', produces: ['prd'] },
      ],
    );
    expect(onBackward.ok).toBe(false);
    expect(onBackward.errors.map((e) => e.rule)).toEqual(['schema']);
    expect(onBackward.errors[0].detail).toMatch(/backward/);
  });

  // ── 防漂移:模板教的键名必须是落库侧真读的那个 ────────────────

  it('模板里写的边上声明键名,归一函数必须真的认(差一个字母就是静默失效)', () => {
    const dir = path.join(ROOT, 'system/agents/workgraph-planning');
    const text = fs.readdirSync(dir)
      .filter((f) => f.endsWith('.md'))
      .map((f) => fs.readFileSync(path.join(dir, f), 'utf-8'))
      .join('\n');

    // 模板必须两个键都教到(少了 produces 就没人产、少了 needs 就表达不了非相邻依赖)
    expect(text).toMatch(/"produces"/);
    expect(text).toMatch(/"needs"/);

    // 反向:模板里出现的每个「边上声明键」都得被 normalizeContracts 读进契约行
    const keys = [...new Set([...text.matchAll(/"(produces|needs|carries|consumes|inputs|outputs)"/g)]
      .map((m) => m[1]))];
    expect(keys.length).toBeGreaterThan(0);
    for (const key of keys) {
      const contracts = org.normalizeContracts({ from: 'x', to: 'y', [key]: ['brd'] });
      expect(contracts.map((c) => c.kind), `模板教了 "${key}",但归一函数不读这个键`).toEqual(['brd']);
    }
  });
});