/** * 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 { requireAuth } = require('../middleware/jwt-auth'); // 38 号文档 Phase 1:全站要求登录,公司归属过滤(经 :pid 反查 company_id)留到 Phase 2。 router.use(requireAuth); 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 fs = require('fs'); const os = require('os'); const path = require('path'); const multer = require('multer'); const { CompanyService, canAccessCompany } = require('../services/company-service'); const companyService = new CompanyService(); // 38 号文档 Phase 2:devloop 的路由不长 :companyId,长的是 :pid(项目 id)——公司归属藏在 // pathResolver 缓存里(resolveProjectMeta(pid).company_id),要反查一跳,不能照抄 // company.js 那种直接的 router.param('companyId', ...)。查不到项目本身就是 404 // (resolveProjectMeta 对未知 pid 抛错,这里接住转成 404);查得到但公司不是你的, // 同样回 404,不暴露"这个项目存在,只是不是你的"。 router.param('pid', (req, res, next, pid) => { let meta; try { meta = pathResolver.resolveProjectMeta(pid); } catch { return res.status(404).json({ error: `项目 ${pid} 不存在` }); } const company = companyService.get(meta.company_id); if (!canAccessCompany(company, req.user)) { return res.status(404).json({ error: `项目 ${pid} 不存在` }); } req.devloopProjectMeta = meta; next(); }); // 单次 intake 上传:内存解析后我们自己写盘到项目目录。10MB / 文件,10 文件上限。 const intakeUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024, files: 10 }, }); 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, mockFixtureDir } = 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' }); } if ((mode === 'linked-new' || mode === 'linked-existing') && !target_path) { return res.status(400).json({ error: `missing field: target_path (required for ${mode})` }); } if (mode && !['sandbox', 'linked-new', 'linked-existing'].includes(mode)) { return res.status(400).json({ error: `invalid mode: ${mode} (must be sandbox | linked-new | linked-existing)` }); } // 这条路由建的是全新项目,还没有 :pid 可供 router.param 拦截——company_id 是从 // body 里点名的,不校验的话任何登录用户都能把 devloop 项目挂到别人的公司名下。 // 放在基本输入校验之后:先把纯输入形状的问题过一遍(便宜、不碰 fs), // 再做需要读盘的归属校验。 if (!canAccessCompany(companyService.get(company_id), req.user)) { return res.status(404).json({ error: `公司 ${company_id} 不存在` }); } // 1. 创建项目 (sandbox 默认 / linked-new 用 target_path) let proj; try { proj = await loopController.createProject({ company_id, mode: mode || 'sandbox', target_path, idea: name ? `${name}: ${requirement}` : requirement, }); } catch (e) { // 用户输入级错误返 400;其他保留 500 if (e.code === 'SENSITIVE_PATH' || e.code === 'DIRTY_WORKTREE' || /target_path|parent dir|not empty|must be absolute|does not exist|not a directory|already contains|uncommitted changes/i.test(e.message)) { return res.status(400).json({ error: e.message }); } throw e; } // 2. 把 name 作为一等字段写入 project.json (供列表 / 改名共用) if (name) { try { pathResolver.patchProjectMeta(proj.pid, { name }); } catch (e) { console.warn('[DevLoop] failed to set name:', e.message); } } // 3. PM → Architect (mock 模式) — mockFixtureDir 可选,P3/P4 scenario 测试用 const phase = await loopController.runDocsPhase(proj.pid, requirement, { mock: mock !== false, mockFixtureDir, }); 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?include_archived=true —— 只列 req.user 自己公司名下的项目 router.get('/projects', h(async (req, res) => { const includeArchived = String(req.query.include_archived || '') === 'true'; const projects = pathResolver.listProjects({ includeArchived }) .filter((p) => canAccessCompany(companyService.get(p.company_id), req.user)); res.json({ projects }); })); // GET /api/devloop/fs/browse?path=/abs/path // 列出指定绝对路径下的子目录(供新建项目时的"指定本机目录"选择用)。 // - 不传 path → 用 $HOME // - 拒绝 sensitive 路径 // - 仅返回目录,不返回文件 router.get('/fs/browse', h(async (req, res) => { let p = req.query.path ? String(req.query.path) : os.homedir(); if (!path.isAbsolute(p)) { return res.status(400).json({ error: 'path must be absolute' }); } p = path.resolve(p); if (pathResolver.isSensitivePath(p)) { return res.status(403).json({ error: `sensitive_path: ${p} is blacklisted` }); } if (!fs.existsSync(p)) { return res.status(404).json({ error: `not found: ${p}` }); } const stat = fs.statSync(p); if (!stat.isDirectory()) { return res.status(400).json({ error: `not a directory: ${p}` }); } let entries = []; try { entries = fs.readdirSync(p, { withFileTypes: true }) .filter(d => d.isDirectory() && !d.name.startsWith('.')) .map(d => { const abs = path.join(p, d.name); let is_empty = false; try { is_empty = fs.readdirSync(abs).length === 0; } catch { /* permission denied 当作非空 */ } return { name: d.name, abs_path: abs, is_empty }; }) .sort((a, b) => a.name.localeCompare(b.name)); } catch (e) { return res.status(403).json({ error: `cannot read: ${e.message}` }); } const parent = path.dirname(p); res.json({ path: p, parent: parent === p ? null : parent, // 根目录 parent === p home: os.homedir(), entries, }); })); // PATCH /api/devloop/projects/:pid // body: { name?, idea_summary? } 仅允许这两个字段(status 走 sign-off / abandon / feedback) router.patch('/projects/:pid', h(async (req, res) => { const { pid } = req.params; try { pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } const allowed = ['name', 'idea_summary']; const patch = {}; for (const k of allowed) { if (req.body && Object.prototype.hasOwnProperty.call(req.body, k)) { const v = req.body[k]; if (typeof v !== 'string') return res.status(400).json({ error: `${k} must be string` }); patch[k] = v.slice(0, 500); } } if (Object.keys(patch).length === 0) { return res.status(400).json({ error: 'no editable fields in body (allowed: name, idea_summary)' }); } const meta = pathResolver.patchProjectMeta(pid, patch); res.json({ ok: true, pid, project: meta }); })); // DELETE /api/devloop/projects/:pid?hard=true // 默认软删 (archived=true);hard=true 时递归删目录 (sandbox) 或解注册 (linked) router.delete('/projects/:pid', h(async (req, res) => { const { pid } = req.params; const hard = String(req.query.hard || '') === 'true'; try { pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } if (hard) { const r = pathResolver.hardDeleteProject(pid); return res.json({ ok: true, pid, hard: true, ...r }); } const meta = pathResolver.patchProjectMeta(pid, { archived: true, archived_at: new Date().toISOString(), }); res.json({ ok: true, pid, hard: false, archived: true, project: meta }); })); // POST /api/devloop/projects/:pid/restore — 把 archived=true 的项目恢复 router.post('/projects/:pid/restore', h(async (req, res) => { const { pid } = req.params; try { pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } const meta = pathResolver.patchProjectMeta(pid, { archived: false, archived_at: null }); res.json({ ok: true, pid, archived: false, project: meta }); })); // 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, }); }); // 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; 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 }); })); // P5: GET /api/devloop/projects/:pid/timeline?limit=200 // 读 timeline.ndjson 最近 N 条 (按时间倒序),给 LoopLogView 用 router.get('/projects/:pid/timeline', h(async (req, res) => { const fs = require('fs'); const { pid } = req.params; try { pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } const limit = Math.max(1, Math.min(2000, parseInt(req.query.limit, 10) || 200)); const p = pathResolver.resolveTimelineLog(pid); if (!fs.existsSync(p)) return res.json({ events: [], total: 0 }); const lines = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean); const events = lines.slice(-limit).reverse().map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); res.json({ events, total: events.length }); })); // P5: GET /api/devloop/projects/:pid/changelog // 读 changelog.ndjson 全部 (按时间倒序) router.get('/projects/:pid/changelog', h(async (req, res) => { const fs = require('fs'); const { pid } = req.params; try { pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } const p = pathResolver.resolveChangelog(pid); if (!fs.existsSync(p)) return res.json({ entries: [], total: 0 }); const entries = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean) .map(l => { try { return JSON.parse(l); } catch { return null; }}).filter(Boolean) .reverse(); res.json({ entries, total: entries.length }); })); // P5: GET /api/devloop/projects/:pid/units/:unitId // 返回 unit 元数据 + 最近 N 次 runs (Tester result.json) router.get('/projects/:pid/units/:unitId', h(async (req, res) => { const fs = require('fs'); const path = require('path'); const { pid, unitId } = req.params; try { pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } const unitDir = pathResolver.resolveUnitDir(pid, unitId); if (!fs.existsSync(unitDir)) return res.status(404).json({ error: 'unknown unit: ' + unitId }); // 从 units.json 找 unit 元数据 let unitMeta = null; try { const uj = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); unitMeta = (uj.units || []).find(u => u.id === unitId) || null; } catch { /* ignore */ } // runs const runsDir = path.join(unitDir, 'runs'); let runs = []; if (fs.existsSync(runsDir)) { const ids = fs.readdirSync(runsDir).filter(n => { try { return fs.statSync(path.join(runsDir, n)).isDirectory(); } catch { return false; } }).sort().reverse().slice(0, 3); for (const id of ids) { const rp = path.join(runsDir, id, 'result.json'); let r = null; if (fs.existsSync(rp)) { try { r = JSON.parse(fs.readFileSync(rp, 'utf8')); } catch {} } runs.push({ run_id: id, result: r }); } } // coder.log 最后 1KB 节选 let coderLog = null; const clp = path.join(unitDir, 'coder.log'); if (fs.existsSync(clp)) { const raw = fs.readFileSync(clp, 'utf8'); coderLog = raw.length > 1024 ? raw.slice(-1024) : raw; } res.json({ unit: unitMeta, runs, coder_log_tail: coderLog }); })); // 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; } })); // ───────────────────────────────────────────────────────────────────────────── // P6 · Requirements 列表 + 新增需求 intake (file upload / dir scan / chat draft) // ───────────────────────────────────────────────────────────────────────────── // GET /api/devloop/projects/:pid/requirements // 返回需求列表:rev=1 为初始需求(取自 project.json.idea_summary), // rev>=2 来自 changelog.ndjson 中 kind=prd_rev 的条目。 router.get('/projects/:pid/requirements', h(async (req, res) => { const { pid } = req.params; let projectMeta; try { projectMeta = JSON.parse(fs.readFileSync(pathResolver.resolveProjectJson(pid), 'utf8')); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } const items = []; // rev 1: 初始需求 items.push({ rev: 1, ts: projectMeta.created_at, by: 'init', trigger: 'project_create', text: projectMeta.idea_summary || '', }); // rev>=2: from changelog const clp = pathResolver.resolveChangelog(pid); if (fs.existsSync(clp)) { const lines = fs.readFileSync(clp, 'utf8').trim().split('\n').filter(Boolean); for (const l of lines) { let e; try { e = JSON.parse(l); } catch { continue; } if (e.kind !== 'prd_rev') continue; items.push({ rev: e.rev, ts: e.ts, by: e.by || 'human', trigger: e.trigger || 'human_feedback', text: e.diff?.feedback_excerpt || e.summary || '', }); } } // 派生 status: 最新一条 = current(用项目状态),其余 = older items.sort((a, b) => a.rev - b.rev); const latestRev = items.length ? items[items.length - 1].rev : 0; const projectStatus = projectMeta.status || 'draft'; for (const it of items) { it.status = it.rev === latestRev ? projectStatus : 'older'; it.is_current = it.rev === latestRev; } res.json({ pid, project_status: projectStatus, current_rev: latestRev, items: items.reverse(), // 倒序:最新在前 }); })); // POST /api/devloop/projects/:pid/intake/files // multipart/form-data: files=...; (.md / .txt / .html 才被采纳,其它跳过) // 保存到 projects/{pid}/intake/{ts}/,返回每个文件的提取文本。 router.post('/projects/:pid/intake/files', intakeUpload.array('files', 10), h(async (req, res) => { const { pid } = req.params; let root; try { root = pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } const ts = new Date().toISOString().replace(/[:.]/g, '-'); const intakeDir = path.join(root, 'intake', ts); fs.mkdirSync(intakeDir, { recursive: true }); const files = req.files || []; const results = []; for (const f of files) { const ext = path.extname(f.originalname).toLowerCase(); if (!['.md', '.txt', '.html', '.htm'].includes(ext)) { results.push({ name: f.originalname, skipped: true, reason: `unsupported ext: ${ext}` }); continue; } const safeName = f.originalname.replace(/[/\\]/g, '_').slice(0, 200); const abs = path.join(intakeDir, safeName); fs.writeFileSync(abs, f.buffer); let text = f.buffer.toString('utf8'); if (ext === '.html' || ext === '.htm') { // 极简 strip:去脚本/样式块 + 拆 tag text = text .replace(//gi, '') .replace(//gi, '') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); } results.push({ name: f.originalname, size: f.size, saved_to: path.relative(root, abs), text, }); } res.json({ ok: true, intake_dir: path.relative(root, intakeDir), files: results }); }) ); // POST /api/devloop/projects/:pid/intake/scan-dir // body: { path: '/abs/dir', max_files?: 50, max_bytes?: 1MB } // 递归找 md/txt/html,读文本。受 sensitive 黑名单拦截。 router.post('/projects/:pid/intake/scan-dir', h(async (req, res) => { const { pid } = req.params; try { pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } const target = req.body?.path && String(req.body.path); const maxFiles = Math.min(200, parseInt(req.body?.max_files, 10) || 50); const maxBytes = Math.min(5 * 1024 * 1024, parseInt(req.body?.max_bytes, 10) || 1024 * 1024); if (!target || !path.isAbsolute(target)) { return res.status(400).json({ error: 'path required (absolute)' }); } if (pathResolver.isSensitivePath(target)) { return res.status(403).json({ error: `sensitive_path: ${target}` }); } if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) { return res.status(404).json({ error: `not a directory: ${target}` }); } const found = []; let totalBytes = 0; let truncated = false; function walk(dir, depth) { if (depth > 6 || found.length >= maxFiles || truncated) return; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { if (e.name.startsWith('.') || e.name === 'node_modules') continue; const full = path.join(dir, e.name); if (e.isDirectory()) { walk(full, depth + 1); continue; } if (!e.isFile()) continue; const ext = path.extname(e.name).toLowerCase(); if (!['.md', '.txt', '.html', '.htm'].includes(ext)) continue; const stat = fs.statSync(full); if (totalBytes + stat.size > maxBytes) { truncated = true; return; } let text; try { text = fs.readFileSync(full, 'utf8'); } catch { continue; } if (ext === '.html' || ext === '.htm') { text = text.replace(//gi, '') .replace(//gi, '') .replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } totalBytes += stat.size; found.push({ path: full, rel: path.relative(target, full), size: stat.size, text }); if (found.length >= maxFiles) { truncated = true; return; } } } walk(target, 0); res.json({ ok: true, scanned_root: target, total_bytes: totalBytes, truncated, files: found }); })); // POST /api/devloop/projects/:pid/draft-chat // body: { messages: [{role:'user'|'assistant', content:string}, ...] } // 返回 { role:'assistant', content:string } // 占位实现:原样回显最后一条 user 消息 + 提示。LLM 接入后此函数体内换实现即可。 router.post('/projects/:pid/draft-chat', h(async (req, res) => { const { pid } = req.params; try { pathResolver.resolveProjectRoot(pid); } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } const messages = Array.isArray(req.body?.messages) ? req.body.messages : []; const lastUser = [...messages].reverse().find(m => m.role === 'user'); const echoText = lastUser?.content?.toString().trim() || '(空消息)'; const reply = `已收到你的描述(共 ${echoText.length} 字符):\n\n> ${echoText.slice(0, 200)}${echoText.length > 200 ? '…' : ''}\n\n(LLM 多轮调试暂未接入。请继续追加细节、上传文档、或扫描代码目录,然后点"提交需求"一次性发给 PM Agent。)`; res.json({ role: 'assistant', content: reply, llm_stub: true }); })); module.exports = router;