phase2-verify.js
7.33 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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#!/usr/bin/env node
/**
* scripts/phase2-verify.js
*
* P2 L1 单 Unit 验收 (mock 模式):
* 1. 启动 devloop router
* 2. POST /api/devloop/projects 创建 todo-demo
* 3. POST /api/devloop/projects/:pid/run?only=todo-be 跑 L1 loop
* 4. 校验 state 链路: pending → coding → testing → failed → coding → testing → passed
* 5. 校验 coder.log 第二条决策含 last_failure (反思生效)
* 6. 校验 runs/ 下至少 2 个 result.json,首失败末通过
* 7. 校验沙箱越权被 _sandbox.assertWritable 拒绝
*
* 通过 → exit 0;失败 → exit 1
*/
process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
const fs = require('fs');
const path = require('path');
const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
require('module').globalPaths.push(serverNodeModules);
const express = (() => {
try { return require('express'); }
catch {
return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
}
})();
const pathResolver = require('../server/services/devloop/path-resolver');
const unitRegistry = require('../server/services/devloop/unit-registry');
const devloopRouter = require('../server/routes/devloop');
const { assertWritable } = require('../server/src/tools/devloop/_sandbox');
const { ToolSandboxError } = require('../server/src/tools/devloop/_errors');
const results = [];
function step(name, fn) {
return (async () => {
try {
const r = await fn();
results.push({ name, ok: true, detail: r?.detail || '' });
return r;
} catch (e) {
results.push({ name, ok: false, reason: e.message });
throw e;
}
})();
}
async function startApp() {
const app = express();
app.use(express.json());
app.use('/api/devloop', devloopRouter);
return new Promise((resolve) => {
const srv = app.listen(0, '127.0.0.1', () => {
resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` });
});
});
}
async function main() {
const { srv, base } = await startApp();
const cid = 'c-phase2-' + Date.now().toString(36);
let pid;
const fsmTrail = [];
try {
// 准备:跑 PM + Architect 把 units.json 写好
const r0 = await step('POST /projects (PM + Architect)', async () => {
const resp = await fetch(`${base}/api/devloop/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
company_id: cid,
name: 'todo-demo',
requirement: '做一个 TODO 列表 API + Vue 前端',
}),
});
if (resp.status !== 200) throw new Error('status ' + resp.status);
const j = await resp.json();
pid = j.pid;
return { detail: `pid=${pid}` };
});
// 监听 unit-registry SSE 收 FSM 转换
unitRegistry.attachSseBroker({
emit: (_pid, kind, ev) => {
if (kind === 'unit.state' && ev.unit_id === 'todo-be') {
fsmTrail.push(`${ev.from}→${ev.to}`);
}
},
});
await step('POST /run?only=todo-be (L1 loop)', async () => {
const resp = await fetch(`${base}/api/devloop/projects/${pid}/run?only=todo-be`, { method: 'POST' });
if (resp.status !== 200) throw new Error('status ' + resp.status);
const j = await resp.json();
if (!j.ok) throw new Error('runUnit ok=false');
if (j.attempts !== 2) throw new Error('want 2 attempts, got ' + j.attempts);
if (j.final_state !== 'passed') throw new Error('final_state=' + j.final_state);
return { detail: `attempts=2 · final=passed · history=${j.history.length} steps` };
});
await step('FSM trail: pending → coding → testing → failed → coding → testing → passed', () => {
const expected = [
'pending→coding',
'coding→testing',
'testing→failed',
'failed→coding',
'coding→testing',
'testing→passed',
];
if (JSON.stringify(fsmTrail) !== JSON.stringify(expected)) {
throw new Error('got: ' + fsmTrail.join(', '));
}
return { detail: fsmTrail.join(' · ') };
});
await step('coder.log second entry references previous failure', () => {
const logPath = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'coder.log');
const log = fs.readFileSync(logPath, 'utf8');
if (!/attempt: 1/.test(log)) throw new Error('missing attempt: 1');
if (!/attempt: 2/.test(log)) throw new Error('missing attempt: 2');
const att2 = log.split('attempt: 2')[1] || '';
for (const field of ['last_failure', 'last_cause', 'this_fix', 'rationale']) {
if (!att2.includes(field)) throw new Error('attempt:2 missing ' + field);
}
return { detail: 'reflection template (last_failure/last_cause/this_fix/rationale) present' };
});
await step('runs/ has ≥2 result.json with first.ok=false, last.ok=true', () => {
const runsDir = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'runs');
const ids = fs.readdirSync(runsDir).sort();
if (ids.length < 2) throw new Error('want ≥2 runs, got ' + ids.length);
const first = JSON.parse(fs.readFileSync(path.join(runsDir, ids[0], 'result.json'), 'utf8'));
const last = JSON.parse(fs.readFileSync(path.join(runsDir, ids[ids.length - 1], 'result.json'), 'utf8'));
if (first.ok !== false) throw new Error('first run.ok should be false');
if (last.ok !== true) throw new Error('last run.ok should be true');
return { detail: `${ids.length} runs · first.ok=false · last.ok=true` };
});
await step('sandbox: writing other unit / prd is rejected', () => {
const root = pathResolver.resolveProjectRoot(pid);
const allowList = ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'];
const ctx = { agent: 'devloop-coder', projectDir: root, unit_id: 'todo-be' };
// allowed
assertWritable(path.join(root, 'units/todo-be/code/server.js'), allowList, ctx);
// denied (cross-unit)
let blockedCross = false;
try { assertWritable(path.join(root, 'units/todo-fe/code/foo.js'), allowList, ctx); }
catch (e) { blockedCross = e instanceof ToolSandboxError; }
if (!blockedCross) throw new Error('cross-unit write was NOT blocked');
// denied (prd)
let blockedPrd = false;
try { assertWritable(path.join(root, 'prd/index.html'), allowList, ctx); }
catch (e) { blockedPrd = e instanceof ToolSandboxError; }
if (!blockedPrd) throw new Error('prd write was NOT blocked');
return { detail: 'cross-unit + prd writes blocked by ToolSandboxError' };
});
} finally {
srv.close();
try {
fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true });
} catch { /* ignore */ }
}
let pass = 0, fail = 0;
console.log('\n┌─ DevLoop Phase 2 — L1 Single Unit Acceptance ────');
for (const r of results) {
const tag = r.ok ? '✓' : '✗';
console.log(`│ ${tag} ${r.name.padEnd(60)} ${r.ok ? r.detail : '— ' + r.reason}`);
r.ok ? pass++ : fail++;
}
console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);
process.exit(fail ? 1 : 0);
}
main().catch(e => {
console.error('\n[phase2-verify] fatal:', e);
for (const r of results) {
console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
}
process.exit(1);
});