work-graph-skeletons.test.js 8.62 KB
/**
 * system/work-graph-templates/*.json 的**数据契约**防线(R-19 / 27-P4 §4.6)。
 *
 * 挡的是这一类缺陷:骨架的 forward 边只带控制流(condition / edgeKind / failReason),
 * 不带 produces / needs。照抄骨架得到的图于是**不传数据** —— 下游节点的提示词里
 * 一条上游产物路径都没有,第四次真 CLI 冒烟里 org-planner-prd 的原话就是
 * 「不是上游没产出,是它的产出结构上到不了我手上」。
 * 补上声明前,那件事只能靠 workgraph-planning 的提示词施压来避免;换个模型就漏。
 *
 * 与 template-refs.test.js 的分工:那边管**引用**(systemAgentId 指不指得到真本体、
 * 落不落得进库),这边管**数据流**(边上有没有声明、声明落库后读不读得回来)。
 *
 * 四条用例分别对应四环里的一环:
 *   ① 骨架过引擎真正用的那套校验(含规则 6 data-contract:声明要吃的 kind 必须有产出方)
 *   ② 每张骨架的**每条** forward 边都有非空声明(新加一张骨架漏写声明也会被抓)
 *   ③ 声明落库:createWorkGraph → graph_edge_contracts 真有行,且 role/kind/required 与 JSON 一致
 *   ④ 读回:inboundContracts(describeInputs 的数据源)对每个非入口节点都非空
 */
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

const ROOT = path.resolve(__dirname, '../../..');
const WORK_GRAPH_DIR = path.join(ROOT, 'system/work-graph-templates');

/**
 * 占位符 → 真实存在的本体 id / 节点名。
 * 落库那两条用例必须替换:work_graph_nodes.system_agent_id 有外键指向 system_agents(id),
 * `{{implAgent}}` 会直接 FOREIGN KEY constraint failed —— 那样测的就不是契约而是外键了。
 * 取值只要求「system/templates/ 下真实存在」,语义上贴近即可。
 */
const PLACEHOLDERS = {
  '{{orgNode}}': 'org-coder',
  '{{splitAgent}}': 'devloop-architect',
  '{{implAgent}}': 'devloop-coder',
  '{{testAgent}}': 'devloop-tester',
  '{{mergeAgent}}': 'devloop-integrator',
  '{{integrationAgent}}': 'devloop-tester',
  '{{validatorAgent}}': 'coder-validator',
};

function readSkeletons() {
  return fs
    .readdirSync(WORK_GRAPH_DIR)
    .filter((f) => f.endsWith('.json'))
    .map((f) => ({
      file: f,
      json: JSON.parse(fs.readFileSync(path.join(WORK_GRAPH_DIR, f), 'utf-8')),
    }));
}

/** 把骨架里的 {{占位符}} 全部替换成真实 id;替换后仍残留 {{ 就让它炸,不要静默落一张废图 */
function materialize(skeleton) {
  let text = JSON.stringify(skeleton.json);
  for (const [ph, real] of Object.entries(PLACEHOLDERS)) text = text.split(ph).join(real);
  if (text.includes('{{')) throw new Error(`${skeleton.file} 有未覆盖的占位符,请补 PLACEHOLDERS`);
  return JSON.parse(text);
}

const isForward = (e) => (e.edgeKind || e.edge_kind || 'forward') !== 'backward';

let wdb;
let orgGraphService;
let normalizeContracts;

beforeAll(async () => {
  // 隔离手法与 template-refs.test.js / graph-engine.test.js 一致:先设 env 再 import。
  // 仓库的 data/graph.sqlite 一行都不该被本文件碰到。
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'work-graph-skeletons-'));
  process.env.GRAPH_DB_PATH = path.join(tmp, 'graph.sqlite');
  process.env.GRAPH_RUNS_DIR = path.join(tmp, 'runs');

  const graphDb = await import('../../services/graph-db.js');
  const seed = await import('../../services/graph-seed-service.js');
  wdb = (await import('../../services/work-graph-db.js')).default;
  orgGraphService = (await import('../../services/org-graph-service.js')).default;
  normalizeContracts = orgGraphService.normalizeContracts;

  graphDb.default.getDb();
  seed.default.seedAgents(); // system/agents/* → system_agents(外键的另一端)
});

describe('work graph 骨架的数据契约', () => {
  const skeletons = readSkeletons();

  it('本目录下有骨架可查(防止目录改名让整个文件静默空跑)', () => {
    expect(skeletons.map((s) => s.file).sort()).toEqual([
      'executor-fanout.json',
      'executor.json',
      'planner.json',
      'validator.json',
    ]);
  });

  it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方)', () => {
    const verdicts = skeletons.map((s) => {
      const r = wdb.validateWorkGraph(s.json.nodes, s.json.edges);
      return `${s.file}: ${r.ok ? 'ok' : JSON.stringify(r.errors)}`;
    });
    expect(verdicts.filter((v) => !v.endsWith(': ok'))).toEqual([]);
  });

  it('每张骨架的每条 forward 边都有非空的 produces / needs 声明', () => {
    // 「至少一张有」不够:新增骨架时漏写声明必须当场红。
    // backward 边反过来一律不许声明(validateGraph 会报 rule=schema),上一条用例已覆盖。
    const naked = [];
    for (const s of skeletons) {
      for (const e of s.json.edges || []) {
        if (!isForward(e)) continue;
        if (normalizeContracts(e).length === 0) naked.push(`${s.file}: ${e.from} -> ${e.to}`);
      }
    }
    expect(naked).toEqual([]);
  });

  it('声明真的落库:createWorkGraph 之后 graph_edge_contracts 里 kind/role/required 与 JSON 逐条一致', () => {
    // 这一步把「JSON 里写了」和「落库后读得到」分开:前者只是文本,后者才是四环闭合的第三环。
    const mismatches = [];
    let contractRows = 0;

    for (const s of skeletons) {
      const json = materialize(s);
      const created = wdb.createWorkGraph({
        companyId: 'work-graph-skeletons-test',
        taskId: `T-${s.json.id}`,
        nodes: json.nodes,
        edges: json.edges,
      });
      expect(`${s.file}: ${created.ok ? 'ok' : JSON.stringify(created.errors)}`).toBe(`${s.file}: ok`);

      const def = wdb.readDefGraph('work', created.id);
      const nameOf = new Map(def.nodes.map((n) => [n.defNodeId, n.name]));
      // 按端点名配对,**不能**按声明序:readDefGraph 是 ORDER BY created_at, id,
      // 同一次事务里 created_at 全等,实际排序落到随机 id 上,落库序 ≠ 声明序。
      const dbByEndpoints = new Map(
        def.edges
          .filter((e) => e.edgeKind !== 'backward')
          .map((e) => [`${nameOf.get(e.from)} -> ${nameOf.get(e.to)}`, e])
      );
      const jsonEdges = json.edges.filter(isForward);
      expect(dbByEndpoints.size).toBe(jsonEdges.length);

      for (const je of jsonEdges) {
        const label = `${s.file} ${je.from} -> ${je.to}`;
        const dbEdge = dbByEndpoints.get(`${je.from} -> ${je.to}`);
        if (!dbEdge) {
          mismatches.push(`${label}: 落库后找不到这条边`);
          continue;
        }
        const want = normalizeContracts(je)
          .map((c) => `${c.role}:${c.kind}:${c.required}`)
          .sort();
        const got = wdb
          .edgeContracts('work', dbEdge.id)
          .map((r) => `${r.role}:${r.kind}:${r.required === 1}`)
          .sort();
        contractRows += got.length;
        if (JSON.stringify(want) !== JSON.stringify(got)) {
          mismatches.push(`${label}: JSON=${JSON.stringify(want)} DB=${JSON.stringify(got)}`);
        }
      }
    }

    expect(mismatches).toEqual([]);
    expect(contractRows).toBeGreaterThan(0); // 全都对不上却"两边都空"时不许静默通过
  });

  it('读回:每个非入口节点的 inboundContracts 都非空(describeInputs 拼路径的数据源)', () => {
    // 引擎给下游拼「上游输入」段落走的就是这个函数(graph-engine.describeInputs)。
    // 它对某个节点返回空 = 那个节点的提示词里一条上游路径都没有。
    const starved = [];

    for (const s of skeletons) {
      const json = materialize(s);
      const created = wdb.createWorkGraph({
        companyId: 'work-graph-skeletons-test',
        taskId: `T-inbound-${s.json.id}`,
        nodes: json.nodes,
        edges: json.edges,
      });
      expect(created.ok).toBe(true);

      const def = wdb.readDefGraph('work', created.id);
      const hasForwardIn = new Set(
        def.edges.filter((e) => e.edgeKind !== 'backward').map((e) => String(e.to))
      );
      for (const n of def.nodes) {
        if (!hasForwardIn.has(String(n.defNodeId))) continue; // 入口节点没有入边,输入来自提示词
        if (wdb.inboundContracts('work', def.edges, n.defNodeId).length === 0) {
          starved.push(`${s.file}: ${n.name}`);
        }
      }
      // 每张图至少有一个非入口节点,否则这条用例在"没节点可查"的情况下静默通过
      expect(hasForwardIn.size).toBeGreaterThan(0);
    }

    expect(starved).toEqual([]);
  });
});