workgraph-planning-endpoints.test.js 15 KB
/**
 * Unit Tests — workgraph-planning 模板的防漂移闸:**模板教的端点必须真的存在**
 *
 * 挡的是已经实际发生过的这一类缺陷:模板(`system/templates/workgraph-planning/*.md`)
 * 教 agent `POST /api/company/<公司ID>/work-graphs` 去落库,而这个路由**从来没有实现过**
 * (`server/index.js` 只挂了 `/api/graph-runs` 与 `/api/company` 的 org-graph 系列,
 * 设计文档里也查不到这个端点)。后果不是功能坏掉 —— `graph-engine.js` 的
 * `extractPlannedGraph` 那条「读回复取图」的分支本来就是主路径,图照样落得进去 ——
 * 而是**每次真跑都白烧一轮 agent 调用**去 curl 一个必然 404 的地址(第五次真 CLI 冒烟实测)。
 * 这种缺陷在单测里天然无感:prompt 文本没有任何编译期检查,端点没了也不会有人红。
 *
 * 所以这里把「模板文本」和「真实路由表」对起来查:
 *   ① 模板里出现的每个 `/api/...` 路径,都必须能在 `server/routes/` 里找到注册;
 *   ② 反向:被显式标注为反例(`NO-SUCH-ENDPOINT`)的路径,必须**确实不存在** ——
 *      哪天这个端点真上线了,这条会红,提醒把警示文本删掉,而不是留一句过时的谣言;
 *   ③ 模板教 agent 交回的那份图示例,本身要是**严格 JSON** 且过 `validateWorkGraph`:
 *      引擎用 `JSON.parse` 取图,示例里带注释/尾逗号就等于教它白烧一轮。
 *
 * ── 路由表怎么来 ────────────────────────────────────────────────
 * **静态解析** `server/index.js` 的 `app.use('<前缀>', require('./routes/<文件>')[.<导出>])`
 * + 路由文件里的 `<router>.get/post/...('<路径>')`,不 require 任何路由模块:
 * 那些模块一 require 就会拉起 service 层(sqlite 连接等),而这条 guard 只需要文本事实。
 *
 * ponytail: 路径匹配按「段」严格比:占位符段(`:id` / `<公司ID>` / `{公司ID}`)两侧都归一成
 * `*`,且**只与占位符相等** —— 字面量位置上出现占位符也算不匹配(松一点就会出现
 * `/api/company/{公司ID}/work-graphs` 被 `PATCH /api/company/templates/:templateId` 匹配上,
 * 实测过)。上限:模板把一族端点写成 `{start,next,...}` 时靠展开逗号处理,
 * 而**动词(GET/POST)不参与比对** —— 端点存在但方法写错,这条查不出来。
 * 要更紧就得真起 express app 逐条 match,代价是把 service 层全拉起来,不值当。
 */
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';

const ROOT = path.resolve(__dirname, '../../..');
const TEMPLATE_DIR = path.join(ROOT, 'system/templates/workgraph-planning');
const ROUTES_DIR = path.join(ROOT, 'server/routes');
const INDEX_JS = path.join(ROOT, 'server/index.js');

/**
 * 反例标记:同一行里出现它,这一行的端点就**不要求**注册(反而要求它不存在,见用例 ②)。
 * 选「同一行」而不是「同一段落」是为了让判据肉眼可查 —— 加一句无关的警示文字不会顺手
 * 豁免掉隔壁那条真端点。
 */
const NO_SUCH = 'NO-SUCH-ENDPOINT';

/**
 * `{start,next,...}` 这种「一族端点写成一行」的写法展开成逐条候选(`...` 省略号丢掉)。
 * 只展开**带逗号**的花括号 —— `{公司ID}` 是占位符,不是选择集。
 */
function expandAlternatives(p) {
  const m = /\{([^{}]*,[^{}]*)\}/.exec(p);
  if (!m) return [p];
  return m[1]
    .split(',').map((s) => s.trim()).filter((s) => s && s !== '...')
    .flatMap((alt) => expandAlternatives(p.slice(0, m.index) + alt + p.slice(m.index + m[0].length)));
}

/** 段级归一:占位符(`:x` / `<x>` / `{x}`)一律 `*`,字面量原样保留 */
function segments(p) {
  return String(p)
    .split('/')
    .filter((s) => s !== '')
    .map((s) => (/[:<>{}*]/.test(s) ? '*' : s));
}

/**
 * 严格逐段相等(占位符只与占位符相等)。
 *
 * 早先写成「`*` 匹配任意一段」,结果 `/api/company/{公司ID}/work-graphs` 被
 * `PATCH /api/company/templates/:templateId` 匹配上了(占位符吃掉 `templates`、
 * `work-graphs` 又被 `:templateId` 吃掉)—— 那样这条 guard 对本次要挡的缺陷直接失效。
 * 所以「字面量位置上出现占位符」也算不匹配。
 */
function pathMatches(a, b) {
  const x = segments(a);
  const y = segments(b);
  return x.length === y.length && x.every((s, i) => s === y[i]);
}

/** 一个模板路径「已注册」= 它展开出的每一条候选都能找到注册 */
function isRegistered(p, registered) {
  return expandAlternatives(p).every((cand) => registered.some((r) => pathMatches(cand, r.full)));
}

/** server/index.js 的挂载表:`app.use('/api/x', require('./routes/y').z)` */
function readMounts() {
  const src = fs.readFileSync(INDEX_JS, 'utf-8');
  const re = /app\.use\(\s*'([^']+)'\s*,\s*require\('\.\/routes\/([\w-]+)'\)(?:\.(\w+))?\s*\)/g;
  const out = [];
  let m;
  while ((m = re.exec(src))) out.push({ prefix: m[1], file: `${m[2]}.js`, exportName: m[3] || null });
  return out;
}

/** 这个挂载点对应的 Router 变量名(`module.exports = router` / `module.exports.companyRouter = x`) */
function routerIdentifier(src, exportName) {
  const re = exportName
    ? new RegExp(`module\\.exports\\.${exportName}\\s*=\\s*(\\w+)`)
    : /module\.exports\s*=\s*(\w+)\s*;/;
  const m = re.exec(src);
  return m ? m[1] : null;
}

/** 真实注册的完整路径集合(前缀 + 声明路径),只收字面量路径 */
function registeredPaths() {
  const out = [];
  for (const mount of readMounts()) {
    const file = path.join(ROUTES_DIR, mount.file);
    if (!fs.existsSync(file)) continue;
    const src = fs.readFileSync(file, 'utf-8');
    const ident = routerIdentifier(src, mount.exportName);
    if (!ident) continue;
    const re = new RegExp(`\\b${ident}\\.(?:get|post|put|patch|delete)\\(\\s*'([^']*)'`, 'g');
    let m;
    while ((m = re.exec(src))) {
      out.push({ full: `${mount.prefix}/${m[1]}`.replace(/\/+/g, '/'), file: mount.file });
    }
  }
  return out;
}

/**
 * 模板里提到的每个 `/api/...` 路径(带出处行号,便于定位)。
 *
 * 占位符里有中文(`<公司ID>`),所以 ASCII 字符类**不够** —— 第一版写成
 * `[A-Za-z0-9_\-./:<>{},]*` 时,`/api/company/<公司ID>/agents` 被截成 `/api/company/<`,
 * 段数从 4 塌成 3,正好被 `GET /api/company/:companyId` 匹配上:整条 guard 对模板里
 * **每一条**真实路径都变成了空跑(变异测试实测撞到)。CJK 只在 `<>` / `{}` 括号里放行,
 * 括号外仍限 ASCII —— 否则紧跟路径的中文正文会被吞进路径里,变成误报。
 */
const ENDPOINT_RE = /\/api\/(?:<[^<>\s]*>|\{[^{}\s]*\}|[A-Za-z0-9_\-.:]|\/)+/g;

function templateEndpoints() {
  const out = [];
  for (const name of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) {
    const lines = fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf-8').split('\n');
    lines.forEach((line, i) => {
      for (const raw of line.match(ENDPOINT_RE) || []) {
        const cleaned = raw.replace(/[.,:;/]+$/, '');
        out.push({ path: cleaned, file: name, line: i + 1, exempt: line.includes(NO_SUCH) });
      }
    });
  }
  return out;
}

/** ```json 围栏块里能解析出 `nodes` 数组的那些(引擎 extractPlannedGraph 同一取法) */
function graphExamples(fileName) {
  const text = fs.readFileSync(path.join(TEMPLATE_DIR, fileName), 'utf-8');
  const out = [];
  const fence = /```json\s*([\s\S]*?)```/g;
  let m;
  while ((m = fence.exec(text))) {
    try {
      const doc = JSON.parse(m[1]);
      if (doc && Array.isArray(doc.nodes)) out.push(doc);
    } catch { /* 不是图的 json 块(或写坏了),交给下面的断言去管 */ }
  }
  return out;
}

describe('workgraph-planning 模板:教的端点必须真的存在', () => {
  const registered = registeredPaths();
  const endpoints = templateEndpoints();

  it('两侧都不是空的(防止 guard 因为改名/正则失效而静默变成空跑)', () => {
    expect(endpoints.length).toBeGreaterThan(0);
    expect(registered.length).toBeGreaterThan(20);
    // 抽取必须**整条**抽出来(含中文占位符):截断成 `/api/company/<` 会被
    // `GET /api/company/:companyId` 匹配上,guard 就整体空跑了
    for (const tail of ['/hire', '/agents']) {
      const hit = endpoints.find((e) => e.path.endsWith(tail));
      expect(hit, `模板里抽不到以 ${tail} 结尾的端点,抽取正则多半又被截断了`).toBeTruthy();
      expect(segments(hit.path).length).toBe(4);
    }
    // 已知一定注册着的几条,用来验「解析链路本身没坏」(含挂在 companyRouter 上的那条)
    for (const known of ['/api/company/<id>/hire', '/api/company/<id>/agents',
      '/api/graph-runs/<id>/{start,next}', '/api/company/<id>/graph-runs']) {
      expect(isRegistered(known, registered), `解析不到 ${known}`).toBe(true);
    }
    // 反面自检:一个明摆着不存在的路径不许被判成已注册(挡住"匹配太松 → guard 空跑")
    expect(isRegistered('/api/company/<id>/definitely-not-a-route', registered)).toBe(false);
  });

  it('① 模板里的每个端点都在 server/routes/ 里真实注册', () => {
    const dangling = endpoints
      .filter((e) => !e.exempt)
      .filter((e) => !isRegistered(e.path, registered))
      .map((e) => `${e.file}:${e.line} → ${e.path} 没有任何路由注册它`
        + `(真要写成反例,同一行加上 ${NO_SUCH} 标记并写明"不存在,不要调用")`);
    expect(dangling).toEqual([]);
  });

  it(`② 标了 ${NO_SUCH} 的端点必须确实不存在(端点真上线了就该删掉这句警示)`, () => {
    const exempt = endpoints.filter((e) => e.exempt);
    // 反例本身也要在(否则这条用例是空跑):删掉那条警示行时这里会红,提醒别把它悄悄弄没了
    expect(exempt.length).toBeGreaterThan(0);
    const stale = exempt
      .filter((e) => isRegistered(e.path, registered))
      .map((e) => `${e.file}:${e.line} → ${e.path} 已经真的注册了,${NO_SUCH} 这句话过时了`);
    expect(stale).toEqual([]);
  });

  it('③ 模板教 agent 交回的图示例:严格 JSON + 过 validateWorkGraph(引擎用 JSON.parse 取它)', () => {
    // 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 一行不碰。
    // validateWorkGraph 是纯函数,但 require 链上有 getDb,保险起见照铁律走。
    process.env.GRAPH_DB_PATH = '/tmp/test-workgraph-planning-endpoints.sqlite';
    for (const m of ['../../services/graph-db', '../../services/work-graph-db']) {
      delete require.cache[require.resolve(m)];
    }
    const wdb = require('../../services/work-graph-db');

    // agent.md 是**唯一进提示词的那份**(agent-manager.getAgentConfig 只把 agent.md 当 prompt),
    // 所以它里面必须有一份可直接照抄的图示例。
    const examples = graphExamples('agent.md');
    expect(examples.length).toBeGreaterThan(0);

    const bad = [];
    for (const dir of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) {
      for (const g of graphExamples(dir)) {
        const verdict = wdb.validateWorkGraph(g.nodes, g.edges || []);
        if (!verdict.ok) bad.push(`${dir}: ${JSON.stringify(verdict.errors)}`);
      }
    }
    expect(bad).toEqual([]);
  });
});

/**
 * ④ 引擎自己拼的规划提示词,不许与模板对立。
 *
 * 第六次真 CLI 冒烟挖出来的:同一条提示词里,模板部分(系统提示词)写着
 * 「不许出现 wg_ + 12 位十六进制,你永远拿不到真的 wg_ id」,而引擎拼的任务描述部分
 * 写着「已经自行落库成功时,改为回一行 workGraphId: wg_xxx」——**两边直接对立**,
 * agent 得赌听谁的。那一轮它选择听模板,是运气不是保证。
 *
 * 同型的还有两条:引擎的内联图示例一个 produces 都没有(agent 唯一见过的「边长什么样」
 * 就是零声明的),以及引擎说端点「尚未上线」(暗示将来会上线,于是它还想去试一次)。
 *
 * 手法与上面一致:**静态读源码文本**,不 require graph-engine(一 require 就拉起 sqlite
 * 与 AgentManager 依赖链)。buildPlanningPrompt 不导出,也不为测试新开导出面。
 * ponytail: 按「## 交回方式」到数组收尾 `];` 之间的文本切段。上限是节标题一改就切不到 ——
 * 所以下面第一条断言先钉住「这段必须切得到且非空」,切不到时是它红而不是静默空跑。
 */
describe('引擎的规划提示词:与 workgraph-planning 模板口径一致', () => {
  const ENGINE_SRC = path.join(ROOT, 'server/services/graph-engine.js');
  const src = fs.readFileSync(ENGINE_SRC, 'utf-8');

  /**
   * buildPlanningPrompt 里「## 交回方式」那一节里**真正会进提示词的文本**。
   *
   * 必须剔掉 `//` 注释行:这一节的注释本身就在解释「为什么不许说某句话」,
   * 连注释一起扫的话,写下禁令理由的那条注释会让 guard 自己红
   * (第一版就是这样红的 —— 它量的是源码文本,而该量的是发给 agent 的文本)。
   */
  function handoffSection() {
    const start = src.indexOf('## 交回方式');
    if (start < 0) return '';
    const end = src.indexOf('\n  ];', start);
    if (end < 0) return '';
    return src.slice(start, end)
      .split('\n')
      .filter((l) => !l.trim().startsWith('//'))
      .join('\n');
  }

  it('这一节切得到(切不到时这条红,而不是让下面三条静默空跑)', () => {
    const sec = handoffSection();
    expect(sec.length).toBeGreaterThan(100);
    // 它必须确实是在讲交回图这件事,否则说明切到了别的地方
    expect(sec).toContain('json');
  });

  it('不邀请 agent 回 workGraphId(模板明令禁止写 wg_ id,两边不许对立)', () => {
    const sec = handoffSection();
    // 找「教它输出 wg_」的措辞。引擎侧仍可以保留**解析**真实 wg_ id 的能力,
    // 禁的只是在提示词里邀请它写一个 —— 它没有任何受支持的落库途径,写出来必是编的。
    expect(sec).not.toMatch(/workGraphId\s*[::]\s*wg_/);
    expect(sec).not.toContain('自行落库成功');
  });

  it('内联图示例的边带 produces(示例是 agent 唯一见过的「边长什么样」)', () => {
    const sec = handoffSection();
    const edges = sec.match(/"edges"\s*:\s*\[[^\]]*\]/);
    expect(edges).not.toBeNull();
    expect(edges[0]).toContain('produces');
  });

  it('提到 work-graphs 端点时不说「尚未上线」(暗示将来会上线,agent 就还会去试)', () => {
    const sec = handoffSection();
    if (!sec.includes('work-graphs')) return; // 不提它也行,那就没这个问题
    expect(sec).not.toContain('尚未上线');
    expect(sec).not.toContain('还没上线');
  });
});