runs-dir-late-env.test.js
6.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
/**
* 「**先 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));
});
});