graph-edge-contracts.test.js 19.4 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421
/**
 * 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, 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}`, 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=log 永远落成 required=0,哪怕调用方(模板/LLM 规划)写的是 required:true(33 号文档 + snakegame-co T-mtfh2kuf-7c31 两次复现)', () => {
    const companyId = 'co-contract-log-floor';
    ensureAgents(['unit-tester', 'coder-validator']);
    const g = wdb.createWorkGraph({
      companyId,
      taskId: 'T-log-floor',
      nodes: [
        { nodeName: 'l-test', systemAgentId: 'unit-tester' },
        { nodeName: 'l-val', systemAgentId: 'coder-validator' },
      ],
      edges: [
        // 裸数组写法 = normalizeContracts 默认 required:true,正是漂移复现的形状
        { from: 'l-test', to: 'l-val', condition: 'success', produces: ['log'] },
      ],
    });
    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([['log', 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']);
    }
  });
});