@@ -68,6 +95,10 @@ async function fetchState(pid) {
function render(s) {
$('pidShow').textContent = s.pid;
$('rootShow').textContent = s.root_path || '';
+ const status = s.status || s.project?.status || 'unknown';
+ const sb = $('statusBadge');
+ sb.textContent = status;
+ sb.className = 'status-badge ' + status;
$('prdRev').textContent = s.prd_rev ?? '-';
$('sddRev').textContent = s.sdd_rev ?? '-';
const units = s.units || [];
@@ -81,6 +112,11 @@ function render(s) {
$('timeline').innerHTML = (s.timeline_tail || []).map(e => `
${e.ts || ''}[${e.kind}]${e.summary || ''}
`).join('');
+
+ // 控制按钮可点性
+ $('signoffBtn').disabled = status !== 'shipped';
+ $('abandonBtn').disabled = status === 'shipped-final' || status === 'abandoned';
+ $('feedbackBtn').disabled = status === 'shipped-final' || status === 'abandoned';
}
async function connect() {
@@ -104,6 +140,45 @@ async function connect() {
$('connectBtn').addEventListener('click', connect);
$('pidInput').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
+
+async function postAction(endpoint, body) {
+ const pid = $('pidInput').value.trim();
+ if (!pid) return;
+ $('opMsg').textContent = '...';
+ try {
+ const r = await fetch(`/api/devloop/projects/${pid}/${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body || {}),
+ });
+ const j = await r.json();
+ if (!r.ok) {
+ $('opMsg').textContent = `error: ${j.error || r.status}`;
+ $('opMsg').className = 'err';
+ } else {
+ $('opMsg').textContent = `${endpoint} ok · status=${j.status || j.pid || ''}`;
+ $('opMsg').className = 'ok-flash';
+ }
+ } catch (e) {
+ $('opMsg').textContent = 'network error: ' + e.message;
+ $('opMsg').className = 'err';
+ }
+ // poll once immediately to refresh
+ const s = await fetchState($('pidInput').value.trim()); render(s);
+}
+
+$('signoffBtn').addEventListener('click', () => postAction('sign-off', { by: 'human' }));
+$('abandonBtn').addEventListener('click', () => {
+ if (!confirm('Abandon this project? (irreversible)')) return;
+ postAction('abandon', { by: 'human', reason: 'user clicked abandon' });
+});
+$('feedbackBtn').addEventListener('click', () => {
+ const text = $('feedbackText').value.trim();
+ if (!text) { $('opMsg').textContent = 'feedback text required'; $('opMsg').className='err'; return; }
+ postAction('feedback', { text, by: 'human' });
+ $('feedbackText').value = '';
+});
+
const fromQuery = new URLSearchParams(location.search).get('pid');
if (fromQuery) { $('pidInput').value = fromQuery; connect(); }
diff --git a/server/routes/devloop.js b/server/routes/devloop.js
index 297f894..392c6c3 100644
--- a/server/routes/devloop.js
+++ b/server/routes/devloop.js
@@ -33,7 +33,7 @@ const h = (fn) => async (req, res) => {
// POST /api/devloop/projects
router.post('/projects', h(async (req, res) => {
- const { company_id, requirement, mode, target_path, mock, name } = req.body || {};
+ const { company_id, requirement, mode, target_path, mock, name, mockFixtureDir } = req.body || {};
if (!company_id) {
return res.status(400).json({ error: 'missing field: company_id' });
}
@@ -49,9 +49,10 @@ router.post('/projects', h(async (req, res) => {
idea: name ? `${name}: ${requirement}` : requirement,
});
- // 2. PM → Architect (mock 模式)
+ // 2. PM → Architect (mock 模式) — mockFixtureDir 可选,P3/P4 scenario 测试用
const phase = await loopController.runDocsPhase(proj.pid, requirement, {
mock: mock !== false,
+ mockFixtureDir,
});
res.json({
@@ -137,6 +138,61 @@ router.get('/projects/:pid/stream', (req, res) => {
});
});
+// P4: POST /api/devloop/projects/:pid/sign-off
+// body: { by?, note? }
+// 400 if status !== 'shipped'
+router.post('/projects/:pid/sign-off', h(async (req, res) => {
+ const { pid } = req.params;
+ try { pathResolver.resolveProjectRoot(pid); }
+ catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
+ try {
+ const meta = await loopController.signOff(pid, req.body || {});
+ res.json({ ok: true, pid, status: meta.status });
+ } catch (e) {
+ if (e.name === 'InvalidProjectStatusTransition') {
+ return res.status(400).json({ error: e.message, from: e.from, to: e.to });
+ }
+ throw e;
+ }
+}));
+
+// P4: POST /api/devloop/projects/:pid/abandon
+// body: { by?, reason?, note? }
+router.post('/projects/:pid/abandon', h(async (req, res) => {
+ const { pid } = req.params;
+ try { pathResolver.resolveProjectRoot(pid); }
+ catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
+ try {
+ const meta = await loopController.abandon(pid, req.body || {});
+ res.json({ ok: true, pid, status: meta.status });
+ } catch (e) {
+ if (e.name === 'InvalidProjectStatusTransition') {
+ return res.status(400).json({ error: e.message, from: e.from, to: e.to });
+ }
+ throw e;
+ }
+}));
+
+// P4: POST /api/devloop/projects/:pid/feedback
+// body: { text (required), by?, mockFixtureDir? }
+// 400 if status ∈ {shipped-final, abandoned}
+router.post('/projects/:pid/feedback', h(async (req, res) => {
+ const { pid } = req.params;
+ const { text, by, mockFixtureDir } = req.body || {};
+ if (!text) return res.status(400).json({ error: 'missing field: text' });
+ try { pathResolver.resolveProjectRoot(pid); }
+ catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
+ try {
+ const r = await loopController.submitFeedback(pid, text, { by, mockFixtureDir });
+ res.json(r);
+ } catch (e) {
+ if (e.name === 'InvalidProjectStatusTransition') {
+ return res.status(400).json({ error: e.message, from: e.from, to: e.to });
+ }
+ throw e;
+ }
+}));
+
// GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be
router.get('/projects/:pid/bugs', h(async (req, res) => {
const { pid } = req.params;
diff --git a/server/src/core/devloop/loop-controller.js b/server/src/core/devloop/loop-controller.js
index faaaf43..00038cd 100644
--- a/server/src/core/devloop/loop-controller.js
+++ b/server/src/core/devloop/loop-controller.js
@@ -51,13 +51,15 @@ async function createProject({ company_id, mode = 'sandbox', target_path, idea =
company_id, mode, target_path,
});
- // project.json
+ // project.json (含 P4 status FSM 字段)
const meta = {
pid,
company_id,
mode,
created_at: new Date().toISOString(),
idea_summary: (idea || '').slice(0, 200),
+ status: 'draft', // FSM: draft → running → shipped → shipped-final | abandoned
+ status_updated_at: new Date().toISOString(),
};
fs.writeFileSync(
pathResolver.resolveProjectJson(pid),
@@ -293,6 +295,9 @@ async function runDocsPhase(pid, requirement, opts = {}) {
});
} catch { /* swallow */ }
+ // P4: 项目进入 running 态(完成 docs 后,准备进 L1/L2)
+ _setProjectStatus(pid, 'running', { reason: 'docs_complete' });
+
return { ok: true, pm, architect };
}
@@ -311,7 +316,10 @@ function getProjectState(pid) {
const projectJson = pathResolver.resolveProjectJson(pid);
if (fs.existsSync(projectJson)) {
- try { out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); } catch {}
+ try {
+ out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8'));
+ out.status = out.project.status || 'unknown';
+ } catch {}
}
const unitsJson = pathResolver.resolveUnitsJson(pid);
@@ -579,6 +587,7 @@ async function runProject(pid, opts = {}) {
history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts });
if (!r.ok) {
sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts });
+ _safeSetStatus(pid, 'l2_blocked', { reason: `unit ${uid} blocked` });
return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` };
}
}
@@ -587,8 +596,8 @@ async function runProject(pid, opts = {}) {
// ── (2) L2 gate ────────────────────────────────────────────
if (!unitRegistry.l2GateOpen(pid)) {
- // 还有非 passed 的 unit (e.g. blocked) → 视为 blocked
sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' });
+ _safeSetStatus(pid, 'l2_blocked', { reason: 'l2GateClosed' });
return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' };
}
@@ -617,6 +626,7 @@ async function runProject(pid, opts = {}) {
summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`,
});
} catch {}
+ _setProjectStatus(pid, 'shipped', { run_id: runId });
return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId };
}
@@ -647,6 +657,7 @@ async function runProject(pid, opts = {}) {
if (!triage) {
sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId });
+ _safeSetStatus(pid, 'l2_blocked', { reason: 'triage_missing' });
return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' };
}
@@ -659,6 +670,7 @@ async function runProject(pid, opts = {}) {
reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous',
run_id: runId, category, confidence: triage.confidence,
});
+ _safeSetStatus(pid, 'l2_blocked', { reason: lowConfidence ? 'low_confidence' : 'spec_ambiguous' });
return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` };
}
@@ -673,6 +685,7 @@ async function runProject(pid, opts = {}) {
const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u));
if (affected.length === 0) {
sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId });
+ _safeSetStatus(pid, 'l2_blocked', { reason: 'impl_bug_no_affected' });
return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history };
}
unitRegistry.reset(pid, affected);
@@ -688,6 +701,7 @@ async function runProject(pid, opts = {}) {
const l2Budget = _l2Budget(pid);
if (currentRounds + 1 > l2Budget) {
sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget });
+ _safeSetStatus(pid, 'l2_exhausted', { reason: 'budget_exceeded' });
return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history };
}
const oldSddRev = _readSddRev(pid);
@@ -714,14 +728,25 @@ async function runProject(pid, opts = {}) {
// Unknown category
sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category });
+ _safeSetStatus(pid, 'l2_blocked', { reason: 'unknown_category' });
return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` };
}
// safety net 超过
sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations });
+ _safeSetStatus(pid, 'l2_exhausted', { reason: 'safety_net' });
return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history };
}
+/** Status set that swallows invalid-transition errors (already in target state etc.) */
+function _safeSetStatus(pid, to, extra) {
+ try { return _setProjectStatus(pid, to, extra); }
+ catch (e) {
+ if (e.name === 'InvalidProjectStatusTransition') return null;
+ throw e;
+ }
+}
+
function _l2Rounds(pid) {
const u = docStore.readUnits(pid);
return u?.l2_rounds || 0;
@@ -745,6 +770,170 @@ function _readSddRev(pid) {
return 1;
}
+// ──────────────────────────────────────────────────────────────
+// P4 · L3 Human Feedback · status FSM + signOff / abandon / feedback
+// ──────────────────────────────────────────────────────────────
+
+const STATUS_TRANSITIONS = {
+ draft: ['running', 'abandoned'],
+ running: ['shipped', 'l2_blocked', 'l2_exhausted', 'abandoned'],
+ l2_blocked: ['running', 'abandoned'],
+ l2_exhausted: ['running', 'abandoned'],
+ shipped: ['running', 'shipped-final', 'abandoned'],
+ 'shipped-final': [], // 终态
+ abandoned: [], // 终态
+};
+
+class InvalidProjectStatusTransition extends Error {
+ constructor(from, to) {
+ super(`Invalid project status transition: ${from} → ${to}`);
+ this.name = 'InvalidProjectStatusTransition';
+ this.from = from;
+ this.to = to;
+ }
+}
+
+function _readProjectMeta(pid) {
+ const p = pathResolver.resolveProjectJson(pid);
+ if (!fs.existsSync(p)) throw new Error(`project.json missing for ${pid}`);
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
+}
+
+function _writeProjectMeta(pid, meta) {
+ fs.writeFileSync(pathResolver.resolveProjectJson(pid), JSON.stringify(meta, null, 2));
+}
+
+/**
+ * 改 project.json.status,做 FSM 校验 + 发 SSE + 写 timeline。
+ * 非法转换抛 InvalidProjectStatusTransition (供路由层映射 400)。
+ */
+function _setProjectStatus(pid, to, extra = {}) {
+ const meta = _readProjectMeta(pid);
+ const from = meta.status || 'draft';
+ if (from === to) return meta; // idempotent
+ const allowed = STATUS_TRANSITIONS[from] || [];
+ if (!allowed.includes(to)) {
+ throw new InvalidProjectStatusTransition(from, to);
+ }
+ meta.status = to;
+ meta.status_updated_at = new Date().toISOString();
+ if (extra.note) meta.status_note = extra.note;
+ _writeProjectMeta(pid, meta);
+
+ try {
+ sseBroker.emit(pid, 'project.status', { from, to, ...extra });
+ timelineLogger.append(pid, {
+ source: extra.by || 'system',
+ kind: 'project.status',
+ ref: pid,
+ summary: `Project status ${from} → ${to}${extra.reason ? ` (${extra.reason})` : ''}`,
+ payload: { from, to, ...extra },
+ });
+ } catch { /* swallow */ }
+ return meta;
+}
+
+/**
+ * Sign Off — 把已 shipped 的项目锁为 shipped-final。
+ * 之后再发 feedback 会被拒绝。
+ */
+async function signOff(pid, opts = {}) {
+ const meta = _readProjectMeta(pid);
+ if (meta.status !== 'shipped') {
+ throw new InvalidProjectStatusTransition(meta.status || 'unknown', 'shipped-final');
+ }
+ _setProjectStatus(pid, 'shipped-final', { by: opts.by || 'human', note: opts.note || 'signed off' });
+ // sign-off commit (留 git 痕)
+ try {
+ await gitVault.commit(pid, {
+ kind: 'chore',
+ summary: `chore: sign-off project ${pid}`,
+ author: { name: opts.by || 'human', email: 'human@devloop.local' },
+ });
+ } catch { /* swallow if nothing to commit */ }
+ return _readProjectMeta(pid);
+}
+
+/**
+ * Abandon — 任意状态 → abandoned。发 SSE close 帧关闭所有订阅。
+ */
+async function abandon(pid, opts = {}) {
+ const meta = _readProjectMeta(pid);
+ if (meta.status === 'shipped-final' || meta.status === 'abandoned') {
+ throw new InvalidProjectStatusTransition(meta.status, 'abandoned');
+ }
+ _setProjectStatus(pid, 'abandoned', { by: opts.by || 'human', reason: opts.reason || 'manual abandon', note: opts.note });
+ try {
+ await gitVault.commit(pid, {
+ kind: 'chore',
+ summary: `chore: abandon project ${pid}${opts.reason ? ' · ' + opts.reason : ''}`,
+ author: { name: opts.by || 'human', email: 'human@devloop.local' },
+ });
+ } catch { /* swallow */ }
+ // 关闭所有 SSE 订阅(close 帧)
+ try { sseBroker.close(pid); } catch { /* ignore */ }
+ return _readProjectMeta(pid);
+}
+
+/**
+ * Submit human feedback — 触发 PM incremental 模式。
+ * 仅当 status ∈ {shipped, running, l2_blocked, l2_exhausted} 时允许。
+ * shipped-final / abandoned → 拒绝。
+ */
+async function submitFeedback(pid, text, opts = {}) {
+ if (!text || typeof text !== 'string') {
+ throw new Error('feedback text required');
+ }
+ const meta = _readProjectMeta(pid);
+ if (meta.status === 'shipped-final' || meta.status === 'abandoned') {
+ throw new InvalidProjectStatusTransition(meta.status, 'feedback');
+ }
+
+ const prdRevBefore = _readPrdRev(pid);
+ const pm = await callAgent(pid, 'devloop-pm',
+ `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.`,
+ { mock: opts.mock !== false, mockFixtureDir: opts.mockFixtureDir },
+ );
+ const prdRevAfter = _readPrdRev(pid);
+
+ // 若 PRD rev bumped → 写 changelog
+ if (prdRevAfter > prdRevBefore) {
+ try {
+ changelogWriter.append(pid, {
+ kind: 'prd_rev',
+ rev: prdRevAfter,
+ by: opts.by || 'human',
+ trigger: 'human_feedback',
+ summary: `PRD bumped to rev ${prdRevAfter} via human feedback`,
+ diff: { feedback_excerpt: text.slice(0, 120) },
+ });
+ sseBroker.emit(pid, 'prd.updated', { rev: prdRevAfter, summary: 'human feedback applied' });
+ } catch { /* swallow */ }
+ }
+
+ // 若当前是 shipped, 改回 running (人类反馈触发新一轮)
+ if (meta.status === 'shipped') {
+ _setProjectStatus(pid, 'running', { reason: 'human_feedback' });
+ }
+
+ return {
+ ok: true,
+ pid,
+ files_written: pm.files_written,
+ prd_rev_before: prdRevBefore,
+ prd_rev_after: prdRevAfter,
+ status: _readProjectMeta(pid).status,
+ };
+}
+
+function _readPrdRev(pid) {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const idx = path.join(root, 'prd', 'index.html');
+ if (!fs.existsSync(idx)) return 0;
+ const m = fs.readFileSync(idx, 'utf8').match(/
]*data-doc="prd"[^>]*data-rev="(\d+)"/);
+ return m ? parseInt(m[1], 10) : 1;
+}
+
module.exports = {
createProject,
commitAfterAgent,
@@ -753,4 +942,9 @@ module.exports = {
runUnit,
runProject,
getProjectState,
+ // P4
+ signOff,
+ abandon,
+ submitFeedback,
+ InvalidProjectStatusTransition,
};
diff --git a/server/tests/devloop/p4-l3-feedback.test.js b/server/tests/devloop/p4-l3-feedback.test.js
new file mode 100644
index 0000000..a5b1b5a
--- /dev/null
+++ b/server/tests/devloop/p4-l3-feedback.test.js
@@ -0,0 +1,241 @@
+/**
+ * P4 · L3 Human Feedback tests
+ *
+ * 覆盖:
+ * - project.status FSM: draft → running → shipped → shipped-final
+ * - submitFeedback bumps prd_rev + shipped → running
+ * - signOff requires status=shipped
+ * - abandon any active state → abandoned (emits close)
+ * - InvalidProjectStatusTransition error mapping
+ * - PM incremental fixture variant runs on 2nd call
+ */
+
+process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-p4-'));
+process.env.HOME = TEST_HOME;
+
+const pathResolver = require('../../services/devloop/path-resolver');
+const sseBroker = require('../../services/devloop/sse-broker');
+const lc = require('../../src/core/devloop/loop-controller');
+
+const HAPPY_DIR = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios/happy_path');
+
+async function fresh(idea = 'p4 test') {
+ pathResolver._resetForTesting();
+ sseBroker._resetForTesting();
+ const cid = 'c-p4-' + Math.random().toString(36).slice(2, 7);
+ const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea });
+ return { pid: proj.pid, cid };
+}
+
+function cleanup(cid) {
+ try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); }
+ catch { /* ignore */ }
+}
+
+describe('P4 · L3 status FSM', () => {
+ describe('happy-path: draft → running → shipped → shipped-final', () => {
+ let pid, cid;
+ beforeAll(async () => { ({ pid, cid } = await fresh()); });
+ afterAll(() => cleanup(cid));
+
+ it('starts as draft', () => {
+ expect(lc.getProjectState(pid).status).toBe('draft');
+ });
+
+ it('runDocsPhase → running', async () => {
+ await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR });
+ expect(lc.getProjectState(pid).status).toBe('running');
+ });
+
+ it('runProject → shipped', async () => {
+ const r = await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR });
+ expect(r.final_state).toBe('shipped');
+ expect(lc.getProjectState(pid).status).toBe('shipped');
+ });
+
+ it('feedback bumps prd_rev 1 → 2 and flips status shipped → running', async () => {
+ const r = await lc.submitFeedback(pid, 'add toggle done feature', { mockFixtureDir: HAPPY_DIR });
+ expect(r.prd_rev_after).toBe(2);
+ expect(r.prd_rev_before).toBe(1);
+ expect(r.status).toBe('running');
+ });
+
+ it('changelog has prd_rev=2 entry', () => {
+ const cl = pathResolver.resolveChangelog(pid);
+ expect(fs.existsSync(cl)).toBe(true);
+ const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+ expect(lines.some(l => l.kind === 'prd_rev' && l.rev === 2)).toBe(true);
+ });
+
+ it('runProject again → shipped', async () => {
+ await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR });
+ expect(lc.getProjectState(pid).status).toBe('shipped');
+ });
+
+ it('signOff → shipped-final', async () => {
+ const meta = await lc.signOff(pid, { by: 'yibo' });
+ expect(meta.status).toBe('shipped-final');
+ });
+
+ it('feedback after sign-off is rejected', async () => {
+ await expect(lc.submitFeedback(pid, 'late change', { mockFixtureDir: HAPPY_DIR }))
+ .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
+ });
+
+ it('signOff after sign-off is rejected', async () => {
+ await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
+ });
+ });
+
+ describe('abandon path', () => {
+ let pid, cid;
+ beforeAll(async () => { ({ pid, cid } = await fresh('abandon test')); });
+ afterAll(() => cleanup(cid));
+
+ it('abandon from draft state succeeds', async () => {
+ const m = await lc.abandon(pid, { by: 'yibo', reason: 'no longer needed' });
+ expect(m.status).toBe('abandoned');
+ });
+
+ it('abandon after abandon is rejected', async () => {
+ await expect(lc.abandon(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
+ });
+
+ it('feedback on abandoned project is rejected', async () => {
+ await expect(lc.submitFeedback(pid, 'x', { mockFixtureDir: HAPPY_DIR }))
+ .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
+ });
+ });
+
+ describe('signOff requires shipped (not running)', () => {
+ let pid, cid;
+ beforeAll(async () => {
+ ({ pid, cid } = await fresh('not yet shipped'));
+ await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR });
+ // 不跑 runProject,status 还是 running
+ });
+ afterAll(() => cleanup(cid));
+
+ it('signOff from running fails', async () => {
+ await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition', from: 'running', to: 'shipped-final' });
+ });
+ });
+
+ describe('feedback empty text rejected', () => {
+ let pid, cid;
+ beforeAll(async () => { ({ pid, cid } = await fresh('empty feedback')); });
+ afterAll(() => cleanup(cid));
+
+ it('throws on empty/missing text', async () => {
+ await expect(lc.submitFeedback(pid, '')).rejects.toThrow(/text/);
+ await expect(lc.submitFeedback(pid, null)).rejects.toThrow(/text/);
+ });
+ });
+});
+
+describe('P4 · routes', () => {
+ let app, base;
+ const cids = [];
+
+ beforeAll(async () => {
+ const express = require('express');
+ const devloopRouter = require('../../routes/devloop');
+ pathResolver._resetForTesting();
+ sseBroker._resetForTesting();
+ const e = express();
+ e.use(express.json());
+ e.use('/api/devloop', devloopRouter);
+ await new Promise(r => {
+ app = e.listen(0, '127.0.0.1', () => {
+ base = `http://127.0.0.1:${app.address().port}`;
+ r();
+ });
+ });
+ });
+
+ afterAll(() => {
+ app.close();
+ for (const c of cids) cleanup(c);
+ });
+
+ it('POST /sign-off requires shipped status (400)', async () => {
+ const cid = 'c-route-signoff-' + Math.random().toString(36).slice(2, 7);
+ cids.push(cid);
+ const r = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
+ });
+ const j = await r.json();
+ // immediately after creation/run (full flow), check signoff
+ const r2 = await fetch(`${base}/api/devloop/projects/${j.pid}/sign-off`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
+ });
+ // After POST /projects, status is 'running' (not yet shipped via runProject)
+ // → signOff should 400
+ expect(r2.status).toBe(400);
+ });
+
+ it('POST /feedback bumps PRD rev', async () => {
+ const cid = 'c-route-feedback-' + Math.random().toString(36).slice(2, 7);
+ cids.push(cid);
+ const r = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
+ });
+ const pid = (await r.json()).pid;
+ const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text: 'add toggle', mockFixtureDir: HAPPY_DIR }),
+ });
+ expect(fb.status).toBe(200);
+ const j = await fb.json();
+ expect(j.prd_rev_after).toBeGreaterThan(j.prd_rev_before);
+ });
+
+ it('POST /feedback missing text → 400', async () => {
+ const cid = 'c-route-fb-empty-' + Math.random().toString(36).slice(2, 7);
+ cids.push(cid);
+ const r = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
+ });
+ const pid = (await r.json()).pid;
+ const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
+ });
+ expect(fb.status).toBe(400);
+ });
+
+ it('POST /abandon → status=abandoned', async () => {
+ const cid = 'c-route-abandon-' + Math.random().toString(36).slice(2, 7);
+ cids.push(cid);
+ const r = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
+ });
+ const pid = (await r.json()).pid;
+ const ab = await fetch(`${base}/api/devloop/projects/${pid}/abandon`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ by: 'yibo', reason: 'test' }),
+ });
+ expect(ab.status).toBe(200);
+ const j = await ab.json();
+ expect(j.status).toBe('abandoned');
+ });
+
+ it('POST /sign-off / abandon / feedback on unknown pid → 404', async () => {
+ for (const ep of ['sign-off', 'abandon', 'feedback']) {
+ const r = await fetch(`${base}/api/devloop/projects/p-nope/${ep}`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(ep === 'feedback' ? { text: 'x' } : {}),
+ });
+ expect(r.status).toBe(404);
+ }
+ });
+});