Commit 0594b1dee730de178e0f51889446b938aa5c621a
1 parent
151acb22
feat(devloop): P4 L3 human feedback — status FSM + signOff/abandon/feedback + extended status page
Implements the §P4 acceptance gate from plan-migration.md, with one
scope adjustment confirmed up-front: full Vue Dashboard (~1650 lines)
deferred to a future UI phase, replaced by extending the existing
P3 static status page with Sign Off / Abandon / Feedback controls.
All P4 backend acceptance criteria are scripted (phase4-verify.js).
Project status FSM (project.json.status):
- server/src/core/devloop/loop-controller.js
* draft → running → shipped → shipped-final | abandoned
↓ ↓
l2_blocked (feedback flips back to running)
l2_exhausted
* `_setProjectStatus` validates transitions via STATUS_TRANSITIONS,
emits SSE `project.status`, writes timeline event.
* createProject seeds status=draft; runDocsPhase end → running;
runProject(shipped) → shipped; all dead-end branches (l2_blocked,
l2_exhausted) also propagate to status via _safeSetStatus.
L3 helpers:
- signOff(pid, opts) — requires status=shipped → shipped-final; commits
a `chore: sign-off` git entry; rejects other source states with
InvalidProjectStatusTransition.
- abandon(pid, opts) — any non-terminal → abandoned; commits + calls
sseBroker.close(pid) to send SSE close frame to subscribers.
- submitFeedback(pid, text, opts) — rejects in shipped-final/abandoned;
invokes PM in incremental mode; on prd_rev bump writes
changelog.ndjson prd_rev entry + emits prd.updated SSE; if prior
status was shipped, flips back to running so next runProject can
rebuild.
PM incremental mock fixture:
- fixtures/devloop-mock/devloop-pm.json — converted to sequence form:
* sequence[0] (init) — original P1 fixture (PRD rev=1, 14 files).
* sequence[1] (incremental) — appends a new section to
prd/feature/feature-tree.html, bumps article data-rev to 2,
writes history/prd-rev-002.html.
Baseline happy-path Integrator:
- fixtures/devloop-mock/devloop-integrator.json — single-shot pass.
Makes vanilla runProject ship without a scenario override.
P4 happy-path scenario:
- fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json —
single-unit (todo-be only) Architect so end-to-end ship works with
the baseline Coder/Tester (which only know todo-be paths).
Routes:
- server/routes/devloop.js:
* POST /projects/:pid/sign-off — 400 InvalidProjectStatusTransition
when status != 'shipped'; 200 returns { status }.
* POST /projects/:pid/abandon — body { by?, reason?, note? }; 400 on
terminal state; 200 returns { status: 'abandoned' }.
* POST /projects/:pid/feedback — body { text (req), by?,
mockFixtureDir? }; 400 on missing text or shipped-final/abandoned;
200 returns { prd_rev_before, prd_rev_after, status }.
* POST /projects: now accepts optional `mockFixtureDir` so the same
fixture dir threads through both the create call and later
feedback (keeps mock-sequence counters aligned).
Static status page (replaces full Vue Dashboard for P4):
- server/public/devloop-status.html — adds:
* Status badge in header (color-coded per status).
* Sign Off / Abandon control buttons (disabled per current state).
* Feedback textarea + Submit button → POST /feedback.
* Buttons reflect FSM (signoff only when shipped; abandon/feedback
locked at terminal states).
Acceptance:
- scripts/phase4-verify.js — 9 steps, full HTTP path:
① POST /projects → running
② POST /run → shipped
③ POST /feedback → PRD rev 1→2 + running (+ changelog entry)
④ POST /run again → shipped
⑤ POST /sign-off → shipped-final
⑥ POST /feedback (after sign-off) → 400
⑦ POST /sign-off (after sign-off) → 400
⑧ second project: POST /abandon → abandoned
⑨ POST /abandon (after abandon) → 400
Tests (+19 cases):
- server/tests/devloop/p4-l3-feedback.test.js
* 14 FSM cases covering happy path + abandon path + signOff
pre-conditions + empty feedback rejection.
* 5 route cases via fetch on ephemeral express app.
Results:
- phase0-verify: 8/8
- phase0-test-call: 7/7
- phase1-verify: 7/7
- phase2-verify: 6/6
- phase3-verify: 9/9
- phase4-verify: 9/9
- vitest devloop+acp-core: 213/213 (was 194 → +19)
Spec deviations explicit:
- Full Vue DevLoopDashboard.vue + Ops 4 sub-pages NOT implemented;
static status page handles the demoable surface area instead.
- All backend endpoints from §P4 (sign-off, abandon, feedback) are
complete and tested.
Specs: docs/prd/newfunction/_spec/plan-migration.md · P4 L3 + UI
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
8 changed files
with
846 additions
and
9 deletions
fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "Happy-path 1-unit (todo-be) architect for P4 acceptance.", | |
| 3 | + "chunks": ["## SDD rev 1 (todo-be only)\n"], | |
| 4 | + "result": "mock-ok devloop-architect[happy]: 1 unit", | |
| 5 | + "files": [ | |
| 6 | + { | |
| 7 | + "path": "sdd/index.html", | |
| 8 | + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>SDD</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>SDD</h3><p>todo-be only</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">v1</div></section></article></body></html>" | |
| 9 | + }, | |
| 10 | + { | |
| 11 | + "path": "sdd/mvcp/control.html", | |
| 12 | + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\" data-unit-id=\"todo-be\"><h3>C-1 POST /todos</h3><p>201 on create, 400 on missing text</p><table><tr><td>text</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">api</div></section></article></body></html>" | |
| 13 | + }, | |
| 14 | + { | |
| 15 | + "path": "units.json", | |
| 16 | + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1, \"prd_rev\": \"1\", \"sdd_rev\": \"1\", \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0, \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:0000000000000002\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n" | |
| 17 | + }, | |
| 18 | + { | |
| 19 | + "path": "integration/spec.json", | |
| 20 | + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + "path": "units/todo-be/spec.json", | |
| 24 | + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}" | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "path": "history/sdd-rev-001.html", | |
| 28 | + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>snap</h3><p>rev1</p></section></article></body></html>" | |
| 29 | + } | |
| 30 | + ] | |
| 31 | +} | ... | ... |
fixtures/devloop-mock/devloop-integrator.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "Baseline happy-path Integrator: returns ok=true on every call. Used by tests/runs that don't override via mockFixtureDir.", | |
| 3 | + "chunks": [ | |
| 4 | + "## step 0 · 读 units.json + integration/spec.json\n", | |
| 5 | + "## step 1-7 · 起 be 进程,跑全部 cases\n", | |
| 6 | + "全部 case 通过 ✓\n" | |
| 7 | + ], | |
| 8 | + "result": "mock-ok devloop-integrator[baseline] PASS: integration green", | |
| 9 | + "files": [ | |
| 10 | + { | |
| 11 | + "path": "integration/runs/run-mock-baseline/result.json", | |
| 12 | + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-baseline\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 850\n}\n" | |
| 13 | + }, | |
| 14 | + { | |
| 15 | + "path": "integration/runs/run-mock-baseline/process.log", | |
| 16 | + "content": "[todo-be] starting on :18001\n[todo-be] /api/health → 200\n[todo-be] case-* pass\n[todo-be] SIGTERM clean exit\n" | |
| 17 | + } | |
| 18 | + ] | |
| 19 | +} | ... | ... |
fixtures/devloop-mock/devloop-pm.json
| 1 | 1 | { |
| 2 | - "_doc": "Mock fixture for PM Agent — drives P1 acceptance (todo-demo project). Produces the full PRD multi-page site + pm agent-state + history snapshot.", | |
| 3 | - "chunks": [ | |
| 2 | + "_doc": "Mock fixture for PM Agent. sequence[0]=init (P1+,full PRD site rev=1), sequence[1]=incremental (P4,appends a section + bumps rev to 2). 第 2+ 次反馈 sticks 在 incremental,每次重复写相同 rev=2 内容 (mock 简化)。", | |
| 3 | + "sequence": [ | |
| 4 | + { | |
| 5 | + "_variant": "init", | |
| 6 | + "chunks": [ | |
| 4 | 7 | "## 第 1 步: 解析 idea\n", |
| 5 | 8 | "用户要求做一个 TODO 列表 API + Vue 前端。\n", |
| 6 | 9 | "\n## 第 2 步: 初始化 prd/ 站点\n", |
| ... | ... | @@ -69,4 +72,28 @@ |
| 69 | 72 | "content": "{\"agent\":\"devloop-pm\",\"call_id\":\"call-mock-pm\",\"created_at\":\"2026-05-13T10:00:00.000Z\",\"items\":[{\"id\":\"step-001\",\"description\":\"bootstrap_prd_site\",\"status\":\"done\",\"depends_on\":[]},{\"id\":\"step-002\",\"description\":\"write index.html\",\"status\":\"done\",\"depends_on\":[\"step-001\"]},{\"id\":\"step-003\",\"description\":\"write product/*.html\",\"status\":\"done\",\"depends_on\":[\"step-002\"]},{\"id\":\"step-004\",\"description\":\"write feature/* + flow/* + prototype/*\",\"status\":\"done\",\"depends_on\":[\"step-002\"]},{\"id\":\"step-005\",\"description\":\"update_search_index\",\"status\":\"done\",\"depends_on\":[\"step-004\"]},{\"id\":\"step-006\",\"description\":\"validate_prd_site\",\"status\":\"done\",\"depends_on\":[\"step-005\"]},{\"id\":\"step-007\",\"description\":\"archive_doc → history/\",\"status\":\"done\",\"depends_on\":[\"step-006\"]},{\"id\":\"step-008\",\"description\":\"echo_diff\",\"status\":\"done\",\"depends_on\":[\"step-007\"]}],\"current\":null,\"progress_pct\":100}" |
| 70 | 73 | } |
| 71 | 74 | ] |
| 75 | + }, | |
| 76 | + { | |
| 77 | + "_variant": "incremental", | |
| 78 | + "chunks": [ | |
| 79 | + "## Mode: incremental\n", | |
| 80 | + "收到人类反馈,追加 prd/feature/feature-tree.html 一个 section,bump prd_rev → 2\n" | |
| 81 | + ], | |
| 82 | + "result": "mock-ok devloop-pm[incremental]: PRD rev 2 + 1 new feature section", | |
| 83 | + "files": [ | |
| 84 | + { | |
| 85 | + "path": "prd/index.html", | |
| 86 | + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>PRD · todo-demo (rev 2)</title><link rel=\"stylesheet\" href=\"assets/shell.css\"></head><body data-page=\"index.html\"><article data-doc=\"prd\" data-rev=\"2\"><section data-rev=\"1\" data-origin=\"system@2026-05-13T10:00:00.000Z\"><h3>项目首页</h3><p>todo-demo · 一个 TODO 列表 API + 前端</p><table><tr><th>章节</th><th>路径</th></tr><tr><td>背景</td><td>product/background.html</td></tr></table><pre>flow: list -> add -> done</pre><svg width=\"100\" height=\"20\"><line x1=\"0\" y1=\"10\" x2=\"100\" y2=\"10\" stroke=\"black\" marker-end=\"url(#arrow)\"/></svg><div class=\"card\">导航卡片</div><details><summary>更多</summary><p>站点入口</p></details></section><section data-rev=\"2\" data-origin=\"human@2026-05-13T11:00:00.000Z\"><h3>新增功能 (feedback)</h3><p>追加: 标记 done / undone 切换</p><table><tr><td>新功能</td></tr></table><pre>toggle done</pre><svg width=\"40\" height=\"20\"></svg><div class=\"card\">v2</div></section><footer data-changelog>rev2: 人类追加 toggle done</footer></article><script src=\"assets/shell.js\"></script></body></html>" | |
| 87 | + }, | |
| 88 | + { | |
| 89 | + "path": "prd/feature/feature-tree.html", | |
| 90 | + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>功能树 rev 2</title><link rel=\"stylesheet\" href=\"../assets/shell.css\"></head><body data-page=\"feature/feature-tree.html\"><article data-doc=\"prd\" data-rev=\"2\"><section data-rev=\"1\" data-origin=\"yibo@2026-05-13T10:00:00.000Z\"><h3>功能树</h3><p>todo-be · todo-fe · todo-db</p><table><tr><th>模块</th><th>说明</th></tr><tr><td>todo-be</td><td>API</td></tr></table><pre>tree</pre><svg width=\"40\" height=\"20\"></svg><div class=\"card\">v1</div></section><section data-rev=\"2\" data-origin=\"human@2026-05-13T11:00:00.000Z\"><h3>NEW: toggle done</h3><p>用户反馈:列表项需要支持标记完成/未完成的切换。新增 PATCH /todos/:id 接口与对应前端按钮。</p><table><tr><td>PATCH</td><td>/todos/:id</td></tr></table><pre>toggle</pre><svg width=\"40\" height=\"20\"></svg><div class=\"card\">delta</div></section></article></body></html>" | |
| 91 | + }, | |
| 92 | + { | |
| 93 | + "path": "history/prd-rev-002.html", | |
| 94 | + "content": "<!doctype html><html><body><article data-doc=\"prd\" data-rev=\"2\"><section data-rev=\"1\"><h3>Snapshot rev 2</h3><p>Archive of PRD rev 2 (after incremental feedback).</p></section></article></body></html>" | |
| 95 | + } | |
| 96 | + ] | |
| 97 | + } | |
| 98 | + ] | |
| 72 | 99 | } | ... | ... |
scripts/phase4-verify.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * scripts/phase4-verify.js | |
| 4 | + * | |
| 5 | + * P4 L3 Human Feedback 验收 (mock 模式 · 端到端 HTTP): | |
| 6 | + * ① POST /projects → status=running, PRD rev 1 | |
| 7 | + * ② POST /run → ships, status=shipped | |
| 8 | + * ③ POST /feedback → PRD rev bumped to 2, status shipped→running, changelog prd_rev=2 | |
| 9 | + * ④ POST /run (re-run) → status=shipped again | |
| 10 | + * ⑤ POST /sign-off → status=shipped-final | |
| 11 | + * ⑥ POST /feedback (after sign-off) → 400 InvalidProjectStatusTransition | |
| 12 | + * ⑦ POST /sign-off (after sign-off) → 400 | |
| 13 | + * ⑧ 第二个项目: POST /abandon → status=abandoned | |
| 14 | + * ⑨ 第二个项目 POST /abandon 再次 → 400 | |
| 15 | + */ | |
| 16 | + | |
| 17 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | |
| 18 | + | |
| 19 | +const fs = require('fs'); | |
| 20 | +const path = require('path'); | |
| 21 | + | |
| 22 | +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); | |
| 23 | +require('module').globalPaths.push(serverNodeModules); | |
| 24 | +const express = (() => { | |
| 25 | + try { return require('express'); } | |
| 26 | + catch { | |
| 27 | + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); | |
| 28 | + } | |
| 29 | +})(); | |
| 30 | + | |
| 31 | +const pathResolver = require('../server/services/devloop/path-resolver'); | |
| 32 | +const sseBroker = require('../server/services/devloop/sse-broker'); | |
| 33 | +const devloopRouter = require('../server/routes/devloop'); | |
| 34 | + | |
| 35 | +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path'); | |
| 36 | + | |
| 37 | +const results = []; | |
| 38 | +function step(name, fn) { | |
| 39 | + return (async () => { | |
| 40 | + try { | |
| 41 | + const r = await fn(); | |
| 42 | + results.push({ name, ok: true, detail: r?.detail || '' }); | |
| 43 | + return r; | |
| 44 | + } catch (e) { | |
| 45 | + results.push({ name, ok: false, reason: e.message }); | |
| 46 | + throw e; | |
| 47 | + } | |
| 48 | + })(); | |
| 49 | +} | |
| 50 | + | |
| 51 | +async function startApp() { | |
| 52 | + const app = express(); | |
| 53 | + app.use(express.json()); | |
| 54 | + app.use('/api/devloop', devloopRouter); | |
| 55 | + return new Promise((resolve) => { | |
| 56 | + const srv = app.listen(0, '127.0.0.1', () => { | |
| 57 | + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); | |
| 58 | + }); | |
| 59 | + }); | |
| 60 | +} | |
| 61 | + | |
| 62 | +async function main() { | |
| 63 | + const { srv, base } = await startApp(); | |
| 64 | + const cid = 'c-phase4-' + Date.now().toString(36); | |
| 65 | + let pid1, pid2; | |
| 66 | + | |
| 67 | + try { | |
| 68 | + await step('① POST /projects → status=running', async () => { | |
| 69 | + const r = await fetch(`${base}/api/devloop/projects`, { | |
| 70 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 71 | + body: JSON.stringify({ company_id: cid, requirement: 'TODO demo', mockFixtureDir: HAPPY_DIR }), | |
| 72 | + }); | |
| 73 | + if (r.status !== 200) throw new Error('status ' + r.status); | |
| 74 | + const j = await r.json(); | |
| 75 | + pid1 = j.pid; | |
| 76 | + const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json(); | |
| 77 | + if (s.status !== 'running') throw new Error('want running, got ' + s.status); | |
| 78 | + return { detail: `pid=${pid1} status=running prd_rev=${s.prd_rev}` }; | |
| 79 | + }); | |
| 80 | + | |
| 81 | + await step('② POST /run → status=shipped', async () => { | |
| 82 | + const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, { | |
| 83 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 84 | + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }), | |
| 85 | + }); | |
| 86 | + const j = await r.json(); | |
| 87 | + if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state); | |
| 88 | + const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json(); | |
| 89 | + if (s.status !== 'shipped') throw new Error('status=' + s.status); | |
| 90 | + return { detail: `final_state=shipped l2_rounds=${j.l2_rounds}` }; | |
| 91 | + }); | |
| 92 | + | |
| 93 | + await step('③ POST /feedback → PRD rev 1→2, status shipped→running', async () => { | |
| 94 | + const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, { | |
| 95 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 96 | + body: JSON.stringify({ text: 'add toggle done', by: 'yibo', mockFixtureDir: HAPPY_DIR }), | |
| 97 | + }); | |
| 98 | + if (r.status !== 200) throw new Error('status ' + r.status); | |
| 99 | + const j = await r.json(); | |
| 100 | + if (j.prd_rev_after !== 2) throw new Error('prd_rev_after=' + j.prd_rev_after); | |
| 101 | + if (j.status !== 'running') throw new Error('status=' + j.status); | |
| 102 | + // changelog | |
| 103 | + const cl = pathResolver.resolveChangelog(pid1); | |
| 104 | + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); | |
| 105 | + if (!lines.some(l => l.kind === 'prd_rev' && l.rev === 2)) throw new Error('no prd_rev=2 in changelog'); | |
| 106 | + return { detail: `rev 1→2 · status=running · changelog updated` }; | |
| 107 | + }); | |
| 108 | + | |
| 109 | + await step('④ POST /run (re-run) → status=shipped', async () => { | |
| 110 | + const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, { | |
| 111 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 112 | + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }), | |
| 113 | + }); | |
| 114 | + const j = await r.json(); | |
| 115 | + if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state); | |
| 116 | + return { detail: 'shipped again' }; | |
| 117 | + }); | |
| 118 | + | |
| 119 | + await step('⑤ POST /sign-off → status=shipped-final', async () => { | |
| 120 | + const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, { | |
| 121 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 122 | + body: JSON.stringify({ by: 'yibo' }), | |
| 123 | + }); | |
| 124 | + if (r.status !== 200) throw new Error('status ' + r.status); | |
| 125 | + const j = await r.json(); | |
| 126 | + if (j.status !== 'shipped-final') throw new Error('status=' + j.status); | |
| 127 | + return { detail: 'status=shipped-final' }; | |
| 128 | + }); | |
| 129 | + | |
| 130 | + await step('⑥ POST /feedback (after sign-off) → 400', async () => { | |
| 131 | + const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, { | |
| 132 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 133 | + body: JSON.stringify({ text: 'late change' }), | |
| 134 | + }); | |
| 135 | + if (r.status !== 400) throw new Error('want 400, got ' + r.status); | |
| 136 | + const j = await r.json(); | |
| 137 | + if (j.from !== 'shipped-final') throw new Error('from=' + j.from); | |
| 138 | + return { detail: '400 InvalidProjectStatusTransition (from=shipped-final)' }; | |
| 139 | + }); | |
| 140 | + | |
| 141 | + await step('⑦ POST /sign-off (after sign-off) → 400', async () => { | |
| 142 | + const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, { | |
| 143 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', | |
| 144 | + }); | |
| 145 | + if (r.status !== 400) throw new Error('want 400, got ' + r.status); | |
| 146 | + return { detail: '400 (terminal state)' }; | |
| 147 | + }); | |
| 148 | + | |
| 149 | + await step('⑧ 第二项目: POST /abandon → status=abandoned', async () => { | |
| 150 | + const cr = await fetch(`${base}/api/devloop/projects`, { | |
| 151 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 152 | + body: JSON.stringify({ company_id: cid, requirement: 'abandon test', mockFixtureDir: HAPPY_DIR }), | |
| 153 | + }); | |
| 154 | + pid2 = (await cr.json()).pid; | |
| 155 | + const ab = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, { | |
| 156 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 157 | + body: JSON.stringify({ by: 'yibo', reason: 'mock test' }), | |
| 158 | + }); | |
| 159 | + if (ab.status !== 200) throw new Error('abandon status ' + ab.status); | |
| 160 | + const j = await ab.json(); | |
| 161 | + if (j.status !== 'abandoned') throw new Error('status=' + j.status); | |
| 162 | + return { detail: `pid=${pid2} status=abandoned` }; | |
| 163 | + }); | |
| 164 | + | |
| 165 | + await step('⑨ POST /abandon (after abandon) → 400', async () => { | |
| 166 | + const r = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, { | |
| 167 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', | |
| 168 | + }); | |
| 169 | + if (r.status !== 400) throw new Error('want 400, got ' + r.status); | |
| 170 | + return { detail: '400 (terminal state)' }; | |
| 171 | + }); | |
| 172 | + | |
| 173 | + } finally { | |
| 174 | + srv.close(); | |
| 175 | + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } | |
| 176 | + catch { /* ignore */ } | |
| 177 | + } | |
| 178 | + | |
| 179 | + let pass = 0, fail = 0; | |
| 180 | + console.log('\n┌─ DevLoop Phase 4 — L3 Human Feedback Acceptance ──'); | |
| 181 | + for (const r of results) { | |
| 182 | + const tag = r.ok ? '✓' : '✗'; | |
| 183 | + console.log(`│ ${tag} ${r.name.padEnd(58)} ${r.ok ? r.detail : '— ' + r.reason}`); | |
| 184 | + r.ok ? pass++ : fail++; | |
| 185 | + } | |
| 186 | + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); | |
| 187 | + process.exit(fail ? 1 : 0); | |
| 188 | +} | |
| 189 | + | |
| 190 | +main().catch(e => { | |
| 191 | + console.error('\n[phase4-verify] fatal:', e); | |
| 192 | + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); | |
| 193 | + process.exit(1); | |
| 194 | +}); | ... | ... |
server/public/devloop-status.html
| ... | ... | @@ -29,6 +29,21 @@ |
| 29 | 29 | input { font-family:monospace; padding:4px 8px; border:1px solid #ccc; border-radius:4px; } |
| 30 | 30 | button { padding:4px 12px; background:#222; color:white; border:none; border-radius:4px; cursor:pointer; } |
| 31 | 31 | .err { color:#c00; margin:8px 0; } |
| 32 | + .ok-flash { color:#080; margin:8px 0; } | |
| 33 | + .status-badge { font-size:11px; padding:3px 10px; border-radius:12px; margin-left:10px; text-transform:uppercase; } | |
| 34 | + .status-badge.draft { background:#eee; color:#666; } | |
| 35 | + .status-badge.running { background:#cce5ff; color:#004085; } | |
| 36 | + .status-badge.shipped { background:#d4edda; color:#155724; } | |
| 37 | + .status-badge.shipped-final { background:#0a6; color:#fff; } | |
| 38 | + .status-badge.abandoned { background:#222; color:#fff; } | |
| 39 | + .status-badge.l2_blocked { background:#fff3cd; color:#856404; } | |
| 40 | + .status-badge.l2_exhausted { background:#f8d7da; color:#721c24; } | |
| 41 | + .controls { display:flex; gap:8px; margin:16px 0; flex-wrap:wrap; } | |
| 42 | + .controls button { padding:6px 14px; } | |
| 43 | + .controls button.signoff { background:#0a6; } | |
| 44 | + .controls button.abandon { background:#822; } | |
| 45 | + .controls button:disabled { background:#ccc; cursor:not-allowed; } | |
| 46 | + textarea { width:100%; min-height:60px; padding:8px; border:1px solid #ccc; border-radius:4px; font-family:inherit; box-sizing:border-box; } | |
| 32 | 47 | </style> |
| 33 | 48 | </head> |
| 34 | 49 | <body> |
| ... | ... | @@ -43,8 +58,20 @@ |
| 43 | 58 | <div id="err" class="err"></div> |
| 44 | 59 | |
| 45 | 60 | <div id="content" style="display:none"> |
| 46 | - <div class="pid">pid: <span id="pidShow"></span> · root: <span id="rootShow"></span></div> | |
| 47 | - <div class="row"> | |
| 61 | + <div class="pid">pid: <span id="pidShow"></span><span id="statusBadge" class="status-badge"></span> · root: <span id="rootShow"></span></div> | |
| 62 | + | |
| 63 | + <div id="opMsg" class="ok-flash"></div> | |
| 64 | + | |
| 65 | + <div class="controls"> | |
| 66 | + <button id="signoffBtn" class="signoff" title="只有 status=shipped 时可以">Sign Off</button> | |
| 67 | + <button id="abandonBtn" class="abandon">Abandon</button> | |
| 68 | + </div> | |
| 69 | + | |
| 70 | + <h2 style="font-size:14px;margin:16px 0 8px">Submit feedback (PM incremental)</h2> | |
| 71 | + <textarea id="feedbackText" placeholder="新功能 / 修订需求,例如: 增加 toggle done 切换"></textarea> | |
| 72 | + <div style="margin-top:8px"><button id="feedbackBtn">Submit feedback</button></div> | |
| 73 | + | |
| 74 | + <div class="row" style="margin-top:16px"> | |
| 48 | 75 | <div class="card"><label>PRD rev</label><div class="v" id="prdRev">-</div></div> |
| 49 | 76 | <div class="card"><label>SDD rev</label><div class="v" id="sddRev">-</div></div> |
| 50 | 77 | <div class="card"><label>Units</label><div class="v" id="unitsCount">0</div></div> |
| ... | ... | @@ -68,6 +95,10 @@ async function fetchState(pid) { |
| 68 | 95 | function render(s) { |
| 69 | 96 | $('pidShow').textContent = s.pid; |
| 70 | 97 | $('rootShow').textContent = s.root_path || ''; |
| 98 | + const status = s.status || s.project?.status || 'unknown'; | |
| 99 | + const sb = $('statusBadge'); | |
| 100 | + sb.textContent = status; | |
| 101 | + sb.className = 'status-badge ' + status; | |
| 71 | 102 | $('prdRev').textContent = s.prd_rev ?? '-'; |
| 72 | 103 | $('sddRev').textContent = s.sdd_rev ?? '-'; |
| 73 | 104 | const units = s.units || []; |
| ... | ... | @@ -81,6 +112,11 @@ function render(s) { |
| 81 | 112 | $('timeline').innerHTML = (s.timeline_tail || []).map(e => ` |
| 82 | 113 | <div><span class="ts">${e.ts || ''}</span><span class="kind">[${e.kind}]</span>${e.summary || ''}</div> |
| 83 | 114 | `).join(''); |
| 115 | + | |
| 116 | + // 控制按钮可点性 | |
| 117 | + $('signoffBtn').disabled = status !== 'shipped'; | |
| 118 | + $('abandonBtn').disabled = status === 'shipped-final' || status === 'abandoned'; | |
| 119 | + $('feedbackBtn').disabled = status === 'shipped-final' || status === 'abandoned'; | |
| 84 | 120 | } |
| 85 | 121 | |
| 86 | 122 | async function connect() { |
| ... | ... | @@ -104,6 +140,45 @@ async function connect() { |
| 104 | 140 | |
| 105 | 141 | $('connectBtn').addEventListener('click', connect); |
| 106 | 142 | $('pidInput').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); }); |
| 143 | + | |
| 144 | +async function postAction(endpoint, body) { | |
| 145 | + const pid = $('pidInput').value.trim(); | |
| 146 | + if (!pid) return; | |
| 147 | + $('opMsg').textContent = '...'; | |
| 148 | + try { | |
| 149 | + const r = await fetch(`/api/devloop/projects/${pid}/${endpoint}`, { | |
| 150 | + method: 'POST', | |
| 151 | + headers: { 'Content-Type': 'application/json' }, | |
| 152 | + body: JSON.stringify(body || {}), | |
| 153 | + }); | |
| 154 | + const j = await r.json(); | |
| 155 | + if (!r.ok) { | |
| 156 | + $('opMsg').textContent = `error: ${j.error || r.status}`; | |
| 157 | + $('opMsg').className = 'err'; | |
| 158 | + } else { | |
| 159 | + $('opMsg').textContent = `${endpoint} ok · status=${j.status || j.pid || ''}`; | |
| 160 | + $('opMsg').className = 'ok-flash'; | |
| 161 | + } | |
| 162 | + } catch (e) { | |
| 163 | + $('opMsg').textContent = 'network error: ' + e.message; | |
| 164 | + $('opMsg').className = 'err'; | |
| 165 | + } | |
| 166 | + // poll once immediately to refresh | |
| 167 | + const s = await fetchState($('pidInput').value.trim()); render(s); | |
| 168 | +} | |
| 169 | + | |
| 170 | +$('signoffBtn').addEventListener('click', () => postAction('sign-off', { by: 'human' })); | |
| 171 | +$('abandonBtn').addEventListener('click', () => { | |
| 172 | + if (!confirm('Abandon this project? (irreversible)')) return; | |
| 173 | + postAction('abandon', { by: 'human', reason: 'user clicked abandon' }); | |
| 174 | +}); | |
| 175 | +$('feedbackBtn').addEventListener('click', () => { | |
| 176 | + const text = $('feedbackText').value.trim(); | |
| 177 | + if (!text) { $('opMsg').textContent = 'feedback text required'; $('opMsg').className='err'; return; } | |
| 178 | + postAction('feedback', { text, by: 'human' }); | |
| 179 | + $('feedbackText').value = ''; | |
| 180 | +}); | |
| 181 | + | |
| 107 | 182 | const fromQuery = new URLSearchParams(location.search).get('pid'); |
| 108 | 183 | if (fromQuery) { $('pidInput').value = fromQuery; connect(); } |
| 109 | 184 | </script> | ... | ... |
server/routes/devloop.js
| ... | ... | @@ -33,7 +33,7 @@ const h = (fn) => async (req, res) => { |
| 33 | 33 | |
| 34 | 34 | // POST /api/devloop/projects |
| 35 | 35 | router.post('/projects', h(async (req, res) => { |
| 36 | - const { company_id, requirement, mode, target_path, mock, name } = req.body || {}; | |
| 36 | + const { company_id, requirement, mode, target_path, mock, name, mockFixtureDir } = req.body || {}; | |
| 37 | 37 | if (!company_id) { |
| 38 | 38 | return res.status(400).json({ error: 'missing field: company_id' }); |
| 39 | 39 | } |
| ... | ... | @@ -49,9 +49,10 @@ router.post('/projects', h(async (req, res) => { |
| 49 | 49 | idea: name ? `${name}: ${requirement}` : requirement, |
| 50 | 50 | }); |
| 51 | 51 | |
| 52 | - // 2. PM → Architect (mock 模式) | |
| 52 | + // 2. PM → Architect (mock 模式) — mockFixtureDir 可选,P3/P4 scenario 测试用 | |
| 53 | 53 | const phase = await loopController.runDocsPhase(proj.pid, requirement, { |
| 54 | 54 | mock: mock !== false, |
| 55 | + mockFixtureDir, | |
| 55 | 56 | }); |
| 56 | 57 | |
| 57 | 58 | res.json({ |
| ... | ... | @@ -137,6 +138,61 @@ router.get('/projects/:pid/stream', (req, res) => { |
| 137 | 138 | }); |
| 138 | 139 | }); |
| 139 | 140 | |
| 141 | +// P4: POST /api/devloop/projects/:pid/sign-off | |
| 142 | +// body: { by?, note? } | |
| 143 | +// 400 if status !== 'shipped' | |
| 144 | +router.post('/projects/:pid/sign-off', h(async (req, res) => { | |
| 145 | + const { pid } = req.params; | |
| 146 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 147 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 148 | + try { | |
| 149 | + const meta = await loopController.signOff(pid, req.body || {}); | |
| 150 | + res.json({ ok: true, pid, status: meta.status }); | |
| 151 | + } catch (e) { | |
| 152 | + if (e.name === 'InvalidProjectStatusTransition') { | |
| 153 | + return res.status(400).json({ error: e.message, from: e.from, to: e.to }); | |
| 154 | + } | |
| 155 | + throw e; | |
| 156 | + } | |
| 157 | +})); | |
| 158 | + | |
| 159 | +// P4: POST /api/devloop/projects/:pid/abandon | |
| 160 | +// body: { by?, reason?, note? } | |
| 161 | +router.post('/projects/:pid/abandon', h(async (req, res) => { | |
| 162 | + const { pid } = req.params; | |
| 163 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 164 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 165 | + try { | |
| 166 | + const meta = await loopController.abandon(pid, req.body || {}); | |
| 167 | + res.json({ ok: true, pid, status: meta.status }); | |
| 168 | + } catch (e) { | |
| 169 | + if (e.name === 'InvalidProjectStatusTransition') { | |
| 170 | + return res.status(400).json({ error: e.message, from: e.from, to: e.to }); | |
| 171 | + } | |
| 172 | + throw e; | |
| 173 | + } | |
| 174 | +})); | |
| 175 | + | |
| 176 | +// P4: POST /api/devloop/projects/:pid/feedback | |
| 177 | +// body: { text (required), by?, mockFixtureDir? } | |
| 178 | +// 400 if status ∈ {shipped-final, abandoned} | |
| 179 | +router.post('/projects/:pid/feedback', h(async (req, res) => { | |
| 180 | + const { pid } = req.params; | |
| 181 | + const { text, by, mockFixtureDir } = req.body || {}; | |
| 182 | + if (!text) return res.status(400).json({ error: 'missing field: text' }); | |
| 183 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 184 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 185 | + try { | |
| 186 | + const r = await loopController.submitFeedback(pid, text, { by, mockFixtureDir }); | |
| 187 | + res.json(r); | |
| 188 | + } catch (e) { | |
| 189 | + if (e.name === 'InvalidProjectStatusTransition') { | |
| 190 | + return res.status(400).json({ error: e.message, from: e.from, to: e.to }); | |
| 191 | + } | |
| 192 | + throw e; | |
| 193 | + } | |
| 194 | +})); | |
| 195 | + | |
| 140 | 196 | // GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be |
| 141 | 197 | router.get('/projects/:pid/bugs', h(async (req, res) => { |
| 142 | 198 | const { pid } = req.params; | ... | ... |
server/src/core/devloop/loop-controller.js
| ... | ... | @@ -51,13 +51,15 @@ async function createProject({ company_id, mode = 'sandbox', target_path, idea = |
| 51 | 51 | company_id, mode, target_path, |
| 52 | 52 | }); |
| 53 | 53 | |
| 54 | - // project.json | |
| 54 | + // project.json (含 P4 status FSM 字段) | |
| 55 | 55 | const meta = { |
| 56 | 56 | pid, |
| 57 | 57 | company_id, |
| 58 | 58 | mode, |
| 59 | 59 | created_at: new Date().toISOString(), |
| 60 | 60 | idea_summary: (idea || '').slice(0, 200), |
| 61 | + status: 'draft', // FSM: draft → running → shipped → shipped-final | abandoned | |
| 62 | + status_updated_at: new Date().toISOString(), | |
| 61 | 63 | }; |
| 62 | 64 | fs.writeFileSync( |
| 63 | 65 | pathResolver.resolveProjectJson(pid), |
| ... | ... | @@ -293,6 +295,9 @@ async function runDocsPhase(pid, requirement, opts = {}) { |
| 293 | 295 | }); |
| 294 | 296 | } catch { /* swallow */ } |
| 295 | 297 | |
| 298 | + // P4: 项目进入 running 态(完成 docs 后,准备进 L1/L2) | |
| 299 | + _setProjectStatus(pid, 'running', { reason: 'docs_complete' }); | |
| 300 | + | |
| 296 | 301 | return { ok: true, pm, architect }; |
| 297 | 302 | } |
| 298 | 303 | |
| ... | ... | @@ -311,7 +316,10 @@ function getProjectState(pid) { |
| 311 | 316 | |
| 312 | 317 | const projectJson = pathResolver.resolveProjectJson(pid); |
| 313 | 318 | if (fs.existsSync(projectJson)) { |
| 314 | - try { out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); } catch {} | |
| 319 | + try { | |
| 320 | + out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); | |
| 321 | + out.status = out.project.status || 'unknown'; | |
| 322 | + } catch {} | |
| 315 | 323 | } |
| 316 | 324 | |
| 317 | 325 | const unitsJson = pathResolver.resolveUnitsJson(pid); |
| ... | ... | @@ -579,6 +587,7 @@ async function runProject(pid, opts = {}) { |
| 579 | 587 | history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts }); |
| 580 | 588 | if (!r.ok) { |
| 581 | 589 | sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts }); |
| 590 | + _safeSetStatus(pid, 'l2_blocked', { reason: `unit ${uid} blocked` }); | |
| 582 | 591 | return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` }; |
| 583 | 592 | } |
| 584 | 593 | } |
| ... | ... | @@ -587,8 +596,8 @@ async function runProject(pid, opts = {}) { |
| 587 | 596 | |
| 588 | 597 | // ── (2) L2 gate ──────────────────────────────────────────── |
| 589 | 598 | if (!unitRegistry.l2GateOpen(pid)) { |
| 590 | - // 还有非 passed 的 unit (e.g. blocked) → 视为 blocked | |
| 591 | 599 | sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' }); |
| 600 | + _safeSetStatus(pid, 'l2_blocked', { reason: 'l2GateClosed' }); | |
| 592 | 601 | return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' }; |
| 593 | 602 | } |
| 594 | 603 | |
| ... | ... | @@ -617,6 +626,7 @@ async function runProject(pid, opts = {}) { |
| 617 | 626 | summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`, |
| 618 | 627 | }); |
| 619 | 628 | } catch {} |
| 629 | + _setProjectStatus(pid, 'shipped', { run_id: runId }); | |
| 620 | 630 | return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId }; |
| 621 | 631 | } |
| 622 | 632 | |
| ... | ... | @@ -647,6 +657,7 @@ async function runProject(pid, opts = {}) { |
| 647 | 657 | |
| 648 | 658 | if (!triage) { |
| 649 | 659 | sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId }); |
| 660 | + _safeSetStatus(pid, 'l2_blocked', { reason: 'triage_missing' }); | |
| 650 | 661 | return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' }; |
| 651 | 662 | } |
| 652 | 663 | |
| ... | ... | @@ -659,6 +670,7 @@ async function runProject(pid, opts = {}) { |
| 659 | 670 | reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous', |
| 660 | 671 | run_id: runId, category, confidence: triage.confidence, |
| 661 | 672 | }); |
| 673 | + _safeSetStatus(pid, 'l2_blocked', { reason: lowConfidence ? 'low_confidence' : 'spec_ambiguous' }); | |
| 662 | 674 | return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` }; |
| 663 | 675 | } |
| 664 | 676 | |
| ... | ... | @@ -673,6 +685,7 @@ async function runProject(pid, opts = {}) { |
| 673 | 685 | const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u)); |
| 674 | 686 | if (affected.length === 0) { |
| 675 | 687 | sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId }); |
| 688 | + _safeSetStatus(pid, 'l2_blocked', { reason: 'impl_bug_no_affected' }); | |
| 676 | 689 | return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history }; |
| 677 | 690 | } |
| 678 | 691 | unitRegistry.reset(pid, affected); |
| ... | ... | @@ -688,6 +701,7 @@ async function runProject(pid, opts = {}) { |
| 688 | 701 | const l2Budget = _l2Budget(pid); |
| 689 | 702 | if (currentRounds + 1 > l2Budget) { |
| 690 | 703 | sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget }); |
| 704 | + _safeSetStatus(pid, 'l2_exhausted', { reason: 'budget_exceeded' }); | |
| 691 | 705 | return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history }; |
| 692 | 706 | } |
| 693 | 707 | const oldSddRev = _readSddRev(pid); |
| ... | ... | @@ -714,14 +728,25 @@ async function runProject(pid, opts = {}) { |
| 714 | 728 | |
| 715 | 729 | // Unknown category |
| 716 | 730 | sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category }); |
| 731 | + _safeSetStatus(pid, 'l2_blocked', { reason: 'unknown_category' }); | |
| 717 | 732 | return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` }; |
| 718 | 733 | } |
| 719 | 734 | |
| 720 | 735 | // safety net 超过 |
| 721 | 736 | sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations }); |
| 737 | + _safeSetStatus(pid, 'l2_exhausted', { reason: 'safety_net' }); | |
| 722 | 738 | return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history }; |
| 723 | 739 | } |
| 724 | 740 | |
| 741 | +/** Status set that swallows invalid-transition errors (already in target state etc.) */ | |
| 742 | +function _safeSetStatus(pid, to, extra) { | |
| 743 | + try { return _setProjectStatus(pid, to, extra); } | |
| 744 | + catch (e) { | |
| 745 | + if (e.name === 'InvalidProjectStatusTransition') return null; | |
| 746 | + throw e; | |
| 747 | + } | |
| 748 | +} | |
| 749 | + | |
| 725 | 750 | function _l2Rounds(pid) { |
| 726 | 751 | const u = docStore.readUnits(pid); |
| 727 | 752 | return u?.l2_rounds || 0; |
| ... | ... | @@ -745,6 +770,170 @@ function _readSddRev(pid) { |
| 745 | 770 | return 1; |
| 746 | 771 | } |
| 747 | 772 | |
| 773 | +// ────────────────────────────────────────────────────────────── | |
| 774 | +// P4 · L3 Human Feedback · status FSM + signOff / abandon / feedback | |
| 775 | +// ────────────────────────────────────────────────────────────── | |
| 776 | + | |
| 777 | +const STATUS_TRANSITIONS = { | |
| 778 | + draft: ['running', 'abandoned'], | |
| 779 | + running: ['shipped', 'l2_blocked', 'l2_exhausted', 'abandoned'], | |
| 780 | + l2_blocked: ['running', 'abandoned'], | |
| 781 | + l2_exhausted: ['running', 'abandoned'], | |
| 782 | + shipped: ['running', 'shipped-final', 'abandoned'], | |
| 783 | + 'shipped-final': [], // 终态 | |
| 784 | + abandoned: [], // 终态 | |
| 785 | +}; | |
| 786 | + | |
| 787 | +class InvalidProjectStatusTransition extends Error { | |
| 788 | + constructor(from, to) { | |
| 789 | + super(`Invalid project status transition: ${from} → ${to}`); | |
| 790 | + this.name = 'InvalidProjectStatusTransition'; | |
| 791 | + this.from = from; | |
| 792 | + this.to = to; | |
| 793 | + } | |
| 794 | +} | |
| 795 | + | |
| 796 | +function _readProjectMeta(pid) { | |
| 797 | + const p = pathResolver.resolveProjectJson(pid); | |
| 798 | + if (!fs.existsSync(p)) throw new Error(`project.json missing for ${pid}`); | |
| 799 | + return JSON.parse(fs.readFileSync(p, 'utf8')); | |
| 800 | +} | |
| 801 | + | |
| 802 | +function _writeProjectMeta(pid, meta) { | |
| 803 | + fs.writeFileSync(pathResolver.resolveProjectJson(pid), JSON.stringify(meta, null, 2)); | |
| 804 | +} | |
| 805 | + | |
| 806 | +/** | |
| 807 | + * 改 project.json.status,做 FSM 校验 + 发 SSE + 写 timeline。 | |
| 808 | + * 非法转换抛 InvalidProjectStatusTransition (供路由层映射 400)。 | |
| 809 | + */ | |
| 810 | +function _setProjectStatus(pid, to, extra = {}) { | |
| 811 | + const meta = _readProjectMeta(pid); | |
| 812 | + const from = meta.status || 'draft'; | |
| 813 | + if (from === to) return meta; // idempotent | |
| 814 | + const allowed = STATUS_TRANSITIONS[from] || []; | |
| 815 | + if (!allowed.includes(to)) { | |
| 816 | + throw new InvalidProjectStatusTransition(from, to); | |
| 817 | + } | |
| 818 | + meta.status = to; | |
| 819 | + meta.status_updated_at = new Date().toISOString(); | |
| 820 | + if (extra.note) meta.status_note = extra.note; | |
| 821 | + _writeProjectMeta(pid, meta); | |
| 822 | + | |
| 823 | + try { | |
| 824 | + sseBroker.emit(pid, 'project.status', { from, to, ...extra }); | |
| 825 | + timelineLogger.append(pid, { | |
| 826 | + source: extra.by || 'system', | |
| 827 | + kind: 'project.status', | |
| 828 | + ref: pid, | |
| 829 | + summary: `Project status ${from} → ${to}${extra.reason ? ` (${extra.reason})` : ''}`, | |
| 830 | + payload: { from, to, ...extra }, | |
| 831 | + }); | |
| 832 | + } catch { /* swallow */ } | |
| 833 | + return meta; | |
| 834 | +} | |
| 835 | + | |
| 836 | +/** | |
| 837 | + * Sign Off — 把已 shipped 的项目锁为 shipped-final。 | |
| 838 | + * 之后再发 feedback 会被拒绝。 | |
| 839 | + */ | |
| 840 | +async function signOff(pid, opts = {}) { | |
| 841 | + const meta = _readProjectMeta(pid); | |
| 842 | + if (meta.status !== 'shipped') { | |
| 843 | + throw new InvalidProjectStatusTransition(meta.status || 'unknown', 'shipped-final'); | |
| 844 | + } | |
| 845 | + _setProjectStatus(pid, 'shipped-final', { by: opts.by || 'human', note: opts.note || 'signed off' }); | |
| 846 | + // sign-off commit (留 git 痕) | |
| 847 | + try { | |
| 848 | + await gitVault.commit(pid, { | |
| 849 | + kind: 'chore', | |
| 850 | + summary: `chore: sign-off project ${pid}`, | |
| 851 | + author: { name: opts.by || 'human', email: 'human@devloop.local' }, | |
| 852 | + }); | |
| 853 | + } catch { /* swallow if nothing to commit */ } | |
| 854 | + return _readProjectMeta(pid); | |
| 855 | +} | |
| 856 | + | |
| 857 | +/** | |
| 858 | + * Abandon — 任意状态 → abandoned。发 SSE close 帧关闭所有订阅。 | |
| 859 | + */ | |
| 860 | +async function abandon(pid, opts = {}) { | |
| 861 | + const meta = _readProjectMeta(pid); | |
| 862 | + if (meta.status === 'shipped-final' || meta.status === 'abandoned') { | |
| 863 | + throw new InvalidProjectStatusTransition(meta.status, 'abandoned'); | |
| 864 | + } | |
| 865 | + _setProjectStatus(pid, 'abandoned', { by: opts.by || 'human', reason: opts.reason || 'manual abandon', note: opts.note }); | |
| 866 | + try { | |
| 867 | + await gitVault.commit(pid, { | |
| 868 | + kind: 'chore', | |
| 869 | + summary: `chore: abandon project ${pid}${opts.reason ? ' · ' + opts.reason : ''}`, | |
| 870 | + author: { name: opts.by || 'human', email: 'human@devloop.local' }, | |
| 871 | + }); | |
| 872 | + } catch { /* swallow */ } | |
| 873 | + // 关闭所有 SSE 订阅(close 帧) | |
| 874 | + try { sseBroker.close(pid); } catch { /* ignore */ } | |
| 875 | + return _readProjectMeta(pid); | |
| 876 | +} | |
| 877 | + | |
| 878 | +/** | |
| 879 | + * Submit human feedback — 触发 PM incremental 模式。 | |
| 880 | + * 仅当 status ∈ {shipped, running, l2_blocked, l2_exhausted} 时允许。 | |
| 881 | + * shipped-final / abandoned → 拒绝。 | |
| 882 | + */ | |
| 883 | +async function submitFeedback(pid, text, opts = {}) { | |
| 884 | + if (!text || typeof text !== 'string') { | |
| 885 | + throw new Error('feedback text required'); | |
| 886 | + } | |
| 887 | + const meta = _readProjectMeta(pid); | |
| 888 | + if (meta.status === 'shipped-final' || meta.status === 'abandoned') { | |
| 889 | + throw new InvalidProjectStatusTransition(meta.status, 'feedback'); | |
| 890 | + } | |
| 891 | + | |
| 892 | + const prdRevBefore = _readPrdRev(pid); | |
| 893 | + const pm = await callAgent(pid, 'devloop-pm', | |
| 894 | + `Mode: incremental\nFeedback from human (${opts.by || 'user'}):\n${text}\n\nAppend a new section under prd/feature/feature-tree.html (or appropriate chapter), bump prd_rev.`, | |
| 895 | + { mock: opts.mock !== false, mockFixtureDir: opts.mockFixtureDir }, | |
| 896 | + ); | |
| 897 | + const prdRevAfter = _readPrdRev(pid); | |
| 898 | + | |
| 899 | + // 若 PRD rev bumped → 写 changelog | |
| 900 | + if (prdRevAfter > prdRevBefore) { | |
| 901 | + try { | |
| 902 | + changelogWriter.append(pid, { | |
| 903 | + kind: 'prd_rev', | |
| 904 | + rev: prdRevAfter, | |
| 905 | + by: opts.by || 'human', | |
| 906 | + trigger: 'human_feedback', | |
| 907 | + summary: `PRD bumped to rev ${prdRevAfter} via human feedback`, | |
| 908 | + diff: { feedback_excerpt: text.slice(0, 120) }, | |
| 909 | + }); | |
| 910 | + sseBroker.emit(pid, 'prd.updated', { rev: prdRevAfter, summary: 'human feedback applied' }); | |
| 911 | + } catch { /* swallow */ } | |
| 912 | + } | |
| 913 | + | |
| 914 | + // 若当前是 shipped, 改回 running (人类反馈触发新一轮) | |
| 915 | + if (meta.status === 'shipped') { | |
| 916 | + _setProjectStatus(pid, 'running', { reason: 'human_feedback' }); | |
| 917 | + } | |
| 918 | + | |
| 919 | + return { | |
| 920 | + ok: true, | |
| 921 | + pid, | |
| 922 | + files_written: pm.files_written, | |
| 923 | + prd_rev_before: prdRevBefore, | |
| 924 | + prd_rev_after: prdRevAfter, | |
| 925 | + status: _readProjectMeta(pid).status, | |
| 926 | + }; | |
| 927 | +} | |
| 928 | + | |
| 929 | +function _readPrdRev(pid) { | |
| 930 | + const root = pathResolver.resolveProjectRoot(pid); | |
| 931 | + const idx = path.join(root, 'prd', 'index.html'); | |
| 932 | + if (!fs.existsSync(idx)) return 0; | |
| 933 | + const m = fs.readFileSync(idx, 'utf8').match(/<article[^>]*data-doc="prd"[^>]*data-rev="(\d+)"/); | |
| 934 | + return m ? parseInt(m[1], 10) : 1; | |
| 935 | +} | |
| 936 | + | |
| 748 | 937 | module.exports = { |
| 749 | 938 | createProject, |
| 750 | 939 | commitAfterAgent, |
| ... | ... | @@ -753,4 +942,9 @@ module.exports = { |
| 753 | 942 | runUnit, |
| 754 | 943 | runProject, |
| 755 | 944 | getProjectState, |
| 945 | + // P4 | |
| 946 | + signOff, | |
| 947 | + abandon, | |
| 948 | + submitFeedback, | |
| 949 | + InvalidProjectStatusTransition, | |
| 756 | 950 | }; | ... | ... |
server/tests/devloop/p4-l3-feedback.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * P4 · L3 Human Feedback tests | |
| 3 | + * | |
| 4 | + * 覆盖: | |
| 5 | + * - project.status FSM: draft → running → shipped → shipped-final | |
| 6 | + * - submitFeedback bumps prd_rev + shipped → running | |
| 7 | + * - signOff requires status=shipped | |
| 8 | + * - abandon any active state → abandoned (emits close) | |
| 9 | + * - InvalidProjectStatusTransition error mapping | |
| 10 | + * - PM incremental fixture variant runs on 2nd call | |
| 11 | + */ | |
| 12 | + | |
| 13 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | |
| 14 | + | |
| 15 | +const fs = require('fs'); | |
| 16 | +const path = require('path'); | |
| 17 | +const os = require('os'); | |
| 18 | + | |
| 19 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-p4-')); | |
| 20 | +process.env.HOME = TEST_HOME; | |
| 21 | + | |
| 22 | +const pathResolver = require('../../services/devloop/path-resolver'); | |
| 23 | +const sseBroker = require('../../services/devloop/sse-broker'); | |
| 24 | +const lc = require('../../src/core/devloop/loop-controller'); | |
| 25 | + | |
| 26 | +const HAPPY_DIR = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios/happy_path'); | |
| 27 | + | |
| 28 | +async function fresh(idea = 'p4 test') { | |
| 29 | + pathResolver._resetForTesting(); | |
| 30 | + sseBroker._resetForTesting(); | |
| 31 | + const cid = 'c-p4-' + Math.random().toString(36).slice(2, 7); | |
| 32 | + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea }); | |
| 33 | + return { pid: proj.pid, cid }; | |
| 34 | +} | |
| 35 | + | |
| 36 | +function cleanup(cid) { | |
| 37 | + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } | |
| 38 | + catch { /* ignore */ } | |
| 39 | +} | |
| 40 | + | |
| 41 | +describe('P4 · L3 status FSM', () => { | |
| 42 | + describe('happy-path: draft → running → shipped → shipped-final', () => { | |
| 43 | + let pid, cid; | |
| 44 | + beforeAll(async () => { ({ pid, cid } = await fresh()); }); | |
| 45 | + afterAll(() => cleanup(cid)); | |
| 46 | + | |
| 47 | + it('starts as draft', () => { | |
| 48 | + expect(lc.getProjectState(pid).status).toBe('draft'); | |
| 49 | + }); | |
| 50 | + | |
| 51 | + it('runDocsPhase → running', async () => { | |
| 52 | + await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR }); | |
| 53 | + expect(lc.getProjectState(pid).status).toBe('running'); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + it('runProject → shipped', async () => { | |
| 57 | + const r = await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR }); | |
| 58 | + expect(r.final_state).toBe('shipped'); | |
| 59 | + expect(lc.getProjectState(pid).status).toBe('shipped'); | |
| 60 | + }); | |
| 61 | + | |
| 62 | + it('feedback bumps prd_rev 1 → 2 and flips status shipped → running', async () => { | |
| 63 | + const r = await lc.submitFeedback(pid, 'add toggle done feature', { mockFixtureDir: HAPPY_DIR }); | |
| 64 | + expect(r.prd_rev_after).toBe(2); | |
| 65 | + expect(r.prd_rev_before).toBe(1); | |
| 66 | + expect(r.status).toBe('running'); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it('changelog has prd_rev=2 entry', () => { | |
| 70 | + const cl = pathResolver.resolveChangelog(pid); | |
| 71 | + expect(fs.existsSync(cl)).toBe(true); | |
| 72 | + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); | |
| 73 | + expect(lines.some(l => l.kind === 'prd_rev' && l.rev === 2)).toBe(true); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it('runProject again → shipped', async () => { | |
| 77 | + await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR }); | |
| 78 | + expect(lc.getProjectState(pid).status).toBe('shipped'); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + it('signOff → shipped-final', async () => { | |
| 82 | + const meta = await lc.signOff(pid, { by: 'yibo' }); | |
| 83 | + expect(meta.status).toBe('shipped-final'); | |
| 84 | + }); | |
| 85 | + | |
| 86 | + it('feedback after sign-off is rejected', async () => { | |
| 87 | + await expect(lc.submitFeedback(pid, 'late change', { mockFixtureDir: HAPPY_DIR })) | |
| 88 | + .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it('signOff after sign-off is rejected', async () => { | |
| 92 | + await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); | |
| 93 | + }); | |
| 94 | + }); | |
| 95 | + | |
| 96 | + describe('abandon path', () => { | |
| 97 | + let pid, cid; | |
| 98 | + beforeAll(async () => { ({ pid, cid } = await fresh('abandon test')); }); | |
| 99 | + afterAll(() => cleanup(cid)); | |
| 100 | + | |
| 101 | + it('abandon from draft state succeeds', async () => { | |
| 102 | + const m = await lc.abandon(pid, { by: 'yibo', reason: 'no longer needed' }); | |
| 103 | + expect(m.status).toBe('abandoned'); | |
| 104 | + }); | |
| 105 | + | |
| 106 | + it('abandon after abandon is rejected', async () => { | |
| 107 | + await expect(lc.abandon(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + it('feedback on abandoned project is rejected', async () => { | |
| 111 | + await expect(lc.submitFeedback(pid, 'x', { mockFixtureDir: HAPPY_DIR })) | |
| 112 | + .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); | |
| 113 | + }); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + describe('signOff requires shipped (not running)', () => { | |
| 117 | + let pid, cid; | |
| 118 | + beforeAll(async () => { | |
| 119 | + ({ pid, cid } = await fresh('not yet shipped')); | |
| 120 | + await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR }); | |
| 121 | + // 不跑 runProject,status 还是 running | |
| 122 | + }); | |
| 123 | + afterAll(() => cleanup(cid)); | |
| 124 | + | |
| 125 | + it('signOff from running fails', async () => { | |
| 126 | + await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition', from: 'running', to: 'shipped-final' }); | |
| 127 | + }); | |
| 128 | + }); | |
| 129 | + | |
| 130 | + describe('feedback empty text rejected', () => { | |
| 131 | + let pid, cid; | |
| 132 | + beforeAll(async () => { ({ pid, cid } = await fresh('empty feedback')); }); | |
| 133 | + afterAll(() => cleanup(cid)); | |
| 134 | + | |
| 135 | + it('throws on empty/missing text', async () => { | |
| 136 | + await expect(lc.submitFeedback(pid, '')).rejects.toThrow(/text/); | |
| 137 | + await expect(lc.submitFeedback(pid, null)).rejects.toThrow(/text/); | |
| 138 | + }); | |
| 139 | + }); | |
| 140 | +}); | |
| 141 | + | |
| 142 | +describe('P4 · routes', () => { | |
| 143 | + let app, base; | |
| 144 | + const cids = []; | |
| 145 | + | |
| 146 | + beforeAll(async () => { | |
| 147 | + const express = require('express'); | |
| 148 | + const devloopRouter = require('../../routes/devloop'); | |
| 149 | + pathResolver._resetForTesting(); | |
| 150 | + sseBroker._resetForTesting(); | |
| 151 | + const e = express(); | |
| 152 | + e.use(express.json()); | |
| 153 | + e.use('/api/devloop', devloopRouter); | |
| 154 | + await new Promise(r => { | |
| 155 | + app = e.listen(0, '127.0.0.1', () => { | |
| 156 | + base = `http://127.0.0.1:${app.address().port}`; | |
| 157 | + r(); | |
| 158 | + }); | |
| 159 | + }); | |
| 160 | + }); | |
| 161 | + | |
| 162 | + afterAll(() => { | |
| 163 | + app.close(); | |
| 164 | + for (const c of cids) cleanup(c); | |
| 165 | + }); | |
| 166 | + | |
| 167 | + it('POST /sign-off requires shipped status (400)', async () => { | |
| 168 | + const cid = 'c-route-signoff-' + Math.random().toString(36).slice(2, 7); | |
| 169 | + cids.push(cid); | |
| 170 | + const r = await fetch(`${base}/api/devloop/projects`, { | |
| 171 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 172 | + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), | |
| 173 | + }); | |
| 174 | + const j = await r.json(); | |
| 175 | + // immediately after creation/run (full flow), check signoff | |
| 176 | + const r2 = await fetch(`${base}/api/devloop/projects/${j.pid}/sign-off`, { | |
| 177 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', | |
| 178 | + }); | |
| 179 | + // After POST /projects, status is 'running' (not yet shipped via runProject) | |
| 180 | + // → signOff should 400 | |
| 181 | + expect(r2.status).toBe(400); | |
| 182 | + }); | |
| 183 | + | |
| 184 | + it('POST /feedback bumps PRD rev', async () => { | |
| 185 | + const cid = 'c-route-feedback-' + Math.random().toString(36).slice(2, 7); | |
| 186 | + cids.push(cid); | |
| 187 | + const r = await fetch(`${base}/api/devloop/projects`, { | |
| 188 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 189 | + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), | |
| 190 | + }); | |
| 191 | + const pid = (await r.json()).pid; | |
| 192 | + const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { | |
| 193 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 194 | + body: JSON.stringify({ text: 'add toggle', mockFixtureDir: HAPPY_DIR }), | |
| 195 | + }); | |
| 196 | + expect(fb.status).toBe(200); | |
| 197 | + const j = await fb.json(); | |
| 198 | + expect(j.prd_rev_after).toBeGreaterThan(j.prd_rev_before); | |
| 199 | + }); | |
| 200 | + | |
| 201 | + it('POST /feedback missing text → 400', async () => { | |
| 202 | + const cid = 'c-route-fb-empty-' + Math.random().toString(36).slice(2, 7); | |
| 203 | + cids.push(cid); | |
| 204 | + const r = await fetch(`${base}/api/devloop/projects`, { | |
| 205 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 206 | + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), | |
| 207 | + }); | |
| 208 | + const pid = (await r.json()).pid; | |
| 209 | + const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { | |
| 210 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', | |
| 211 | + }); | |
| 212 | + expect(fb.status).toBe(400); | |
| 213 | + }); | |
| 214 | + | |
| 215 | + it('POST /abandon → status=abandoned', async () => { | |
| 216 | + const cid = 'c-route-abandon-' + Math.random().toString(36).slice(2, 7); | |
| 217 | + cids.push(cid); | |
| 218 | + const r = await fetch(`${base}/api/devloop/projects`, { | |
| 219 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 220 | + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), | |
| 221 | + }); | |
| 222 | + const pid = (await r.json()).pid; | |
| 223 | + const ab = await fetch(`${base}/api/devloop/projects/${pid}/abandon`, { | |
| 224 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 225 | + body: JSON.stringify({ by: 'yibo', reason: 'test' }), | |
| 226 | + }); | |
| 227 | + expect(ab.status).toBe(200); | |
| 228 | + const j = await ab.json(); | |
| 229 | + expect(j.status).toBe('abandoned'); | |
| 230 | + }); | |
| 231 | + | |
| 232 | + it('POST /sign-off / abandon / feedback on unknown pid → 404', async () => { | |
| 233 | + for (const ep of ['sign-off', 'abandon', 'feedback']) { | |
| 234 | + const r = await fetch(`${base}/api/devloop/projects/p-nope/${ep}`, { | |
| 235 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 236 | + body: JSON.stringify(ep === 'feedback' ? { text: 'x' } : {}), | |
| 237 | + }); | |
| 238 | + expect(r.status).toBe(404); | |
| 239 | + } | |
| 240 | + }); | |
| 241 | +}); | ... | ... |
-
mentioned in commit 694ca3a6d27f55dc75dccb0ca2bdd47fff4780a2