graph-edge-contract-role.test.js 12.2 KB
/**
 * Unit Tests — R-19.A2 契约的 role 落库 + PATCH 通道上的规则 6(27-P4 §4.6)
 *
 * 补的是 R-19.A 之后**还漏的那一环**,形状与前十三次完全一样:
 * `normalizeContracts` 产出 `role`(produces / needs),`declareEdgeContract` 的入参里没有它、
 * 表也没有这一列 —— 产生 → **传递(丢)** → 落库 → 读回。
 *
 * 为什么这一环非补不可:规则 6(data-contract)靠「有人 needs 但没人 produces」判错,
 * 只存 kind 的话从库里读回来就分不清谁该产、谁只是要吃,校验在库这一侧永远算不出来。
 * 而 `patchGraph` 第 5 步「改完的整张图必须仍然合法」读的正是库 ——
 * 增量 PATCH(删节点 / 删边)又恰恰是「此前满足的 needs 失去产出方」的**唯一发生地**,
 * 于是校验偏偏在最该生效的那条通道上失效。全量 writeGraph 反而没事:
 * 规划 agent 刚写的声明还在内存里那份数组上。
 *
 * 四条用例分别锁:① role 真的落库且两个值可区分 ② readGraph 把契约读回来
 * ③ **PATCH 删掉产出方 → 整体回滚**(本次修复的全部意义)④ 旧行 NULL role 按 produces 读。
 *
 * 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 全程不碰;
 * 全部走服务层真入口,不起 Claude CLI。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';

const TEST_DB_PATH = '/tmp/test-graph-contract-role.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',
];

describe('R-19.A2 契约 role 落库 + PATCH 通道的 data-contract 校验', () => {
  let wdb;
  let org;

  beforeAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
    wdb = require('../../services/work-graph-db');
    org = require('../../services/org-graph-service');
  });

  afterAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    delete process.env.GRAPH_DB_PATH;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  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;

  const edgeIdOf = (companyId, from, to) =>
    wdb.getDb()
      .prepare(`
        SELECT id FROM org_graph_edges
        WHERE company_id = ? AND from_node_id = ? AND to_node_id = ? AND status = 'active'
      `)
      .get(companyId, orgNodeId(companyId, from), orgNodeId(companyId, to)).id;

  /** 一张三节点的图:planner 产 prd+acceptance,validator 隔着 coder 吃 acceptance */
  function seedRelayGraph(companyId) {
    const res = org.writeGraph(companyId, {
      nodes: ['org-planner', 'org-coder', 'org-validator'],
      edges: [
        { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['prd', 'acceptance'] },
        { 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);
    return res;
  }

  it('① role 落进 graph_edge_contracts,produces / needs 两个值可区分', () => {
    const companyId = 'co-role-store';
    seedRelayGraph(companyId);

    const roleOf = (from, to, kind) =>
      wdb.edgeContracts('org', edgeIdOf(companyId, from, to)).find((c) => c.kind === kind).role;

    // 同一个 kind(acceptance)在两条边上是两个不同的 role —— 只存 kind 时这层区别就没了
    expect(roleOf('org-planner', 'org-coder', 'acceptance')).toBe('produces');
    expect(roleOf('org-coder', 'org-validator', 'acceptance')).toBe('needs');
    expect(roleOf('org-planner', 'org-coder', 'prd')).toBe('produces');
  });

  it('① work 层同样落 role(两层共用一张表、一个归一函数)', () => {
    const companyId = 'co-role-work';
    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, legacy_path, status, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
    `);
    for (const id of ['brd-planning', 'prd-planning', 'sdd-planning']) {
      ins.run(id, id, `system/agents/${id}`, ts, ts);
    }

    const g = wdb.createWorkGraph({
      companyId,
      taskId: 'T-role',
      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' },
        { from: 'p-prd', to: 'p-sdd', condition: 'success', produces: ['prd'], needs: ['brd'] },
      ],
    });
    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 relay = def.edges.find(
      (e) => e.from === byName.get('p-prd') && e.to === byName.get('p-sdd')
    );
    const byKind = new Map(wdb.edgeContracts('work', relay.id).map((c) => [c.kind, c.role]));
    expect(byKind.get('prd')).toBe('produces');
    expect(byKind.get('brd')).toBe('needs');
  });

  it('② readGraph / getGraph 把契约读回来(PATCH 校验与控制台都靠它)', () => {
    const companyId = 'co-role-read';
    seedRelayGraph(companyId);

    const graph = org.getGraph(companyId);
    const edge = graph.edges.find(
      (e) => e.from === orgNodeId(companyId, 'org-coder') && e.to === orgNodeId(companyId, 'org-validator')
    );
    // 缺了这一行,第 5 步的重新校验拿到的是一张没有契约的图 → 规则 6 恒真通过
    expect(edge.contracts).toEqual([{ kind: 'acceptance', required: true, role: 'needs' }]);
    // 无声明的边上 contracts 整个字段不出现(与 failReason 同一个约定:
    // 绝大多数存量边一条声明都没有,恒空数组会让既有消费方凭空多出一个字段)。
    // backward 边按规则不许声明契约,正好是这种形态。
    const back = graph.edges.find(
      (e) => e.from === orgNodeId(companyId, 'org-validator') && e.to === orgNodeId(companyId, 'org-coder')
    );
    expect(back).not.toHaveProperty('contracts');
    // 对外形状不带内部行 id
    expect(edge).not.toHaveProperty('id');
  });

  it('③ PATCH 删掉产出方节点 → 规则 6 拒绝,整个 PATCH 回滚', () => {
    const companyId = 'co-role-patch';
    seedRelayGraph(companyId);

    // org-planner 是 acceptance 的唯一产出方;删了它,validator 那条 needs 就悬空了。
    // 这正是「跑到那一步才由 agent 说『我拿不到输入』」的结构版。
    const res = org.patchGraph(companyId, { removeNodes: ['org-planner'] });

    expect(res.ok).toBe(false);
    const contractErr = (res.errors || []).find((e) => e.rule === 'data-contract');
    expect(contractErr).toBeTruthy();
    expect(contractErr.detail).toContain('acceptance');

    // 回滚:planner 和它的契约都还在,库是改动前的样子
    expect(orgNodeId(companyId, 'org-planner')).toBeTruthy();
    expect(
      wdb.edgeContracts('org', edgeIdOf(companyId, 'org-planner', 'org-coder')).map((c) => c.kind).sort()
    ).toEqual(['acceptance', 'prd']);
  });

  it('③ 同一次 PATCH 里补上新产出方 → 通过(校验的是终态,不是删除动作本身)', () => {
    const companyId = 'co-role-patch-ok';
    seedRelayGraph(companyId);

    // 删掉 planner 的同时让另一个节点接过 acceptance 的产出 —— 终态合法就该放行,
    // 否则这条校验会变成「任何删除都不许」的死规则。
    const res = org.patchGraph(companyId, {
      addNodes: [{ roleName: 'org-brd' }],
      removeNodes: ['org-planner'],
      addEdges: [
        { from: 'org-brd', to: 'org-coder', condition: 'success', produces: ['prd', 'acceptance'] },
      ],
    });

    expect(res.errors || []).toEqual([]);
    expect(res.ok).toBe(true);
    expect(
      wdb.edgeContracts('org', edgeIdOf(companyId, 'org-brd', 'org-coder')).map((c) => c.role)
    ).toEqual(['produces', 'produces']);
  });

  it('④ 无契约声明的图(老库里的常态)PATCH 照旧放行,规则 6 不凭空报错', () => {
    const companyId = 'co-role-nocontract';
    const res0 = org.writeGraph(companyId, {
      nodes: ['org-planner', 'org-coder'],
      edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }],
    });
    expect(res0.ok).toBe(true);

    // 一条契约都没声明时,producedKinds 与 needs 都是空集 —— 规则 6 该完全沉默。
    // 存量公司的图全是这个形状,误报一条就是把所有老图的 PATCH 通道堵死。
    // (节点必须连边加进来,否则撞的是 single-entry / reachable 那两条既有规则,与本条无关)
    const res = org.patchGraph(companyId, {
      addNodes: [{ roleName: 'org-extra' }],
      addEdges: [{ from: 'org-coder', to: 'org-extra', condition: 'success' }],
    });
    expect(res.errors || []).toEqual([]);
    expect(res.ok).toBe(true);
  });
});

/**
 * 迁移路径单独一个库:新建库的 CREATE TABLE 里 role 是 NOT NULL,
 * 所以「role 为 NULL 的旧行」在新库里**结构上不可能出现** —— 往新库里硬塞 NULL
 * 测的是个不存在的状态。真造一个 R-19.A2 之前的表形态,让 ensureSchema 迁过去,
 * 测的才是 ALTER + 回填这条真实路径。
 */
describe('R-19.A2 迁移:老库补 role 列并回填 produces', () => {
  const MIG_DB = '/tmp/test-graph-contract-role-mig.sqlite';
  const MIG_FILES = [MIG_DB, `${MIG_DB}-shm`, `${MIG_DB}-wal`];
  let wdb;

  beforeAll(() => {
    for (const f of MIG_FILES) { try { fs.unlinkSync(f); } catch {} }

    // 1. 造出 R-19.A2 之前的表形态(没有 role 列)并写入一行「旧行」
    const Database = require('better-sqlite3');
    const raw = new Database(MIG_DB);
    raw.exec(`
      CREATE TABLE graph_edge_contracts (
        id          TEXT PRIMARY KEY,
        edge_layer  TEXT NOT NULL CHECK (edge_layer IN ('org','work')),
        edge_id     TEXT NOT NULL,
        kind        TEXT NOT NULL,
        required    INTEGER NOT NULL DEFAULT 1,
        created_at  TEXT NOT NULL
      );
      CREATE UNIQUE INDEX idx_graph_edge_contracts_dedupe
        ON graph_edge_contracts(edge_layer, edge_id, kind);
    `);
    raw
      .prepare(`
        INSERT INTO graph_edge_contracts (id, edge_layer, edge_id, kind, required, created_at)
        VALUES ('gec_legacy', 'org', '7', 'acceptance', 1, '2026-01-01T00:00:00.000Z')
      `)
      .run();
    raw.close();

    // 2. 让 ensureSchema 对着这个库跑一遍(require 时触发)
    process.env.GRAPH_DB_PATH = MIG_DB;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
    wdb = require('../../services/work-graph-db');
  });

  afterAll(() => {
    for (const f of MIG_FILES) { try { fs.unlinkSync(f); } catch {} }
    delete process.env.GRAPH_DB_PATH;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  it('旧行回填成 produces,而不是留空', () => {
    const row = wdb.edgeContracts('org', '7').find((c) => c.id === 'gec_legacy');
    expect(row).toBeTruthy();
    // 回填 produces 而不是 NULL:规则 6 里 NULL 既不算产出也不算需求,
    // 一条本该是 produces 的旧行留空,会让下游的 needs 突然找不到产出方 ——
    // 那是误拒一张合法的图。校验闸门宁可漏判不可错拦。
    expect(row.role).toBe('produces');
    expect(row.kind).toBe('acceptance');
    expect(row.required).toBe(1);
  });

  it('迁移后新写入的行照常带 role(迁移不是一次性补丁,是新形态的起点)', () => {
    wdb.declareEdgeContract({ edgeLayer: 'org', edgeId: '9', kind: 'prd', role: 'needs' });
    expect(wdb.edgeContracts('org', '9')[0].role).toBe('needs');
  });
});