/** * DevLoop · REST Routes (P1) * * POST /api/devloop/projects * body: { company_id, name?, requirement, mode?, target_path?, mock? } * → 创建项目 + 跑 PM / Architect 两个 Agent (mock 模式产出 PRD/SDD) * → 返 { pid, root_path, pm: {...}, architect: {...} } * * GET /api/devloop/projects/:pid/state * → 返 { pid, project, units, prd_rev, sdd_rev, prd_site, sdd_site, timeline_tail } * * GET /api/devloop/projects (额外:列项目) * → 返 { projects: [...] } * * 规格: docs/prd/newfunction/_spec/plan-migration.md · P1 验收 */ const express = require('express'); const router = express.Router(); const loopController = require('../src/core/devloop/loop-controller'); const pathResolver = require('../services/devloop/path-resolver'); const sseBroker = require('../services/devloop/sse-broker'); const bugTracker = require('../services/devloop/bug-tracker'); const h = (fn) => async (req, res) => { try { await fn(req, res); } catch (e) { console.error(`[DevLoop] ${req.method} ${req.path}:`, e.message); res.status(e.statusCode || 500).json({ error: e.message }); } }; // POST /api/devloop/projects router.post('/projects', h(async (req, res) => { const { company_id, requirement, mode, target_path, mock, name } = req.body || {}; if (!company_id) { return res.status(400).json({ error: 'missing field: company_id' }); } if (!requirement || typeof requirement !== 'string') { return res.status(400).json({ error: 'missing field: requirement' }); } // 1. 创建项目 (sandbox 默认) const proj = await loopController.createProject({ company_id, mode: mode || 'sandbox', target_path, idea: name ? `${name}: ${requirement}` : requirement, }); // 2. PM → Architect (mock 模式) const phase = await loopController.runDocsPhase(proj.pid, requirement, { mock: mock !== false, }); res.json({ ok: true, pid: proj.pid, root_path: proj.root_path, mode: proj.mode, pm: { files_written: phase.pm.files_written.length, commit: phase.pm.commit?.sha || null, mock: phase.pm.mock, }, architect: { files_written: phase.architect.files_written.length, commit: phase.architect.commit?.sha || null, mock: phase.architect.mock, }, }); })); // GET /api/devloop/projects router.get('/projects', h(async (_req, res) => { const projects = pathResolver.listProjects(); res.json({ projects }); })); // POST /api/devloop/projects/:pid/run[?only=] [body: { mockFixtureDir? }] // - 不带 only: 跑完整 runProject (P3) // - only=: 单 unit drill-down (P2 兼容) router.post('/projects/:pid/run', h(async (req, res) => { const { pid } = req.params; const { only } = req.query; const { mockFixtureDir } = req.body || {}; try { if (only) { const result = await loopController.runUnit(pid, only, { mock: true, mockFixtureDir }); return res.json({ ok: result.ok, pid, unit_id: only, attempts: result.attempts, final_state: result.final_state, history: result.history.map(h => ({ agent: h.agent, attempt: h.attempt, ok: h.ok, files: h.files?.length || 0, commit: h.commit || null, })), }); } // 完整 runProject const result = await loopController.runProject(pid, { mock: true, mockFixtureDir }); return res.json({ ok: result.ok, pid, final_state: result.final_state, l2_rounds: result.l2_rounds, run_id: result.run_id || null, reason: result.reason || null, history: result.history, }); } catch (e) { if (/Unknown project/.test(e.message)) { return res.status(404).json({ error: 'unknown project: ' + pid }); } if (/not found in project/.test(e.message)) { return res.status(404).json({ error: 'unknown unit: ' + (only || '?') }); } throw e; } })); // GET /api/devloop/projects/:pid/stream — SSE // client may send Last-Event-ID header → broker replays from there router.get('/projects/:pid/stream', (req, res) => { const { pid } = req.params; try { pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders?.(); const lastEventId = req.headers['last-event-id'] || req.query.lastEventId || null; sseBroker.subscribe(pid, res, { replayFromOffset: lastEventId != null ? lastEventId : undefined, }); }); // 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; try { pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } const filter = {}; if (req.query.status) filter.status = String(req.query.status).split(','); if (req.query.unit_id) filter.unit_id = req.query.unit_id; if (req.query.severity) filter.severity = String(req.query.severity).split(','); const bugs = bugTracker.list(pid, filter); res.json({ bugs, total: bugs.length }); })); // GET /api/devloop/projects/:pid/state router.get('/projects/:pid/state', h(async (req, res) => { try { const state = loopController.getProjectState(req.params.pid); res.json(state); } catch (e) { if (/Unknown project/.test(e.message)) { return res.status(404).json({ error: 'unknown project: ' + req.params.pid }); } throw e; } })); module.exports = router;