runs-dir-late-env.test.js 6.26 KB
/**
 * 「**先 require 再设 `GRAPH_RUNS_DIR`**」也必须算数(27-P3C 缺陷 F8)。
 *
 * 既有的 `goal-task-acceptance.test.js` / `company-goal-task-routes.test.js` 都在文件头
 * 郑重写着「必须先设 env 再 require」,于是它们只覆盖了**先设 env** 的那一半 ——
 * 而缺陷 F8 说的正是另一半:`graph-engine.js` 与 `graph-run-sse.js` 曾在**模块加载时**
 * 快照 `process.env.GRAPH_RUNS_DIR`。快照的形态把加载顺序变成了隐式契约,可这两个模块被
 * `routes/company.js`、运维脚本、REPL 各自 require,谁先谁后没有任何保证。顺序一错:
 *   · 写侧落 `$GRAPH_RUNS_DIR/{rootRunId}/`、读侧去仓库 `data/companies/…` 找 →
 *     `describeTask` 拿到 null 后**静默退回兜底文案「执行图节点任务」**,agent 不知道要
 *     干什么却照样烧预算,全程零报错;
 *   · 反过来则是往用户仓库 `data/companies/` 里堆 run 目录与空项目根。
 * 两种都不报错,所以只能靠用例把「加载顺序不该影响结果」这条不变式钉住。
 *
 * 与 `runs-root-single-holder.test.js` 的分工:那份是源码级元测试(绑了这个 env 的标识符
 * 不许零使用),管不到「什么时候读」;这份是行为级的,专门把读取时机钉在调用时。
 *
 * 手法:**beforeAll 里先 require、之后才 mkdtemp + 设 env**。用例断言写入落在事后设的目录里,
 * 并反向断言仓库 `data/companies/` 下一个字节都不该多 —— 只验前者的话,一份把两个根都写一遍
 * 的实现也是绿的。
 */

const fs = require('fs');
const os = require('os');
const path = require('path');

const REPO_ROOT = path.resolve(__dirname, '../../..');
const TEST_DB_PATH = '/tmp/test-runs-dir-late-env.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
  '../../services/graph-db',
  '../../services/work-graph-db',
  '../../services/graph-run-sse',
  '../../services/graph-engine',
  '../../services/goal-contract',
];

// 假 run 用的坐标:company_id 只在**没有**覆盖时才会出现在路径里,正好拿它做反向断言
const RUN_ID = 'run_late_env_probe';
const COMPANY_ID = 'late-env-co';

let sse;
let engine;
let goalContract;
let runsDir;
let prevRunsDir;

describe('GRAPH_RUNS_DIR 在 require 之后才设也必须生效(27-P3C 缺陷 F8)', () => {
  beforeAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
    // 库路径与本用例无关(它仍是加载时快照),先设上免得 getDb() 打到仓库 data/ 里
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    prevRunsDir = process.env.GRAPH_RUNS_DIR;
    delete process.env.GRAPH_RUNS_DIR;
    for (const m of MODULES) delete require.cache[require.resolve(m)];

    // ① 先 require —— 此刻 GRAPH_RUNS_DIR 还不存在。模块加载时快照的实现会在这里
    //    把 null 记死,后面设什么都没用。
    sse = require('../../services/graph-run-sse');
    engine = require('../../services/graph-engine');
    goalContract = require('../../services/goal-contract');

    // ② 之后才设
    runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p3c-late-env-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
  });

  afterAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
    try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch { /* ignore */ }
    // 断言失败(= 缺陷复现)时残留会落在仓库里,顺手收掉,别让一次红测污染用户仓库。
    // 只删本用例造出来的两个坐标,且不碰真公司目录(真公司一定有 company.json)。
    for (const cid of [COMPANY_ID, '_unknown']) {
      const dir = path.join(REPO_ROOT, 'data', 'companies', cid);
      if (!fs.existsSync(dir) || fs.existsSync(path.join(dir, 'company.json'))) continue;
      try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ }
    }
    delete process.env.GRAPH_DB_PATH;
    if (prevRunsDir === undefined) delete process.env.GRAPH_RUNS_DIR;
    else process.env.GRAPH_RUNS_DIR = prevRunsDir;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  it('graph-run-sse:stream.log / timeline.ndjson 落在事后设的目录里', () => {
    // 库里没有这条 run 行 → resolveMeta 退化成 { rootRunId: 自己, companyId: '_unknown' },
    // 于是「没读到 env」的实现会写到仓库 data/companies/_unknown/runs/ 下,肉眼可分。
    expect(sse.runDir(RUN_ID)).toBe(path.join(runsDir, RUN_ID));

    sse.emitEvent(RUN_ID, 'test.late-env', '用例事件:验证 run 目录根');
    expect(fs.existsSync(path.join(runsDir, RUN_ID, 'stream.log'))).toBe(true);
    expect(fs.existsSync(path.join(runsDir, RUN_ID, 'timeline.ndjson'))).toBe(true);
    expect(fs.existsSync(path.join(REPO_ROOT, 'data', 'companies', '_unknown', 'runs', RUN_ID))).toBe(false);
  });

  it('graph-engine:项目根同样跟着走,不落回仓库 data/companies/', () => {
    // ensureProjectRoot 是 allocateRunProjectRoot(分配)与 runRootDir(落记录)两个使用点的
    // 唯一导出入口;run 只需要三列,不必真起一条 run。
    const run = { id: RUN_ID, root_run_id: RUN_ID, company_id: COMPANY_ID };
    const rec = engine.ensureProjectRoot({ run, runId: RUN_ID });

    expect(rec).toBeTruthy();
    expect(rec.mode).toBe('runs-dir-override'); // 快照实现在这里会退回 'sandbox'
    expect(rec.root_path).toBe(path.join(runsDir, RUN_ID, 'project'));
    expect(fs.existsSync(rec.root_path)).toBe(true);
    // 记录文件(project-root.json)走 runRootDir,是第二个使用点,得单独看一眼
    expect(fs.existsSync(path.join(runsDir, RUN_ID, 'artifacts', 'project-root.json'))).toBe(true);
    expect(fs.existsSync(path.join(REPO_ROOT, 'data', 'companies', COMPANY_ID))).toBe(false);
  });

  it('goal-contract.resolveRunDir 与上面两处指向同一个目录', () => {
    // 读侧的第三个持有者。三处任意一处退化成加载时快照,写侧读侧就分家 ——
    // 而分家的症状是 describeTask 静默兜底,不是报错。
    expect(goalContract.resolveRunDir(COMPANY_ID, RUN_ID)).toBe(sse.runDir(RUN_ID));
  });
});