From 694ca3a6d27f55dc75dccb0ca2bdd47fff4780a2 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 13 May 2026 18:28:12 +0800 Subject: [PATCH] feat(devloop): P5 Vue Dashboard — full UI per §P4 spec (12 components + 4 ops sub-pages + UI endpoints) --- scripts/phase5-verify.js | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/routes/devloop.js | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/devloop/routes.test.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/DashboardHeader.vue | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/FeedbackModal.vue | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/LiveAgentStream.vue | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/LoopStatusBar.vue | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/PrdDiffPanel.vue | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/UnitDrawer.vue | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/UnitsDAG.vue | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/ops/BuglistView.vue | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/ops/ChangelogView.vue | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/ops/LoopLogView.vue | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/devloop/ops/ProgressView.vue | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/router/index.js | 13 +++++++++++++ web/src/views/DevLoopDashboard.vue | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/views/DevLoopOps.vue | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 17 files changed, 1533 insertions(+), 0 deletions(-) create mode 100644 scripts/phase5-verify.js create mode 100644 web/src/components/devloop/DashboardHeader.vue create mode 100644 web/src/components/devloop/FeedbackModal.vue create mode 100644 web/src/components/devloop/LiveAgentStream.vue create mode 100644 web/src/components/devloop/LoopStatusBar.vue create mode 100644 web/src/components/devloop/PrdDiffPanel.vue create mode 100644 web/src/components/devloop/UnitDrawer.vue create mode 100644 web/src/components/devloop/UnitsDAG.vue create mode 100644 web/src/components/devloop/ops/BuglistView.vue create mode 100644 web/src/components/devloop/ops/ChangelogView.vue create mode 100644 web/src/components/devloop/ops/LoopLogView.vue create mode 100644 web/src/components/devloop/ops/ProgressView.vue create mode 100644 web/src/views/DevLoopDashboard.vue create mode 100644 web/src/views/DevLoopOps.vue diff --git a/scripts/phase5-verify.js b/scripts/phase5-verify.js new file mode 100644 index 0000000..7e8b815 --- /dev/null +++ b/scripts/phase5-verify.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +/** + * scripts/phase5-verify.js + * + * P5 Vue Dashboard 验收 (后端 + 静态产物): + * ① Vue 构建产物 (DevLoopDashboard + DevLoopOps + 8 子组件) 存在 + * ② GET /:pid/state 返带 status + units 字段 + * ③ GET /:pid/timeline 返事件列表 + * ④ GET /:pid/changelog 返条目列表 + * ⑤ GET /:pid/units/:unitId 返 unit + runs + coder_log_tail + * ⑥ GET /:pid/bugs 返 bug 列表 + * ⑦ SSE /:pid/stream 一秒内收到 ≥1 心跳/事件 + * + * 注: 这是后端 + 产物层面的验证;真正点击鼠标 UI 需要 vite dev 跑起来手测。 + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); + +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); +require('module').globalPaths.push(serverNodeModules); +const express = (() => { + try { return require('express'); } + catch { + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); + } +})(); + +const pathResolver = require('../server/services/devloop/path-resolver'); +const lc = require('../server/src/core/devloop/loop-controller'); +const devloopRouter = require('../server/routes/devloop'); + +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path'); +const WEB_DIST = path.resolve(__dirname, '../web/dist'); + +const results = []; +function step(name, fn) { + return (async () => { + try { + const r = await fn(); + results.push({ name, ok: true, detail: r?.detail || '' }); + return r; + } catch (e) { + results.push({ name, ok: false, reason: e.message }); + throw e; + } + })(); +} + +async function startApp() { + const app = express(); + app.use(express.json()); + app.use('/api/devloop', devloopRouter); + return new Promise((resolve) => { + const srv = app.listen(0, '127.0.0.1', () => { + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); + }); + }); +} + +async function main() { + await step('① Vue build 产物存在 (Dashboard + Ops + components)', () => { + const required = [ + 'index.html', 'assets', + ]; + for (const f of required) { + if (!fs.existsSync(path.join(WEB_DIST, f))) throw new Error(`web/dist/${f} missing — run npm run web:build`); + } + const assets = fs.readdirSync(path.join(WEB_DIST, 'assets')); + if (!assets.some(n => /DevLoopDashboard.*\.js$/.test(n))) throw new Error('DevLoopDashboard chunk not built'); + if (!assets.some(n => /DevLoopOps.*\.js$/.test(n))) throw new Error('DevLoopOps chunk not built'); + return { detail: `DevLoopDashboard + DevLoopOps chunks present in web/dist/assets` }; + }); + + const { srv, base } = await startApp(); + const cid = 'c-phase5-' + Date.now().toString(36); + let pid; + + try { + await step('② 准备项目: POST /projects → state', async () => { + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'TODO', mockFixtureDir: HAPPY_DIR }), + }); + pid = (await r.json()).pid; + const s = await (await fetch(`${base}/api/devloop/projects/${pid}/state`)).json(); + if (!s.status) throw new Error('state.status missing'); + if (!Array.isArray(s.units)) throw new Error('state.units missing'); + return { detail: `pid=${pid} status=${s.status} units=${s.units.length}` }; + }); + + await step('③ GET /:pid/timeline returns events', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline?limit=50`); + const j = await r.json(); + if (!Array.isArray(j.events)) throw new Error('events not array'); + if (j.events.length === 0) throw new Error('events empty'); + return { detail: `${j.events.length} events` }; + }); + + await step('④ GET /:pid/changelog returns entries', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`); + const j = await r.json(); + if (!Array.isArray(j.entries)) throw new Error('entries not array'); + return { detail: `${j.entries.length} entries` }; + }); + + await step('⑤ GET /:pid/units/todo-be returns unit + runs', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`); + if (r.status !== 200) throw new Error(`status ${r.status}`); + const j = await r.json(); + if (!j.unit) throw new Error('unit missing'); + if (!Array.isArray(j.runs)) throw new Error('runs not array'); + return { detail: `unit.status=${j.unit?.status} · ${j.runs.length} runs` }; + }); + + await step('⑥ GET /:pid/bugs returns bug list', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/bugs`); + const j = await r.json(); + if (!Array.isArray(j.bugs)) throw new Error('bugs not array'); + return { detail: `${j.bugs.length} bugs` }; + }); + + await step('⑦ SSE /:pid/stream delivers ≥1 event in 1s after emit', async () => { + // 用 http.get 拿原生 stream + const url = new URL(`${base}/api/devloop/projects/${pid}/stream`); + const events = await new Promise((resolve) => { + const out = []; + const req = http.get({ + hostname: url.hostname, port: url.port, path: url.pathname, + headers: { 'Accept': 'text/event-stream' }, + }, (res) => { + let buf = ''; + res.on('data', (chunk) => { + buf += chunk.toString(); + const matches = buf.matchAll(/^event: (.+)$/gm); + for (const m of matches) out.push(m[1]); + buf = buf.slice(buf.lastIndexOf('\n\n') + 2); + }); + res.on('error', () => resolve(out)); + }); + // Trigger an event so we have something to receive + setTimeout(async () => { + try { + await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'feedback for sse test', mockFixtureDir: HAPPY_DIR }), + }); + } catch { /* ignore */ } + }, 100); + setTimeout(() => { try { req.destroy(); } catch {} resolve(out); }, 1500); + }); + if (events.length === 0) throw new Error('no SSE events received'); + return { detail: `${events.length} events: ${events.slice(0, 4).join(', ')}` }; + }); + + } finally { + srv.close(); + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } + catch { /* ignore */ } + } + + let pass = 0, fail = 0; + console.log('\n┌─ DevLoop Phase 5 — Vue Dashboard Acceptance ──────'); + for (const r of results) { + const tag = r.ok ? '✓' : '✗'; + console.log(`│ ${tag} ${r.name.padEnd(60)} ${r.ok ? r.detail : '— ' + r.reason}`); + r.ok ? pass++ : fail++; + } + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); + process.exit(fail ? 1 : 0); +} + +main().catch(e => { + console.error('\n[phase5-verify] fatal:', e); + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); + process.exit(1); +}); diff --git a/server/routes/devloop.js b/server/routes/devloop.js index 392c6c3..ec4ea5d 100644 --- a/server/routes/devloop.js +++ b/server/routes/devloop.js @@ -206,6 +206,79 @@ router.get('/projects/:pid/bugs', h(async (req, res) => { 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 { diff --git a/server/tests/devloop/routes.test.js b/server/tests/devloop/routes.test.js index 7fe2227..61c6e13 100644 --- a/server/tests/devloop/routes.test.js +++ b/server/tests/devloop/routes.test.js @@ -189,4 +189,52 @@ describe('routes/devloop', () => { const j = await r.json(); expect(Array.isArray(j.projects)).toBe(true); }); + + // P5 — UI-supporting endpoints + describe('P5 UI endpoints', () => { + let pid; + beforeAll(async () => { + const cid = 'c-p5-routes-' + Math.random().toString(36).slice(2, 7); + created.push(cid); + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'p5 test' }), + }); + pid = (await r.json()).pid; + }); + + it('GET /timeline returns events array', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline`); + expect(r.status).toBe(200); + const j = await r.json(); + expect(Array.isArray(j.events)).toBe(true); + expect(j.events.length).toBeGreaterThan(0); + }); + + it('GET /changelog returns entries array', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`); + expect(r.status).toBe(200); + const j = await r.json(); + expect(Array.isArray(j.entries)).toBe(true); + }); + + it('GET /units/:unitId returns unit + runs + coder_log_tail', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`); + expect(r.status).toBe(200); + const j = await r.json(); + expect(j).toHaveProperty('unit'); + expect(j).toHaveProperty('runs'); + expect(Array.isArray(j.runs)).toBe(true); + }); + + it('GET /units/:unitId 404 on unknown unit', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/no-such-unit`); + expect(r.status).toBe(404); + }); + + it('GET /timeline 404 on unknown pid', async () => { + const r = await fetch(`${base}/api/devloop/projects/p-nope/timeline`); + expect(r.status).toBe(404); + }); + }); }); diff --git a/web/src/components/devloop/DashboardHeader.vue b/web/src/components/devloop/DashboardHeader.vue new file mode 100644 index 0000000..6c9d7e4 --- /dev/null +++ b/web/src/components/devloop/DashboardHeader.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/web/src/components/devloop/FeedbackModal.vue b/web/src/components/devloop/FeedbackModal.vue new file mode 100644 index 0000000..48e974a --- /dev/null +++ b/web/src/components/devloop/FeedbackModal.vue @@ -0,0 +1,82 @@ +