#!/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); });