/** * 「**先 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)); }); });