org-layer-contracts.test.js 17.1 KB
/**
 * Unit Tests — R-19 **org 层**数据契约:先有读回方,才谈声明方
 *
 * 起因是第五次真 CLI 冒烟:`graph_edge_contracts` 里 `edge_layer='org'` 是 0 行。
 * 直觉判断是「orggraph-planning 模板没教它声明」,核实之后这个判断是**错的**:
 *
 *   - work-graph-db.readDefGraph 给 org 图的节点**全部**写 nodeKind='subgraph'
 *     (注释原文:「org graph 的节点全部是 subgraph executor」);
 *   - graph-engine.describeInputs 第一行就是 `ctx.nodeKind === 'subgraph'` 时返回空串。
 *
 * 两条合起来 = org 层的边契约在输入侧**一个读回方都没有**。先补模板的话,产出的正是
 * 「声明了没人读」——本仓库已经踩了 14 次的那个形状。
 *
 * 所以读回方接在 `buildPlanningPrompt`(org 节点派给 workgraph-planning 的提示词)上:
 * org 层契约的语义正落在这里 —— 「你这个 org node 要吃上游产出的 X、要产下游要吃的 Y,
 * 于是你规划的子图必须让入口节点消费 X、必须有节点产出 Y」。
 *
 * 本文件锁四件事:
 *   ① 读回方成立    org 边上的 produces → 规划提示词里出现 ③bis 段 + 上游实例的**绝对路径**
 *   ② 三种口径      查得到给绝对路径 / required 查不到给 ⚠️ / 可选查不到整行省略
 *   ③ 无契约不留空标题  一条契约都没有时整段(含标题)消失,不给 agent「查过了,没有」的错觉
 *   ④ 模板防漂移    orggraph-planning 模板里出现的每个「边上声明键」都必须被
 *                   org-graph-service.normalizeContracts 真读到;kind 取值域逐字对齐 ARTIFACT_KINDS
 *
 * buildPlanningPrompt / describeOrgContracts **故意不导出**(graph-engine.js 的口径:
 * 不为测试新开公开面),所以 ①②③ 全部走 advanceOne 这个真入口:
 * 用 setPlanWorkGraph 只替掉**上游** org 节点的规划(给它一张现成子图),
 * 被测节点则委托回真的 `defaultPlanWorkGraph`,靠替换 AgentManager 截获它发出的提示词。
 *
 * 隔离手法同 graph-files-index.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
 * 仓库 data/ 全程不被碰;Claude CLI 一次都不起(AgentManager 被换成假货)。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

const REPO = path.resolve(__dirname, '../../..');

describe('R-19 org 层数据契约(读回方 = buildPlanningPrompt)', () => {
  let wdb;
  let orgSvc;
  let engine;
  let runsDir;
  const TEST_DB_PATH = '/tmp/test-org-layer-contracts.sqlite';
  const AM_PATH = require.resolve('../../src/core/agent-manager');

  /** defaultPlanWorkGraph 发出的提示词,一次调用一条 */
  let captured = [];
  let savedAgentManager;

  /**
   * 假 AgentManager:截下提示词就抛。
   *
   * 抛而不是「回一张合法子图」是刻意的:本文件要验的是**提示词内容**,
   * 让规划"成功"还得替它造一张过五项校验的子图,那些代码与被测行为无关。
   * defaultPlanWorkGraph 对 callAgent 抛出的错不重试(只对「图画错了」重试),
   * 所以一次 advanceOne 恰好产出一条提示词,节点按 node_error 失败 —— 正是我们要的。
   */
  class FakeAgentManager {
    async callAgent(agentName, prompt) {
      captured.push({ agentName, prompt });
      throw new Error('fake-agent-manager: 已截获提示词,不真的调 CLI');
    }
  }

  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(), 'org-contracts-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');
    orgSvc = require('../../services/org-graph-service');
    engine = require('../../services/graph-engine');

    // defaultPlanWorkGraph 里是 `require('../src/core/agent-manager')`(调用时才 require),
    // 所以换掉 require.cache 里的 exports 就够,不必去 patch 原型 —— 也就不会构造真的
    // AgentManager(它的构造函数会挂上全局 LogManager / SessionPool)。afterAll 原样还回去。
    savedAgentManager = require.cache[AM_PATH];
    require.cache[AM_PATH] = {
      id: AM_PATH, filename: AM_PATH, loaded: true, children: [], paths: [],
      exports: FakeAgentManager,
    };
  });

  afterAll(() => {
    if (savedAgentManager) require.cache[AM_PATH] = savedAgentManager;
    else delete require.cache[AM_PATH];
    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)];
    }
  });

  // ── 脚手架 ───────────────────────────────────────────────────

  let seq = 0;
  /** 前缀刻意带 tmp-:resolvePlanningAgentName 会拿它去 REPO/data/companies 下找目录,
   *  找不到就试着 hire 一次;hire 第一步 get(companyId) 读不到 company.json 直接抛,
   *  被 catch 掉 —— 全程不在仓库 data/ 下建任何东西。 */
  const nextCompany = () => `tmp-orgc-${process.pid}-${++seq}`;

  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 run,把入口节点跑通(它的子图写出 `writes` 里的文件并被索引),
   * 让第二个 org 节点变 ready,然后再推一步 —— 那一步就会调真的 defaultPlanWorkGraph。
   *
   * @param edges org 边,可带 produces / needs
   * @param writes 入口节点的子图那个 work 节点要落盘的文件 {文件名: 内容}
   * @returns {Promise<{prompt: string, runId: string}>} 被测节点(org-coder)收到的规划提示词
   */
  async function planOrgRun({ companyId, taskId, nodes, edges, writes = {} }) {
    expect(orgSvc.writeGraph(companyId, { nodes, edges }).ok).toBe(true);

    ensureAgents(['plan-worker']);
    const sub = wdb.createWorkGraph({ companyId, taskId, nodes: ['plan-worker'], edges: [] });
    expect(sub.errors || []).toEqual([]);

    // 上游 org 节点用注入的假规划(一张现成子图);被测节点委托回真路径。
    engine.setPlanWorkGraph(async (ctx) => {
      if (ctx.nodeName === 'org-planner') return { workGraphId: sub.id };
      return engine.defaultPlanWorkGraph(ctx);
    });
    // 子图里那个 work 节点:把文件写进自己的私有目录,outputRef 指向该目录
    // —— 形状与 defaultAgentExecutor 一致,settleNode 的 indexNodeOutputs 才会登记它。
    engine.setAgentExecutor(async (ctx) => {
      const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId);
      fs.mkdirSync(dir, { recursive: true });
      for (const [name, body] of Object.entries(writes)) {
        fs.writeFileSync(path.join(dir, name), body);
      }
      return { status: 'success', outputRef: dir };
    });

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

      captured = [];
      await engine.advanceOne(run.id);                       // org-planner → 起子 run
      const child = wdb.listRunTree(run.id).find((r) => r.parent_run_id === run.id);
      await engine.advanceOne(child.id);                     // 子图那个节点跑完 → 回写父节点
      expect(wdb.getRun(child.id).run_status).toBe('completed');
      await engine.advanceOne(run.id);                       // org-coder → 真 defaultPlanWorkGraph
    } finally {
      engine.setPlanWorkGraph(null);
      engine.setAgentExecutor(null);
    }

    expect(captured).toHaveLength(1);
    return { prompt: captured[0].prompt, runId };
  }

  // ── ① 读回方成立(最关键的一条)───────────────────────────────

  it('① org 边上声明 produces → 被测 org node 的规划提示词里出现 ③bis 段与上游实例的绝对路径', async () => {
    const companyId = nextCompany();
    const taskId = 'task-org-in';
    const { prompt } = await planOrgRun({
      companyId, taskId,
      nodes: ['org-planner', 'org-coder', 'org-validator'],
      edges: [
        // 入边:上游 org node 会产出一份 brd 交给 org-coder
        { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] },
        // 出边:org-coder 自己要产出一份 sdd 给下游 org node;
        // 同一条边上的 needs 是**下游**要吃的(产出方是隔了一个节点的 org-planner),
        // 它不是 org-coder 的产出 —— 混进去就会命令 org-coder 再产一份 brd。
        { from: 'org-coder', to: 'org-validator', condition: 'success', produces: ['sdd'], needs: ['brd'] },
      ],
      writes: { 'brd.md': '# 业务需求' },
    });

    // 提示词确实发给了规划本体(不是发给别人)
    expect(captured[0].agentName).toMatch(/workgraph-planning$/);

    // 入边那一半:给的是**绝对路径**,且指向真实落盘的那份 brd.md
    expect(prompt).toContain('③bis');
    expect(prompt).toContain('### 上游节点交给你的输入');
    const abs = prompt.match(/^- brd:(.+)$/m);
    expect(abs, '提示词里没有 brd 那一行').toBeTruthy();
    expect(path.isAbsolute(abs[1])).toBe(true);
    // 真正的证据不是「长得像路径」,而是这个路径**读得出上游刚写的那份内容**。
    // (rel_path 是相对公司根算的,节点私有目录在 GRAPH_RUNS_DIR 覆盖下位于公司根之外,
    //  所以拼回来的绝对路径带 ../ 归一后并不以公司根开头 —— 不拿前缀做断言。)
    expect(fs.readFileSync(abs[1], 'utf-8')).toBe('# 业务需求');
    expect(prompt).not.toContain('⚠️ 上游声明会产出');

    // 出边那一半:硬约束语气 + 点名 kind
    expect(prompt).toContain('### 你这一层必须产出的东西(硬约束)');
    expect(prompt).toMatch(/必须有节点真的产出上面每一个 kind/);
    // 只列出边上 role='produces' 的那些。同一条出边上的 needs(brd)是下游要吃的东西,
    // 混进来就变成「命令 org-coder 再产一份 brd」——一条它根本没答应过的硬约束。
    const produced = prompt.match(/找输入的:(.+)$/m);
    expect(produced, '提示词里没有「必须产出」那一行').toBeTruthy();
    expect(produced[1]).toBe('sdd');
    // 出边声明的 kind 不该被误当成「上游给你的输入」列在入边那一半里
    expect(prompt).not.toMatch(/^- sdd:/m);
  });

  // ── ② 另两种口径:required 查不到给 ⚠️;可选查不到整行省略 ──────

  it('② required 声明查不到实例 → ⚠️;required:false 查不到 → 整行省略(不留半句话)', async () => {
    const companyId = nextCompany();
    const taskId = 'task-org-miss';
    const { prompt } = await planOrgRun({
      companyId, taskId,
      nodes: ['org-planner', 'org-coder'],
      edges: [{
        from: 'org-planner', to: 'org-coder', condition: 'success',
        // brd 会真的落盘;acceptance 是 required 但没人产;log 是可选且没人产
        produces: ['brd', 'acceptance', { kind: 'log', required: false }],
      }],
      writes: { 'brd.md': '# 业务需求' },
    });

    expect(prompt).toMatch(/^- brd:\//m);
    expect(prompt).toContain('- acceptance:⚠️ 上游声明会产出,但索引里查不到实例');
    // 可选且查不到 → 一行都不留(留一行「查不到」会让 agent 以为这是个待办)
    expect(prompt).not.toMatch(/^- log[::]/m);
  });

  // ── ③ 一条契约都没有时整段消失(含标题)──────────────────────

  it('③ org 图上一条契约都没声明 → ③bis 整段不出现(空标题会被读成「查过了,没有」)', async () => {
    const companyId = nextCompany();
    const { prompt } = await planOrgRun({
      companyId, taskId: 'task-org-none',
      nodes: ['org-planner', 'org-coder'],
      edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }],
      writes: { 'brd.md': '# 业务需求' },   // 实例在索引里,但图上没人声明要它
    });

    expect(prompt).not.toContain('③bis');
    expect(prompt).not.toContain('上游节点交给你的输入');
    expect(prompt).not.toContain('必须产出的东西');
    // 其余四项输入照旧在(证明省略的只是这一段,不是整段提示词拼挂了)
    expect(prompt).toContain('## ③ 可读的 artifacts/');
    expect(prompt).toContain('## ④ 上一轮 remediation_note');
  });

  // ── ⑤ 产出侧的事实:org 节点自己什么都不登记 ────────────────────

  it('⑤ org 层的 produces 在产出侧不靠 indexNodeOutputs:org 节点 output_ref 恒为 null,实例是子 run 里的 work 节点登记的', async () => {
    const companyId = nextCompany();
    const taskId = 'task-org-out';
    const { runId } = await planOrgRun({
      companyId, taskId,
      nodes: ['org-planner', 'org-coder'],
      edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] }],
      writes: { 'brd.md': '# 业务需求' },
    });

    // org 节点是 subgraph executor,它的 outcome 由 deriveChildOutcome 拼,**不带 outputRef**。
    // 所以 indexNodeOutputs 在 org run 上第一行就 return —— 3076 行那句 layer 三元里的
    // 'org' 分支对 org run 是走不到的死路径。这不是缺陷,是「org 节点自己不写盘」的必然结果。
    const orgNodes = wdb.getDb()
      .prepare(`SELECT * FROM graph_run_nodes WHERE run_id = ?`).all(runId);
    expect(orgNodes.length).toBeGreaterThan(0);
    expect(orgNodes.map((n) => n.output_ref)).toEqual(orgNodes.map(() => null));

    // 那份 brd 的索引行是**子 run 里那个 work 节点**登记的(node_run_id 属于子 run)。
    // 它之所以能被 org 层的入边声明查到,只因为 graph_files 是按 (company, task) 检索的,
    // 而子 run 从父 run 继承同一个 task_id —— 这就是 org 层 produces 在产出侧成立的全部理由。
    const rows = wdb.findFiles({ companyId, taskId, kinds: ['brd'] });
    expect(rows).toHaveLength(1);
    expect(orgNodes.map((n) => n.id)).not.toContain(rows[0].produced_by_node_run_id);
    const child = wdb.listRunTree(runId).find((r) => r.parent_run_id === runId);
    expect(rows[0].produced_by_run_id).toBe(child.id);
  });

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

  it('④ orggraph-planning 模板里的边上声明键,normalizeContracts 必须真的认(差一个字母就是静默失效)', () => {
    const dir = path.join(REPO, 'system/agents/orggraph-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"/);

    const keys = [...new Set(
      [...text.matchAll(/"(produces|needs|carries|consumes|inputs|outputs|provides)"/g)].map((m) => m[1])
    )];
    expect(keys.length).toBeGreaterThan(0);
    for (const key of keys) {
      const contracts = orgSvc.normalizeContracts({ from: 'x', to: 'y', [key]: ['brd'] });
      expect(contracts.map((c) => c.kind), `模板教了 "${key}",但归一函数不读这个键`).toEqual(['brd']);
    }

    // kind 取值域逐字对齐:模板漏教一个值,规划 agent 就永远不会用它;
    // 模板多教一个值,validateGraph 会以 rule=schema 把整张图打回。
    for (const kind of orgSvc.ARTIFACT_KINDS) {
      expect(text, `ARTIFACT_KINDS 里的 ${kind} 模板没教`).toContain(kind);
    }
    // backward 边上不许声明 —— 引擎解析输入只看 forward 入边(validateGraph 也拦)
    expect(text).toMatch(/backward/);
  });
});