Commit d1d06391150566e24e477b4ecdc0fd367e658ae7
Merge remote-tracking branch 'origin/main'
# Conflicts: # system/agents/master/.claude/scheduled_tasks.json
Showing
52 changed files
with
5699 additions
and
95 deletions
CLAUDE.md
| ... | ... | @@ -17,8 +17,11 @@ npm run setup |
| 17 | 17 | # 开发模式(前后端分离,跑完整 ArenaSK + LemonBoss) |
| 18 | 18 | npm run dev # 后端 server/index.js:3010 + 前端 vite:5173 |
| 19 | 19 | |
| 20 | -# 仅 Agent Chat 子系统(轻量,不依赖 Skill DB) | |
| 21 | -bash start.sh # server/agent-server.js:3010 + vite:5173 | |
| 20 | +# 等价快捷脚本(先 kill 占用端口再起) | |
| 21 | +bash start.sh # server/index.js:3010 + vite:5173 | |
| 22 | + | |
| 23 | +# (历史轻量入口,仅 Agent Chat / Company / OKR / Tasks / DevLoop 子集) | |
| 24 | +PORT=3010 node server/agent-server.js | |
| 22 | 25 | ``` |
| 23 | 26 | |
| 24 | 27 | > 端口默认 **3010**(`server/config.js`),不是 3000。`start.sh` 会先 kill 占用 3010 / 5173 的进程。 |
| ... | ... | @@ -64,17 +67,17 @@ auto-dev "<task>" # bin 入口 → server/src/cli/auto-dev.js |
| 64 | 67 | |
| 65 | 68 | | 入口 | 端口 | 启动方式 | 挂载路由 | |
| 66 | 69 | |------|------|----------|----------| |
| 67 | -| `server/index.js` | 3010 | `npm run dev`, `npm run server` | 全量:`/api/claude-chat` `/api/config` `/api/evolution` `/api/innovator` `/api/master` `/api/open` `/api/arena` `/api/auth` `/api/users` `/api/whatsapp` `/api/agent` `/api/goals` `/api/company` `/api/tasks`,WS `/ws/claude`,`NODE_ENV=production` 时托管 `web/dist/` 静态文件 | | |
| 68 | -| `server/agent-server.js` | 3010 | `bash start.sh` | 仅 LemonBoss 子集:`/api/agent` `/api/company` `/api/goals` `/api/tasks` | | |
| 70 | +| `server/index.js` | 3010 | `npm run dev` / `npm run server` / `bash start.sh` | 全量:`/api/claude-chat` `/api/config` `/api/evolution` `/api/innovator` `/api/master` `/api/open` `/api/arena` `/api/auth` `/api/users` `/api/whatsapp` `/api/agent` `/api/goals` `/api/company` `/api/tasks` `/api/devloop`,WS `/ws/claude`,插件 / 心跳 / 中断任务恢复,`NODE_ENV=production` 时托管 `web/dist/` 静态文件 | | |
| 71 | +| `server/agent-server.js` | 3010 | 仅手动 `node server/agent-server.js`(历史保留) | 子集:`/api/agent` `/api/company` `/api/goals` `/api/tasks` `/api/devloop` | | |
| 69 | 72 | |
| 70 | -两者**不要同时启动**(端口冲突)。需要 Skill / Arena / Master Agent 时跑 `index.js`;只调通 Agent Chat / Company / OKR 时跑 `agent-server.js`。 | |
| 73 | +两者**不要同时启动**(端口冲突)。默认走 `index.js`(`start.sh` / `npm run dev` 都指向它);`agent-server.js` 只在你想跑无依赖最小子集时手动启动。 | |
| 71 | 74 | |
| 72 | 75 | ### 目录速查 |
| 73 | 76 | |
| 74 | 77 | | 目录 | 说明 | |
| 75 | 78 | |------|------| |
| 76 | -| `server/index.js` | 完整 ArenaSK 服务(API + WS + 插件 + 前端托管) | | |
| 77 | -| `server/agent-server.js` | 独立 Agent Chat 服务(LemonBoss 子集) | | |
| 79 | +| `server/index.js` | 完整 ArenaSK 服务(API + WS + 插件 + 前端托管),`start.sh` / `npm run dev` 默认入口 | | |
| 80 | +| `server/agent-server.js` | 历史保留的轻量子集入口(Agent / Company / Goals / Tasks / DevLoop),仅手动启动 | | |
| 78 | 81 | | `server/routes/` | 15 个 API 路由 | |
| 79 | 82 | | `server/services/` | 26 个业务服务(skill-db, benchmark-runner, company-service, heartbeat, pipeline-service, retrain-pipeline, …) | |
| 80 | 83 | | `server/src/core/` | Agent 引擎核心(orchestrator, master-session, agent-manager, scheduler, session-pool, prompt-builder, runtime-resolver, …) | |
| ... | ... | @@ -139,7 +142,7 @@ auto-dev "<task>" # bin 入口 → server/src/cli/auto-dev.js |
| 139 | 142 | ## 注意事项 |
| 140 | 143 | |
| 141 | 144 | - 生产环境只跑 `server/index.js`:它会同时托管 API + `web/dist/` 静态文件(`NODE_ENV=production`)。 |
| 142 | -- `start.sh` **只**做:kill 端口 → 起 `agent-server.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。 | |
| 145 | +- `start.sh` **只**做:kill 占用 3010/5173 的进程 → 起 `server/index.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。 | |
| 143 | 146 | - `server/index.js` 与 `agent-server.js` 都内置了 **body-sanitizer 中间件**:在 `express.json()` 之前清掉 `\x00-\x1F` 控制字符,避免外部 Agent 通过 `curl` 传未转义换行/制表符导致的 `Bad control character` 报错。修改请求体解析链路前先看这两段。 |
| 144 | 147 | - 模块别名 `@server` → `server/`(`module-alias` 注册),跨子包 require 时可用。 |
| 145 | 148 | - Claude CLI 进程默认带 `--dangerously-skip-permissions`,写入受限路径前自己评估风险。 | ... | ... |
fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "1-unit (todo-be only) contract_mismatch scenario. seq[0]=init, seq[1]=patch (rev=2 + l2_rounds=1).", | |
| 3 | + "sequence": [ | |
| 4 | + { | |
| 5 | + "_variant": "init", | |
| 6 | + "chunks": ["## SDD rev 1 + units.json (single unit: todo-be)\n"], | |
| 7 | + "result": "mock-ok devloop-architect[init]: SDD rev 1", | |
| 8 | + "files": [ | |
| 9 | + { | |
| 10 | + "path": "sdd/index.html", | |
| 11 | + "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</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</div></section></article></body></html>" | |
| 12 | + }, | |
| 13 | + { | |
| 14 | + "path": "sdd/mvcp/control.html", | |
| 15 | + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control rev1</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 (ambiguous on errors)</p><table><tr><td>text</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">v1</div></section></article></body></html>" | |
| 16 | + }, | |
| 17 | + { | |
| 18 | + "path": "units.json", | |
| 19 | + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1,\n \"prd_rev\": \"1\",\n \"sdd_rev\": \"1\",\n \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0,\n \"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" | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "path": "integration/spec.json", | |
| 23 | + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" | |
| 24 | + }, | |
| 25 | + { | |
| 26 | + "path": "units/todo-be/spec.json", | |
| 27 | + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"GET /todos returns array\",\"POST /todos empty returns 400\"]}" | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "path": "history/sdd-rev-001.html", | |
| 31 | + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>snap1</h3><p>rev1</p></section></article></body></html>" | |
| 32 | + } | |
| 33 | + ] | |
| 34 | + }, | |
| 35 | + { | |
| 36 | + "_variant": "patch", | |
| 37 | + "chunks": ["## Patch: SDD rev 2, units.json l2_rounds=1, todo-be status=pending\n"], | |
| 38 | + "result": "mock-ok devloop-architect[patch]: SDD rev 2", | |
| 39 | + "files": [ | |
| 40 | + { | |
| 41 | + "path": "sdd/mvcp/control.html", | |
| 42 | + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control rev 2</title></head><body><article data-doc=\"sdd\" data-rev=\"2\"><section data-rev=\"2\" data-unit-id=\"todo-be\"><h3>C-1 POST /todos (clarified)</h3><p>201 on create; 400 on missing text</p><table><tr><td>text</td><td>required</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">v2</div></section></article></body></html>" | |
| 43 | + }, | |
| 44 | + { | |
| 45 | + "path": "sdd/index.html", | |
| 46 | + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>SDD rev2</title></head><body><article data-doc=\"sdd\" data-rev=\"2\"><section data-rev=\"2\"><h3>SDD</h3><p>rev 2</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</div></section></article></body></html>" | |
| 47 | + }, | |
| 48 | + { | |
| 49 | + "path": "units.json", | |
| 50 | + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 2,\n \"prd_rev\": \"1\",\n \"sdd_rev\": \"2\",\n \"updated_at\": \"2026-05-13T10:05:00.000Z\",\n \"l2_rounds\": 1,\n \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:00000000000000aa\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n" | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + "path": "history/sdd-rev-002.html", | |
| 54 | + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"2\"><section data-rev=\"1\"><h3>snap2</h3><p>patch round 1</p></section></article></body></html>" | |
| 55 | + } | |
| 56 | + ] | |
| 57 | + } | |
| 58 | + ] | |
| 59 | +} | ... | ... |
fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "L2 contract_mismatch: round 1 fails (auth-be 500 vs auth-fe expects 401), round 2 passes after Architect patches SDD + L1 reruns.", | |
| 3 | + "sequence": [ | |
| 4 | + { | |
| 5 | + "_variant": "round1-fail", | |
| 6 | + "chunks": [ | |
| 7 | + "## step 0 · 读 units.json + integration/spec.json\n", | |
| 8 | + "## step 1-4 · 起 be 进程,跑 case-add-list\n", | |
| 9 | + "case 失败: POST /todos 返 500, 期望 201/400 (contract 不一致)\n" | |
| 10 | + ], | |
| 11 | + "result": "mock-ok devloop-integrator[round1] FAILED: contract mismatch", | |
| 12 | + "files": [ | |
| 13 | + { | |
| 14 | + "path": "integration/runs/run-mock-l2-1/result.json", | |
| 15 | + "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\", \"todo-fe\"],\n \"got\": { \"status\": 500, \"body\": {} },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"TypeError: cannot read property text\"\n }],\n \"duration_ms\": 1234\n}\n" | |
| 16 | + }, | |
| 17 | + { | |
| 18 | + "path": "integration/runs/run-mock-l2-1/process.log", | |
| 19 | + "content": "[todo-be] starting on :18001\n[todo-be] TypeError at server.js:8\n[todo-be] exited code 1\n" | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "path": "integration/runs/run-mock-l2-1/http-trace.ndjson", | |
| 23 | + "content": "{\"ts\":1747139000,\"unit\":\"todo-be\",\"method\":\"POST\",\"url\":\"http://127.0.0.1:18001/todos\",\"status\":500}\n" | |
| 24 | + } | |
| 25 | + ] | |
| 26 | + }, | |
| 27 | + { | |
| 28 | + "_variant": "round2-pass", | |
| 29 | + "chunks": [ | |
| 30 | + "## L2 round 2 (after SDD patched + L1 reran)\n", | |
| 31 | + "case-add-list 通过 ✓\n" | |
| 32 | + ], | |
| 33 | + "result": "mock-ok devloop-integrator[round2] PASS: integration green", | |
| 34 | + "files": [ | |
| 35 | + { | |
| 36 | + "path": "integration/runs/run-mock-l2-2/result.json", | |
| 37 | + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 980\n}\n" | |
| 38 | + } | |
| 39 | + ] | |
| 40 | + } | |
| 41 | + ] | |
| 42 | +} | ... | ... |
fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "Triager归因为 contract_mismatch (confidence 0.85) → LoopController will dispatch Architect(patch).", | |
| 3 | + "chunks": [ | |
| 4 | + "## step 0 · 收集证据 (result.json + process.log + sdd + contracts)\n", | |
| 5 | + "## step 1 · category_classifier → contract_mismatch\n", | |
| 6 | + "## step 2 · 起草 proposed_sdd_patch\n", | |
| 7 | + "## step 3 · 写 triage.json + evidence 自检\n" | |
| 8 | + ], | |
| 9 | + "result": "mock-ok devloop-triager: contract_mismatch confidence=0.85 affected=[todo-be]", | |
| 10 | + "files": [ | |
| 11 | + { | |
| 12 | + "path": "integration/runs/run-mock-l2-1/triage.json", | |
| 13 | + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"todo-be 返 500 但 SDD 规定参数错应返 400; contract 描述与实现不齐\",\n \"category\": \"contract_mismatch\",\n \"affected_units\": [\"todo-be\"],\n \"proposed_sdd_patch\": {\n \"target_section\": \"#unit-todo-be\",\n \"op\": \"update\",\n \"text\": \"明确: POST /todos 缺 text 字段必须返 400 not 500\"\n },\n \"confidence\": 0.85,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/result.json: status: 500\",\n \"integration/runs/run-mock-l2-1/process.log: TypeError\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 320\n}\n" | |
| 14 | + } | |
| 15 | + ] | |
| 16 | +} | ... | ... |
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-scenarios/impl_bug/devloop-architect.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", | |
| 3 | + "chunks": ["## SDD rev 1 (single unit todo-be)\n"], | |
| 4 | + "result": "mock-ok devloop-architect[init]: SDD rev 1", | |
| 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</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</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 (explicit)</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-scenarios/impl_bug/devloop-integrator.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "impl_bug: integration fails with assertion (status 500 but SDD said something specific). Round 2 passes after just resetting affected units.", | |
| 3 | + "sequence": [ | |
| 4 | + { | |
| 5 | + "_variant": "round1-fail", | |
| 6 | + "chunks": ["## case fail: todo-be assertion mismatch\n"], | |
| 7 | + "result": "mock-ok devloop-integrator[round1]: impl_bug failure", | |
| 8 | + "files": [ | |
| 9 | + { | |
| 10 | + "path": "integration/runs/run-mock-l2-1/result.json", | |
| 11 | + "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\"],\n \"got\": { \"status\": 500 },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"ReferenceError: passwrd is not defined\"\n }],\n \"duration_ms\": 1100\n}\n" | |
| 12 | + }, | |
| 13 | + { | |
| 14 | + "path": "integration/runs/run-mock-l2-1/process.log", | |
| 15 | + "content": "[todo-be] ReferenceError at line 8\n" | |
| 16 | + } | |
| 17 | + ] | |
| 18 | + }, | |
| 19 | + { | |
| 20 | + "_variant": "round2-pass", | |
| 21 | + "chunks": ["## pass after coder reset\n"], | |
| 22 | + "result": "mock-ok devloop-integrator[round2]: pass", | |
| 23 | + "files": [ | |
| 24 | + { | |
| 25 | + "path": "integration/runs/run-mock-l2-2/result.json", | |
| 26 | + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 950\n}\n" | |
| 27 | + } | |
| 28 | + ] | |
| 29 | + } | |
| 30 | + ] | |
| 31 | +} | ... | ... |
fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "Triager: impl_bug — SDD 写得清楚,code 没遵守。不改 SDD,只 reset todo-be。", | |
| 3 | + "chunks": [ | |
| 4 | + "## category_classifier → impl_bug (SDD 明确,code 实现错)\n" | |
| 5 | + ], | |
| 6 | + "result": "mock-ok devloop-triager: impl_bug confidence=0.78 affected=[todo-be]", | |
| 7 | + "files": [ | |
| 8 | + { | |
| 9 | + "path": "integration/runs/run-mock-l2-1/triage.json", | |
| 10 | + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"todo-be code/server.js 抛 ReferenceError; SDD 明确写 POST /todos 入参为 text 字段,代码没遵守\",\n \"category\": \"impl_bug\",\n \"affected_units\": [\"todo-be\"],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.78,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/process.log: ReferenceError\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 290\n}\n" | |
| 11 | + } | |
| 12 | + ] | |
| 13 | +} | ... | ... |
fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", | |
| 3 | + "chunks": ["## SDD rev 1 (single unit todo-be)\n"], | |
| 4 | + "result": "mock-ok devloop-architect[init]: SDD rev 1", | |
| 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</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</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 (explicit)</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-scenarios/infra_flake/devloop-integrator.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "infra_flake: round 1 crashed (EADDRINUSE), round 2 passes after retry (no reset, no budget burn).", | |
| 3 | + "sequence": [ | |
| 4 | + { | |
| 5 | + "_variant": "round1-crash", | |
| 6 | + "chunks": ["## EADDRINUSE on :18001\n"], | |
| 7 | + "result": "mock-ok devloop-integrator[round1] CRASH: EADDRINUSE", | |
| 8 | + "files": [ | |
| 9 | + { | |
| 10 | + "path": "integration/runs/run-mock-l2-1/result.json", | |
| 11 | + "content": "{\n \"ok\": false,\n \"crashed\": true,\n \"run_id\": \"run-mock-l2-1\",\n \"crash_reason\": \"EADDRINUSE on :18001\",\n \"summary\": { \"total\": 0, \"passed\": 0, \"failed\": 0 },\n \"failures\": [],\n \"stderr_excerpt\": \"Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\"\n}\n" | |
| 12 | + }, | |
| 13 | + { | |
| 14 | + "path": "integration/runs/run-mock-l2-1/process.log", | |
| 15 | + "content": "[todo-be] Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\n" | |
| 16 | + } | |
| 17 | + ] | |
| 18 | + }, | |
| 19 | + { | |
| 20 | + "_variant": "round2-pass", | |
| 21 | + "chunks": ["## retry: new port, pass\n"], | |
| 22 | + "result": "mock-ok devloop-integrator[round2]: pass", | |
| 23 | + "files": [ | |
| 24 | + { | |
| 25 | + "path": "integration/runs/run-mock-l2-2/result.json", | |
| 26 | + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 920\n}\n" | |
| 27 | + } | |
| 28 | + ] | |
| 29 | + } | |
| 30 | + ] | |
| 31 | +} | ... | ... |
fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "Triager: infra_flake — env issue, just rerun. affected=[], no SDD change.", | |
| 3 | + "chunks": ["## stderr 含 EADDRINUSE → infra_flake (直接重跑)\n"], | |
| 4 | + "result": "mock-ok devloop-triager: infra_flake confidence=0.95", | |
| 5 | + "files": [ | |
| 6 | + { | |
| 7 | + "path": "integration/runs/run-mock-l2-1/triage.json", | |
| 8 | + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"端口 18001 已被占用 (EADDRINUSE),进程未起;与 code/SDD 无关\",\n \"category\": \"infra_flake\",\n \"affected_units\": [],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.95,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/process.log: EADDRINUSE\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 180\n}\n" | |
| 9 | + } | |
| 10 | + ] | |
| 11 | +} | ... | ... |
fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", | |
| 3 | + "chunks": ["## SDD rev 1 (single unit todo-be)\n"], | |
| 4 | + "result": "mock-ok devloop-architect[init]: SDD rev 1", | |
| 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</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</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 (explicit)</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-scenarios/spec_ambiguous/devloop-integrator.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "spec_ambiguous: integration fails but root cause is SDD ambiguity. Triager confidence < 0.5 → human intervention.", | |
| 3 | + "chunks": ["## case fail (spec_ambiguous)\n"], | |
| 4 | + "result": "mock-ok devloop-integrator: failure due to ambiguous SDD", | |
| 5 | + "files": [ | |
| 6 | + { | |
| 7 | + "path": "integration/runs/run-mock-l2-1/result.json", | |
| 8 | + "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\"],\n \"got\": { \"status\": 200, \"body\": { \"id\": 1 } },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"behavior undefined in SDD\"\n }],\n \"duration_ms\": 1000\n}\n" | |
| 9 | + } | |
| 10 | + ] | |
| 11 | +} | ... | ... |
fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json
0 → 100644
| 1 | +{ | |
| 2 | + "_doc": "Triager: spec_ambiguous, confidence 0.4 → human_intervention_needed.", | |
| 3 | + "chunks": ["## SDD 未明确 token 续期 / POST 状态码 → spec_ambiguous\n"], | |
| 4 | + "result": "mock-ok devloop-triager: spec_ambiguous confidence=0.4 (升 L3)", | |
| 5 | + "files": [ | |
| 6 | + { | |
| 7 | + "path": "integration/runs/run-mock-l2-1/triage.json", | |
| 8 | + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"SDD 没明确规定 POST /todos 应返 200 还是 201; 两种解读都说得通\",\n \"category\": \"spec_ambiguous\",\n \"affected_units\": [],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.4,\n \"evidence\": [],\n \"will_auto_apply\": false,\n \"evidence_verified\": true,\n \"duration_ms\": 250\n}\n" | |
| 9 | + } | |
| 10 | + ] | |
| 11 | +} | ... | ... |
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 | } | ... | ... |
packages/acp-core/protocol/ClaudeCliProtocol.js
| ... | ... | @@ -49,6 +49,9 @@ class ClaudeCliProtocol extends EventEmitter { |
| 49 | 49 | this._mockMode = MOCK_MODE || options.mock === true; |
| 50 | 50 | this._mockFixtureDir = options.mockFixtureDir |
| 51 | 51 | || path.resolve(__dirname, '../../../fixtures/devloop-mock'); |
| 52 | + // P3 场景测试: 如 scenario 目录无 {agent}.json,回落到 baseline 目录 | |
| 53 | + this._mockFallbackDir = options.mockFallbackDir | |
| 54 | + || path.resolve(__dirname, '../../../fixtures/devloop-mock'); | |
| 52 | 55 | } |
| 53 | 56 | |
| 54 | 57 | /** |
| ... | ... | @@ -73,6 +76,19 @@ class ClaudeCliProtocol extends EventEmitter { |
| 73 | 76 | '--dangerously-skip-permissions', |
| 74 | 77 | ]; |
| 75 | 78 | |
| 79 | + // DevLoop 真模式: 注入 agent 专属 system prompt (来自 system/templates/{agent}/prompt.md) | |
| 80 | + // 不用 --bare — 我们仍想要 Read/Write/Edit/Bash 自动可用 + 工具权限提示等正常默认。 | |
| 81 | + // 用 --append-system-prompt 而不是 --system-prompt: 保留 Claude 默认提示 | |
| 82 | + // (含工具使用约定),再追加 agent 的 ROLE / SANDBOX / WORKFLOW 段。 | |
| 83 | + if (this.options.systemPrompt && typeof this.options.systemPrompt === 'string') { | |
| 84 | + args.push('--append-system-prompt', this.options.systemPrompt); | |
| 85 | + } | |
| 86 | + | |
| 87 | + // 单次会话预算上限 (USD); 防 Agent 跑飞烧钱 | |
| 88 | + if (typeof this.options.maxBudgetUsd === 'number' && this.options.maxBudgetUsd > 0) { | |
| 89 | + args.push('--max-budget-usd', String(this.options.maxBudgetUsd)); | |
| 90 | + } | |
| 91 | + | |
| 76 | 92 | // 支持禁用特定原生工具 (用于编排型 Agent,强制纯文本输出) |
| 77 | 93 | if (this.options.disallowedTools && this.options.disallowedTools.length > 0) { |
| 78 | 94 | args.push('--disallowedTools', ...this.options.disallowedTools); |
| ... | ... | @@ -536,21 +552,25 @@ class ClaudeCliProtocol extends EventEmitter { |
| 536 | 552 | async _sendMockPrompt(text) { |
| 537 | 553 | const agentName = this.agentInfo?.name || this.options.agentName || 'default'; |
| 538 | 554 | const fixturePath = path.join(this._mockFixtureDir, `${agentName}.json`); |
| 539 | - const defaultPath = path.join(this._mockFixtureDir, 'default.json'); | |
| 555 | + const fallbackPath = this._mockFallbackDir !== this._mockFixtureDir | |
| 556 | + ? path.join(this._mockFallbackDir, `${agentName}.json`) | |
| 557 | + : null; | |
| 558 | + const defaultPath = path.join(this._mockFallbackDir, 'default.json'); | |
| 540 | 559 | |
| 541 | 560 | let raw; |
| 542 | - if (fs.existsSync(fixturePath)) { | |
| 561 | + const tryPaths = [fixturePath]; | |
| 562 | + if (fallbackPath) tryPaths.push(fallbackPath); | |
| 563 | + tryPaths.push(defaultPath); | |
| 564 | + | |
| 565 | + for (const p of tryPaths) { | |
| 566 | + if (!fs.existsSync(p)) continue; | |
| 543 | 567 | try { |
| 544 | - raw = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); | |
| 568 | + raw = JSON.parse(fs.readFileSync(p, 'utf8')); | |
| 569 | + break; | |
| 545 | 570 | } catch (e) { |
| 546 | - console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${fixturePath}): ${e.message}`); | |
| 571 | + console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${p}): ${e.message}`); | |
| 547 | 572 | } |
| 548 | 573 | } |
| 549 | - if (!raw && fs.existsSync(defaultPath)) { | |
| 550 | - try { | |
| 551 | - raw = JSON.parse(fs.readFileSync(defaultPath, 'utf8')); | |
| 552 | - } catch { /* ignore */ } | |
| 553 | - } | |
| 554 | 574 | if (!raw) { |
| 555 | 575 | // echo fallback: 把 prompt 头 80 字符回显 |
| 556 | 576 | raw = { | ... | ... |
scripts/phase3-verify.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * scripts/phase3-verify.js | |
| 4 | + * | |
| 5 | + * P3 L1 + L2 联动验收 (mock 模式 · 4 类分支): | |
| 6 | + * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 · sdd.updated SSE · changelog sdd_rev 条 | |
| 7 | + * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 · 仅 reset Unit | |
| 8 | + * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE | |
| 9 | + * ④ infra_flake → shipped · l2_rounds=0 · rerun 无 reset | |
| 10 | + * ⑤ Observability → bugs.ndjson · timeline.ndjson · stream.log SSE replay | |
| 11 | + * | |
| 12 | + * 用法: node scripts/phase3-verify.js | |
| 13 | + */ | |
| 14 | + | |
| 15 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | |
| 16 | + | |
| 17 | +const fs = require('fs'); | |
| 18 | +const path = require('path'); | |
| 19 | + | |
| 20 | +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); | |
| 21 | +require('module').globalPaths.push(serverNodeModules); | |
| 22 | +const express = (() => { | |
| 23 | + try { return require('express'); } | |
| 24 | + catch { | |
| 25 | + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); | |
| 26 | + } | |
| 27 | +})(); | |
| 28 | + | |
| 29 | +const pathResolver = require('../server/services/devloop/path-resolver'); | |
| 30 | +const sseBroker = require('../server/services/devloop/sse-broker'); | |
| 31 | +const bugTracker = require('../server/services/devloop/bug-tracker'); | |
| 32 | +const lc = require('../server/src/core/devloop/loop-controller'); | |
| 33 | + | |
| 34 | +const SCENARIO_BASE = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios'); | |
| 35 | + | |
| 36 | +const results = []; | |
| 37 | +function step(name, fn) { | |
| 38 | + return (async () => { | |
| 39 | + try { | |
| 40 | + const r = await fn(); | |
| 41 | + results.push({ name, ok: true, detail: r?.detail || '' }); | |
| 42 | + return r; | |
| 43 | + } catch (e) { | |
| 44 | + results.push({ name, ok: false, reason: e.message }); | |
| 45 | + throw e; | |
| 46 | + } | |
| 47 | + })(); | |
| 48 | +} | |
| 49 | + | |
| 50 | +function makeFakeRes(events) { | |
| 51 | + return { | |
| 52 | + setHeader() {}, flushHeaders() {}, end() {}, | |
| 53 | + on() {}, | |
| 54 | + write(line) { | |
| 55 | + const m = line.match(/^event: (.+)$/m); | |
| 56 | + if (m) events.push(m[1]); | |
| 57 | + }, | |
| 58 | + }; | |
| 59 | +} | |
| 60 | + | |
| 61 | +async function runScenario(scenario) { | |
| 62 | + pathResolver._resetForTesting(); | |
| 63 | + sseBroker._resetForTesting(); | |
| 64 | + const cid = 'c-phase3-' + scenario + '-' + Date.now().toString(36); | |
| 65 | + const dir = path.join(SCENARIO_BASE, scenario); | |
| 66 | + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'phase3 ' + scenario }); | |
| 67 | + await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir }); | |
| 68 | + | |
| 69 | + const events = []; | |
| 70 | + sseBroker.subscribe(proj.pid, makeFakeRes(events), {}); | |
| 71 | + | |
| 72 | + const result = await lc.runProject(proj.pid, { mockFixtureDir: dir }); | |
| 73 | + sseBroker.close(proj.pid); | |
| 74 | + return { pid: proj.pid, cid, result, events }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +function cleanup(cid) { | |
| 78 | + try { | |
| 79 | + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); | |
| 80 | + } catch { /* ignore */ } | |
| 81 | +} | |
| 82 | + | |
| 83 | +async function main() { | |
| 84 | + const cidsToClean = []; | |
| 85 | + | |
| 86 | + // ① contract_mismatch | |
| 87 | + let cm; | |
| 88 | + try { | |
| 89 | + await step('① contract_mismatch → shipped (sdd_rev=2, l2_rounds=1)', async () => { | |
| 90 | + cm = await runScenario('contract_mismatch'); | |
| 91 | + cidsToClean.push(cm.cid); | |
| 92 | + if (cm.result.final_state !== 'shipped') throw new Error('not shipped: ' + cm.result.final_state); | |
| 93 | + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(cm.pid), 'utf8')); | |
| 94 | + if (u.l2_rounds !== 1) throw new Error('l2_rounds=' + u.l2_rounds); | |
| 95 | + if (u.sdd_rev !== '2') throw new Error('sdd_rev=' + u.sdd_rev); | |
| 96 | + return { detail: `pid=${cm.pid} sdd_rev=2 l2_rounds=1` }; | |
| 97 | + }); | |
| 98 | + | |
| 99 | + await step(' sdd.updated SSE event', () => { | |
| 100 | + if (!cm.events.includes('sdd.updated')) throw new Error('no sdd.updated in: ' + cm.events.join(',')); | |
| 101 | + return { detail: 'sdd.updated emitted' }; | |
| 102 | + }); | |
| 103 | + | |
| 104 | + await step(' changelog has sdd_rev=2 entry', () => { | |
| 105 | + const cl = pathResolver.resolveChangelog(cm.pid); | |
| 106 | + if (!fs.existsSync(cl)) throw new Error('changelog missing'); | |
| 107 | + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); | |
| 108 | + if (!lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)) throw new Error('no sdd_rev=2 in changelog'); | |
| 109 | + return { detail: `${lines.length} changelog entries` }; | |
| 110 | + }); | |
| 111 | + | |
| 112 | + await step(' bug opened → fixed', () => { | |
| 113 | + const bugs = bugTracker.list(cm.pid, {}); | |
| 114 | + if (bugs.length !== 1) throw new Error('want 1 bug, got ' + bugs.length); | |
| 115 | + if (bugs[0].status !== 'fixed') throw new Error('bug.status=' + bugs[0].status); | |
| 116 | + return { detail: `${bugs[0].id} (${bugs[0].status})` }; | |
| 117 | + }); | |
| 118 | + } catch { /* errors recorded by step */ } | |
| 119 | + | |
| 120 | + // ② impl_bug | |
| 121 | + let ib; | |
| 122 | + try { | |
| 123 | + await step('② impl_bug → shipped · SDD unchanged · l2_rounds=0', async () => { | |
| 124 | + ib = await runScenario('impl_bug'); | |
| 125 | + cidsToClean.push(ib.cid); | |
| 126 | + if (ib.result.final_state !== 'shipped') throw new Error('state=' + ib.result.final_state); | |
| 127 | + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(ib.pid), 'utf8')); | |
| 128 | + if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds + ' (impl_bug should not burn)'); | |
| 129 | + if (u.sdd_rev !== '1') throw new Error('sdd_rev=' + u.sdd_rev + ' (should remain 1)'); | |
| 130 | + return { detail: `sdd_rev=1 l2_rounds=0` }; | |
| 131 | + }); | |
| 132 | + } catch {} | |
| 133 | + | |
| 134 | + // ③ spec_ambiguous | |
| 135 | + let sa; | |
| 136 | + try { | |
| 137 | + await step('③ spec_ambiguous → l2_blocked + human_intervention_needed SSE', async () => { | |
| 138 | + sa = await runScenario('spec_ambiguous'); | |
| 139 | + cidsToClean.push(sa.cid); | |
| 140 | + if (sa.result.final_state !== 'l2_blocked') throw new Error('state=' + sa.result.final_state); | |
| 141 | + if (!sa.events.includes('human_intervention_needed')) throw new Error('no human_intervention_needed SSE'); | |
| 142 | + return { detail: 'l2_blocked · SSE fired' }; | |
| 143 | + }); | |
| 144 | + } catch {} | |
| 145 | + | |
| 146 | + // ④ infra_flake | |
| 147 | + let inf; | |
| 148 | + try { | |
| 149 | + await step('④ infra_flake → shipped · l2_rounds=0 · rerun_integrator', async () => { | |
| 150 | + inf = await runScenario('infra_flake'); | |
| 151 | + cidsToClean.push(inf.cid); | |
| 152 | + if (inf.result.final_state !== 'shipped') throw new Error('state=' + inf.result.final_state); | |
| 153 | + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(inf.pid), 'utf8')); | |
| 154 | + if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds); | |
| 155 | + const actions = inf.result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); | |
| 156 | + if (!actions.some(a => /rerun_integrator/.test(a))) throw new Error('no rerun_integrator action'); | |
| 157 | + return { detail: 'shipped without budget burn' }; | |
| 158 | + }); | |
| 159 | + } catch {} | |
| 160 | + | |
| 161 | + // ⑤ Observability cross-check | |
| 162 | + try { | |
| 163 | + await step('⑤ Observability — timeline / bugs / changelog / stream.log', () => { | |
| 164 | + // 用 contract_mismatch 项目验 | |
| 165 | + if (!cm) throw new Error('cm scenario did not run'); | |
| 166 | + const tl = pathResolver.resolveTimelineLog(cm.pid); | |
| 167 | + const bg = pathResolver.resolveBugsLog(cm.pid); | |
| 168 | + const cl = pathResolver.resolveChangelog(cm.pid); | |
| 169 | + const sl = pathResolver.resolveStreamLog(cm.pid); | |
| 170 | + for (const [name, p] of [['timeline', tl], ['bugs', bg], ['changelog', cl], ['stream.log', sl]]) { | |
| 171 | + if (!fs.existsSync(p)) throw new Error(`${name} missing`); | |
| 172 | + } | |
| 173 | + const tlKinds = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean) | |
| 174 | + .map(l => { try { return JSON.parse(l).kind; } catch { return ''; }}); | |
| 175 | + for (const must of ['unit.state', 'integration.result', 'triage.result', 'sdd.updated']) { | |
| 176 | + // unit.state is via sseBroker.emit not timeline; but we should at least have integration.result + sdd.updated as timeline kinds — actually those go via sseBroker.emit too. Check timeline has agent.call entries. | |
| 177 | + } | |
| 178 | + // 至少: project.created + agent.call (PM + Architect + L1 coder/tester + integrator + triager + architect-patch) + project.shipped | |
| 179 | + const expectedTimelineKinds = ['project.created', 'agent.call', 'project.shipped']; | |
| 180 | + const missing = expectedTimelineKinds.filter(k => !tlKinds.includes(k)); | |
| 181 | + if (missing.length) throw new Error('timeline missing kinds: ' + missing.join(',')); | |
| 182 | + return { detail: `timeline=${tlKinds.length}ev · bugs+changelog+stream.log present` }; | |
| 183 | + }); | |
| 184 | + | |
| 185 | + await step('⑥ SSE replay via Last-Event-ID', async () => { | |
| 186 | + if (!cm) throw new Error('cm scenario did not run'); | |
| 187 | + const replayed = []; | |
| 188 | + sseBroker.subscribe(cm.pid, makeFakeRes(replayed), { replayFromOffset: 3 }); | |
| 189 | + sseBroker.close(cm.pid); | |
| 190 | + // 至少应回放出 sdd.updated / project.shipped 之类 | |
| 191 | + if (replayed.length < 2) throw new Error('replay too few events: ' + replayed.length); | |
| 192 | + return { detail: `replayed ${replayed.length} events from id=3` }; | |
| 193 | + }); | |
| 194 | + } catch {} | |
| 195 | + | |
| 196 | + for (const c of cidsToClean) cleanup(c); | |
| 197 | + | |
| 198 | + let pass = 0, fail = 0; | |
| 199 | + console.log('\n┌─ DevLoop Phase 3 — L1+L2 联动 Acceptance ────────'); | |
| 200 | + for (const r of results) { | |
| 201 | + const tag = r.ok ? '✓' : '✗'; | |
| 202 | + console.log(`│ ${tag} ${r.name.padEnd(62)} ${r.ok ? r.detail : '— ' + r.reason}`); | |
| 203 | + r.ok ? pass++ : fail++; | |
| 204 | + } | |
| 205 | + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); | |
| 206 | + process.exit(fail ? 1 : 0); | |
| 207 | +} | |
| 208 | + | |
| 209 | +main().catch(e => { | |
| 210 | + console.error('\n[phase3-verify] fatal:', e); | |
| 211 | + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); | |
| 212 | + process.exit(1); | |
| 213 | +}); | ... | ... |
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 | +}); | ... | ... |
scripts/phase5-verify.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * scripts/phase5-verify.js | |
| 4 | + * | |
| 5 | + * P5 Vue Dashboard 验收 (后端 + 静态产物): | |
| 6 | + * ① Vue 构建产物 (DevLoopDashboard + DevLoopOps + 8 子组件) 存在 | |
| 7 | + * ② GET /:pid/state 返带 status + units 字段 | |
| 8 | + * ③ GET /:pid/timeline 返事件列表 | |
| 9 | + * ④ GET /:pid/changelog 返条目列表 | |
| 10 | + * ⑤ GET /:pid/units/:unitId 返 unit + runs + coder_log_tail | |
| 11 | + * ⑥ GET /:pid/bugs 返 bug 列表 | |
| 12 | + * ⑦ SSE /:pid/stream 一秒内收到 ≥1 心跳/事件 | |
| 13 | + * | |
| 14 | + * 注: 这是后端 + 产物层面的验证;真正点击鼠标 UI 需要 vite dev 跑起来手测。 | |
| 15 | + */ | |
| 16 | + | |
| 17 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | |
| 18 | + | |
| 19 | +const fs = require('fs'); | |
| 20 | +const path = require('path'); | |
| 21 | +const http = require('http'); | |
| 22 | + | |
| 23 | +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); | |
| 24 | +require('module').globalPaths.push(serverNodeModules); | |
| 25 | +const express = (() => { | |
| 26 | + try { return require('express'); } | |
| 27 | + catch { | |
| 28 | + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); | |
| 29 | + } | |
| 30 | +})(); | |
| 31 | + | |
| 32 | +const pathResolver = require('../server/services/devloop/path-resolver'); | |
| 33 | +const lc = require('../server/src/core/devloop/loop-controller'); | |
| 34 | +const devloopRouter = require('../server/routes/devloop'); | |
| 35 | + | |
| 36 | +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path'); | |
| 37 | +const WEB_DIST = path.resolve(__dirname, '../web/dist'); | |
| 38 | + | |
| 39 | +const results = []; | |
| 40 | +function step(name, fn) { | |
| 41 | + return (async () => { | |
| 42 | + try { | |
| 43 | + const r = await fn(); | |
| 44 | + results.push({ name, ok: true, detail: r?.detail || '' }); | |
| 45 | + return r; | |
| 46 | + } catch (e) { | |
| 47 | + results.push({ name, ok: false, reason: e.message }); | |
| 48 | + throw e; | |
| 49 | + } | |
| 50 | + })(); | |
| 51 | +} | |
| 52 | + | |
| 53 | +async function startApp() { | |
| 54 | + const app = express(); | |
| 55 | + app.use(express.json()); | |
| 56 | + app.use('/api/devloop', devloopRouter); | |
| 57 | + return new Promise((resolve) => { | |
| 58 | + const srv = app.listen(0, '127.0.0.1', () => { | |
| 59 | + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); | |
| 60 | + }); | |
| 61 | + }); | |
| 62 | +} | |
| 63 | + | |
| 64 | +async function main() { | |
| 65 | + await step('① Vue build 产物存在 (Dashboard + Ops + components)', () => { | |
| 66 | + const required = [ | |
| 67 | + 'index.html', 'assets', | |
| 68 | + ]; | |
| 69 | + for (const f of required) { | |
| 70 | + if (!fs.existsSync(path.join(WEB_DIST, f))) throw new Error(`web/dist/${f} missing — run npm run web:build`); | |
| 71 | + } | |
| 72 | + const assets = fs.readdirSync(path.join(WEB_DIST, 'assets')); | |
| 73 | + if (!assets.some(n => /DevLoopDashboard.*\.js$/.test(n))) throw new Error('DevLoopDashboard chunk not built'); | |
| 74 | + if (!assets.some(n => /DevLoopOps.*\.js$/.test(n))) throw new Error('DevLoopOps chunk not built'); | |
| 75 | + return { detail: `DevLoopDashboard + DevLoopOps chunks present in web/dist/assets` }; | |
| 76 | + }); | |
| 77 | + | |
| 78 | + const { srv, base } = await startApp(); | |
| 79 | + const cid = 'c-phase5-' + Date.now().toString(36); | |
| 80 | + let pid; | |
| 81 | + | |
| 82 | + try { | |
| 83 | + await step('② 准备项目: POST /projects → state', async () => { | |
| 84 | + const r = await fetch(`${base}/api/devloop/projects`, { | |
| 85 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 86 | + body: JSON.stringify({ company_id: cid, requirement: 'TODO', mockFixtureDir: HAPPY_DIR }), | |
| 87 | + }); | |
| 88 | + pid = (await r.json()).pid; | |
| 89 | + const s = await (await fetch(`${base}/api/devloop/projects/${pid}/state`)).json(); | |
| 90 | + if (!s.status) throw new Error('state.status missing'); | |
| 91 | + if (!Array.isArray(s.units)) throw new Error('state.units missing'); | |
| 92 | + return { detail: `pid=${pid} status=${s.status} units=${s.units.length}` }; | |
| 93 | + }); | |
| 94 | + | |
| 95 | + await step('③ GET /:pid/timeline returns events', async () => { | |
| 96 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline?limit=50`); | |
| 97 | + const j = await r.json(); | |
| 98 | + if (!Array.isArray(j.events)) throw new Error('events not array'); | |
| 99 | + if (j.events.length === 0) throw new Error('events empty'); | |
| 100 | + return { detail: `${j.events.length} events` }; | |
| 101 | + }); | |
| 102 | + | |
| 103 | + await step('④ GET /:pid/changelog returns entries', async () => { | |
| 104 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`); | |
| 105 | + const j = await r.json(); | |
| 106 | + if (!Array.isArray(j.entries)) throw new Error('entries not array'); | |
| 107 | + return { detail: `${j.entries.length} entries` }; | |
| 108 | + }); | |
| 109 | + | |
| 110 | + await step('⑤ GET /:pid/units/todo-be returns unit + runs', async () => { | |
| 111 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`); | |
| 112 | + if (r.status !== 200) throw new Error(`status ${r.status}`); | |
| 113 | + const j = await r.json(); | |
| 114 | + if (!j.unit) throw new Error('unit missing'); | |
| 115 | + if (!Array.isArray(j.runs)) throw new Error('runs not array'); | |
| 116 | + return { detail: `unit.status=${j.unit?.status} · ${j.runs.length} runs` }; | |
| 117 | + }); | |
| 118 | + | |
| 119 | + await step('⑥ GET /:pid/bugs returns bug list', async () => { | |
| 120 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/bugs`); | |
| 121 | + const j = await r.json(); | |
| 122 | + if (!Array.isArray(j.bugs)) throw new Error('bugs not array'); | |
| 123 | + return { detail: `${j.bugs.length} bugs` }; | |
| 124 | + }); | |
| 125 | + | |
| 126 | + await step('⑦ SSE /:pid/stream delivers ≥1 event in 1s after emit', async () => { | |
| 127 | + // 用 http.get 拿原生 stream | |
| 128 | + const url = new URL(`${base}/api/devloop/projects/${pid}/stream`); | |
| 129 | + const events = await new Promise((resolve) => { | |
| 130 | + const out = []; | |
| 131 | + const req = http.get({ | |
| 132 | + hostname: url.hostname, port: url.port, path: url.pathname, | |
| 133 | + headers: { 'Accept': 'text/event-stream' }, | |
| 134 | + }, (res) => { | |
| 135 | + let buf = ''; | |
| 136 | + res.on('data', (chunk) => { | |
| 137 | + buf += chunk.toString(); | |
| 138 | + const matches = buf.matchAll(/^event: (.+)$/gm); | |
| 139 | + for (const m of matches) out.push(m[1]); | |
| 140 | + buf = buf.slice(buf.lastIndexOf('\n\n') + 2); | |
| 141 | + }); | |
| 142 | + res.on('error', () => resolve(out)); | |
| 143 | + }); | |
| 144 | + // Trigger an event so we have something to receive | |
| 145 | + setTimeout(async () => { | |
| 146 | + try { | |
| 147 | + await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { | |
| 148 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 149 | + body: JSON.stringify({ text: 'feedback for sse test', mockFixtureDir: HAPPY_DIR }), | |
| 150 | + }); | |
| 151 | + } catch { /* ignore */ } | |
| 152 | + }, 100); | |
| 153 | + setTimeout(() => { try { req.destroy(); } catch {} resolve(out); }, 1500); | |
| 154 | + }); | |
| 155 | + if (events.length === 0) throw new Error('no SSE events received'); | |
| 156 | + return { detail: `${events.length} events: ${events.slice(0, 4).join(', ')}` }; | |
| 157 | + }); | |
| 158 | + | |
| 159 | + } finally { | |
| 160 | + srv.close(); | |
| 161 | + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } | |
| 162 | + catch { /* ignore */ } | |
| 163 | + } | |
| 164 | + | |
| 165 | + let pass = 0, fail = 0; | |
| 166 | + console.log('\n┌─ DevLoop Phase 5 — Vue Dashboard Acceptance ──────'); | |
| 167 | + for (const r of results) { | |
| 168 | + const tag = r.ok ? '✓' : '✗'; | |
| 169 | + console.log(`│ ${tag} ${r.name.padEnd(60)} ${r.ok ? r.detail : '— ' + r.reason}`); | |
| 170 | + r.ok ? pass++ : fail++; | |
| 171 | + } | |
| 172 | + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); | |
| 173 | + process.exit(fail ? 1 : 0); | |
| 174 | +} | |
| 175 | + | |
| 176 | +main().catch(e => { | |
| 177 | + console.error('\n[phase5-verify] fatal:', e); | |
| 178 | + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); | |
| 179 | + process.exit(1); | |
| 180 | +}); | ... | ... |
scripts/real-pm-smoke.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * scripts/real-pm-smoke.js | |
| 4 | + * | |
| 5 | + * 最小验证: 真 claude CLI 跑一次 PM Agent,看 ClaudeCliProtocol + | |
| 6 | + * --append-system-prompt + 工作树 mtime diff 这条 real 链路是否 | |
| 7 | + * 端到端能用。预算 $0.50 cap。 | |
| 8 | + * | |
| 9 | + * 期望: | |
| 10 | + * ① claude 进程 spawn 成功 | |
| 11 | + * ② Agent 输出非空 (result_text 有内容) | |
| 12 | + * ③ files_written 至少 ≥ 1 (Agent 调用 Write 工具写了文件) | |
| 13 | + * ④ git 有新 commit | |
| 14 | + * | |
| 15 | + * NOTE: 真 LLM 输出不可控;PM 可能不完全照 prompt.md 跑全套 11 章节。 | |
| 16 | + * 只要它写了任意 prd/* 文件就算 wire-up 通。 | |
| 17 | + */ | |
| 18 | + | |
| 19 | +delete process.env.LEMONBOSS_CLAUDE_MODE; // 显式关闭 mock | |
| 20 | + | |
| 21 | +// 加载根 .env (含 CLAUDE_CODE_OAUTH_TOKEN); dotenv 装在 server/node_modules | |
| 22 | +const path = require('path'); | |
| 23 | +const dotenv = require(require.resolve('dotenv', { paths: [path.resolve(__dirname, '../server')] })); | |
| 24 | +dotenv.config({ path: path.join(__dirname, '../.env') }); | |
| 25 | + | |
| 26 | +const fs = require('fs'); | |
| 27 | + | |
| 28 | +const lc = require('../server/src/core/devloop/loop-controller'); | |
| 29 | +const pr = require('../server/services/devloop/path-resolver'); | |
| 30 | + | |
| 31 | +// --max-budget-usd 仅在 --print 模式可用,与我们的 stream-json 长连接互斥 | |
| 32 | +// → 用进程超时 (5min) + 一次性单 agent 调用兜底 | |
| 33 | +const HARD_TIMEOUT_MS = 5 * 60 * 1000; | |
| 34 | +const cleanup = []; | |
| 35 | + | |
| 36 | +async function main() { | |
| 37 | + console.log('\n┌─ Real Claude CLI · PM Smoke ────────────────────'); | |
| 38 | + console.log(`│ Hard timeout: 5min (no maxBudgetUsd — only --print mode)`); | |
| 39 | + console.log(`│ Model: claude default`); | |
| 40 | + console.log('└──────────────────────────────────────────────────\n'); | |
| 41 | + | |
| 42 | + const cid = 'c-real-pm-' + Date.now().toString(36); | |
| 43 | + cleanup.push(cid); | |
| 44 | + const proj = await lc.createProject({ | |
| 45 | + company_id: cid, | |
| 46 | + mode: 'sandbox', | |
| 47 | + idea: 'real PM smoke: minimal TODO API + Vue UI', | |
| 48 | + }); | |
| 49 | + console.log(`pid = ${proj.pid}`); | |
| 50 | + console.log(`root = ${pr.resolveProjectRoot(proj.pid)}\n`); | |
| 51 | + | |
| 52 | + const userPrompt = [ | |
| 53 | + 'Mode: init', | |
| 54 | + 'Requirement: A minimal TODO list — POST /todos (add) + GET /todos (list).', | |
| 55 | + '', | |
| 56 | + '约束 (please obey for this smoke test):', | |
| 57 | + ' • cwd 已经是项目根,直接相对路径写文件', | |
| 58 | + ' • 至少写 prd/index.html 一个最小 HTML stub (含 <article data-doc="prd" data-rev="1"><section data-rev="1"><h3>...</h3><p>...</p></section></article>)', | |
| 59 | + ' • 不要尝试调 hash_contract / bootstrap_prd_site 等 LemonBoss 内部工具 — 它们在本 session 不可用', | |
| 60 | + ' • 不要执行 npm install / git / curl 等 Bash 命令', | |
| 61 | + ' • 完成后直接 stop, 不需要总结', | |
| 62 | + '', | |
| 63 | + '回 "DONE" 一词即可结束。', | |
| 64 | + ].join('\n'); | |
| 65 | + | |
| 66 | + console.log('▸ calling devloop-pm via real claude CLI...'); | |
| 67 | + console.log(` (system prompt: system/templates/devloop-pm/prompt.md, ~3KB)\n`); | |
| 68 | + | |
| 69 | + const startMs = Date.now(); | |
| 70 | + let chunkCount = 0; | |
| 71 | + const r = await Promise.race([ | |
| 72 | + lc.callAgent(proj.pid, 'devloop-pm', userPrompt, { | |
| 73 | + mock: false, | |
| 74 | + onChunk: () => { chunkCount++; }, | |
| 75 | + }), | |
| 76 | + new Promise((_, rej) => | |
| 77 | + setTimeout(() => rej(new Error('hard timeout 5min reached')), HARD_TIMEOUT_MS), | |
| 78 | + ), | |
| 79 | + ]); | |
| 80 | + const elapsedSec = Math.round((Date.now() - startMs) / 1000); | |
| 81 | + | |
| 82 | + console.log('\n▸ result:'); | |
| 83 | + console.log(` elapsed: ${elapsedSec}s`); | |
| 84 | + console.log(` stream chunks: ${chunkCount}`); | |
| 85 | + console.log(` result text: ${r.result_text.slice(0, 120).replace(/\n/g, ' ')}${r.result_text.length > 120 ? '…' : ''}`); | |
| 86 | + console.log(` files_written: ${r.files_written.length}`); | |
| 87 | + for (const f of r.files_written.slice(0, 10)) console.log(` · ${f}`); | |
| 88 | + if (r.files_written.length > 10) console.log(` · (+${r.files_written.length - 10} more)`); | |
| 89 | + console.log(` commit: ${r.commit?.sha?.slice(0, 7) || '(none)'}`); | |
| 90 | + | |
| 91 | + console.log('\n┌─ Wire-up status ────────────────────────────────'); | |
| 92 | + const checks = [ | |
| 93 | + ['process spawn', true], | |
| 94 | + ['stream chunks', chunkCount > 0], | |
| 95 | + ['result text', r.result_text.length > 0], | |
| 96 | + ['files written', r.files_written.length > 0], | |
| 97 | + ['git commit', !!r.commit?.sha], | |
| 98 | + ]; | |
| 99 | + let pass = 0; | |
| 100 | + for (const [k, ok] of checks) { | |
| 101 | + console.log(`│ ${ok ? '✓' : '✗'} ${k}`); | |
| 102 | + if (ok) pass++; | |
| 103 | + } | |
| 104 | + console.log(`└─ ${pass}/${checks.length} checks passed\n`); | |
| 105 | + | |
| 106 | + console.log('Artifacts retained for inspection:'); | |
| 107 | + console.log(` ${pr.resolveProjectRoot(proj.pid)}`); | |
| 108 | + console.log(` cleanup: rm -rf data/companies/${cid}\n`); | |
| 109 | + | |
| 110 | + process.exit(pass === checks.length ? 0 : 1); | |
| 111 | +} | |
| 112 | + | |
| 113 | +main().catch(e => { | |
| 114 | + console.error('\n[real-pm-smoke] FATAL:', e?.message || e); | |
| 115 | + console.error(e?.stack); | |
| 116 | + process.exit(1); | |
| 117 | +}); | ... | ... |
scripts/self-bootstrap.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * scripts/self-bootstrap.js | |
| 4 | + * | |
| 5 | + * "自举乐趣" — 让 DevLoop 系统去开发 LemonBoss DevLoop 自己的下一个 feature。 | |
| 6 | + * | |
| 7 | + * 流程模拟一个人在 5173 端口前端发起的完整工作流: | |
| 8 | + * 1. POST /projects — 新需求:给 DevLoop 自己加 /restart 端点 | |
| 9 | + * 2. GET /state — 看 PRD/SDD 出来了 (status=running) | |
| 10 | + * 3. POST /run — 自动 L1 → L2 → ship (status=shipped) | |
| 11 | + * 4. GET /timeline / bugs / changelog / units — 检查所有产物 | |
| 12 | + * 5. POST /feedback — 反馈追加:支持 force=true 参数 (触发 PM incremental) | |
| 13 | + * 6. POST /run — 再 ship 一次 (PRD rev 1→2 后) | |
| 14 | + * 7. POST /sign-off — 锁定 shipped-final | |
| 15 | + * 8. POST /feedback — 验证锁定后 400 | |
| 16 | + * | |
| 17 | + * 当前 Agent 全部 mock 模式,产出是 fixture-driven 的演示数据。整个流程跑通即 | |
| 18 | + * 证明三层 Loop 协议 + 持久化 + 状态机 + UI 端点完整 wire-up。真切换到 real | |
| 19 | + * Claude CLI 时只需 LEMONBOSS_CLAUDE_MODE=real (或不设),Mock 路径关闭即可。 | |
| 20 | + */ | |
| 21 | + | |
| 22 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | |
| 23 | + | |
| 24 | +const fs = require('fs'); | |
| 25 | +const path = require('path'); | |
| 26 | + | |
| 27 | +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); | |
| 28 | +require('module').globalPaths.push(serverNodeModules); | |
| 29 | +const express = (() => { | |
| 30 | + try { return require('express'); } | |
| 31 | + catch { | |
| 32 | + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); | |
| 33 | + } | |
| 34 | +})(); | |
| 35 | + | |
| 36 | +const pathResolver = require('../server/services/devloop/path-resolver'); | |
| 37 | +const devloopRouter = require('../server/routes/devloop'); | |
| 38 | + | |
| 39 | +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path'); | |
| 40 | +const BOOTSTRAP_IDEA = `Add POST /api/devloop/projects/:pid/restart endpoint: | |
| 41 | + - resets a 'shipped-final' or 'abandoned' project back to 'draft' | |
| 42 | + - re-runs runDocsPhase from scratch with the original requirement | |
| 43 | + - returns { ok, pid, status, idea } | |
| 44 | + - rejects non-terminal states with 400`; | |
| 45 | + | |
| 46 | +const PIPELINE_LOG = []; | |
| 47 | +function logStep(name, info = '') { | |
| 48 | + const ts = new Date().toTimeString().slice(0, 8); | |
| 49 | + console.log(`[${ts}] ▸ ${name}${info ? ' ' + info : ''}`); | |
| 50 | + PIPELINE_LOG.push({ ts, name, info }); | |
| 51 | +} | |
| 52 | + | |
| 53 | +function logArtifact(name, value) { | |
| 54 | + console.log(` · ${name.padEnd(20)} ${value}`); | |
| 55 | +} | |
| 56 | + | |
| 57 | +async function startApp() { | |
| 58 | + const app = express(); | |
| 59 | + app.use(express.json()); | |
| 60 | + app.use('/api/devloop', devloopRouter); | |
| 61 | + return new Promise(r => { | |
| 62 | + const s = app.listen(0, '127.0.0.1', () => { | |
| 63 | + r({ srv: s, base: `http://127.0.0.1:${s.address().port}` }); | |
| 64 | + }); | |
| 65 | + }); | |
| 66 | +} | |
| 67 | + | |
| 68 | +async function J(url, opts = {}) { | |
| 69 | + const r = await fetch(url, opts); | |
| 70 | + let j; try { j = await r.json(); } catch { j = null; } | |
| 71 | + return { status: r.status, body: j }; | |
| 72 | +} | |
| 73 | + | |
| 74 | +async function main() { | |
| 75 | + console.log('\n╔══════════════════════════════════════════════════════════════════╗'); | |
| 76 | + console.log('║ DevLoop · 自举测试 (Self-Bootstrap) ║'); | |
| 77 | + console.log('║ 让系统去开发自己的下一个 feature ║'); | |
| 78 | + console.log('╚══════════════════════════════════════════════════════════════════╝\n'); | |
| 79 | + | |
| 80 | + console.log('Meta-Requirement:'); | |
| 81 | + for (const line of BOOTSTRAP_IDEA.split('\n')) console.log(' ' + line); | |
| 82 | + console.log(''); | |
| 83 | + | |
| 84 | + const { srv, base } = await startApp(); | |
| 85 | + const cid = 'c-self-' + Date.now().toString(36); | |
| 86 | + let pid; | |
| 87 | + | |
| 88 | + try { | |
| 89 | + // ─── Step 1: 提交新需求 ─── | |
| 90 | + logStep('Step 1 / POST /projects (new requirement)'); | |
| 91 | + { | |
| 92 | + const r = await J(`${base}/api/devloop/projects`, { | |
| 93 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 94 | + body: JSON.stringify({ | |
| 95 | + company_id: cid, | |
| 96 | + name: 'devloop-self-restart', | |
| 97 | + requirement: BOOTSTRAP_IDEA, | |
| 98 | + mockFixtureDir: HAPPY_DIR, | |
| 99 | + }), | |
| 100 | + }); | |
| 101 | + if (r.status !== 200) throw new Error('POST /projects: ' + r.status + ' / ' + JSON.stringify(r.body)); | |
| 102 | + pid = r.body.pid; | |
| 103 | + logArtifact('pid', pid); | |
| 104 | + logArtifact('pm files', r.body.pm.files_written); | |
| 105 | + logArtifact('architect files', r.body.architect.files_written); | |
| 106 | + logArtifact('PM commit', r.body.pm.commit?.slice(0, 7)); | |
| 107 | + logArtifact('Arch commit', r.body.architect.commit?.slice(0, 7)); | |
| 108 | + } | |
| 109 | + | |
| 110 | + // ─── Step 2: 读 state ─── | |
| 111 | + logStep('Step 2 / GET /state'); | |
| 112 | + { | |
| 113 | + const r = await J(`${base}/api/devloop/projects/${pid}/state`); | |
| 114 | + logArtifact('status', r.body.status); | |
| 115 | + logArtifact('prd_rev', r.body.prd_rev); | |
| 116 | + logArtifact('sdd_rev', r.body.sdd_rev); | |
| 117 | + logArtifact('units', r.body.units?.map(u => `${u.id}:${u.status}`).join(', ')); | |
| 118 | + logArtifact('idea', r.body.project?.idea_summary?.slice(0, 60)); | |
| 119 | + } | |
| 120 | + | |
| 121 | + // ─── Step 3: 跑 L1 → L2 → ship ─── | |
| 122 | + logStep('Step 3 / POST /run (runProject — drives L1 + L2 + ship)'); | |
| 123 | + { | |
| 124 | + const r = await J(`${base}/api/devloop/projects/${pid}/run`, { | |
| 125 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 126 | + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }), | |
| 127 | + }); | |
| 128 | + if (r.status !== 200) throw new Error('POST /run: ' + r.status); | |
| 129 | + logArtifact('final_state', r.body.final_state); | |
| 130 | + logArtifact('l2_rounds', r.body.l2_rounds); | |
| 131 | + logArtifact('run_id', r.body.run_id); | |
| 132 | + logArtifact('L1+L2 phases', r.body.history?.length); | |
| 133 | + } | |
| 134 | + | |
| 135 | + // ─── Step 4: 4 个 Ops 子端点全部检查 ─── | |
| 136 | + logStep('Step 4 / GET /bugs · /timeline · /changelog · /units/todo-be'); | |
| 137 | + { | |
| 138 | + const bugs = await J(`${base}/api/devloop/projects/${pid}/bugs`); | |
| 139 | + const tl = await J(`${base}/api/devloop/projects/${pid}/timeline?limit=20`); | |
| 140 | + const cl = await J(`${base}/api/devloop/projects/${pid}/changelog`); | |
| 141 | + const unit = await J(`${base}/api/devloop/projects/${pid}/units/todo-be`); | |
| 142 | + logArtifact('bugs total', `${bugs.body.total} (statuses: ${bugs.body.bugs.map(b => b.status).join(', ')})`); | |
| 143 | + logArtifact('timeline events', tl.body.total); | |
| 144 | + logArtifact('changelog entries', cl.body.total); | |
| 145 | + logArtifact('todo-be runs', unit.body.runs?.length); | |
| 146 | + logArtifact('todo-be status', unit.body.unit?.status); | |
| 147 | + } | |
| 148 | + | |
| 149 | + // ─── Step 5: human feedback ─── | |
| 150 | + logStep('Step 5 / POST /feedback (iterate the spec)'); | |
| 151 | + { | |
| 152 | + const r = await J(`${base}/api/devloop/projects/${pid}/feedback`, { | |
| 153 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 154 | + body: JSON.stringify({ | |
| 155 | + text: 'Also add ?from=<rev> query param so /restart can rewind to a specific PRD rev. Default current rev.', | |
| 156 | + by: 'yibo', | |
| 157 | + mockFixtureDir: HAPPY_DIR, | |
| 158 | + }), | |
| 159 | + }); | |
| 160 | + if (r.status !== 200) throw new Error('POST /feedback: ' + r.status); | |
| 161 | + logArtifact('prd_rev', `${r.body.prd_rev_before} → ${r.body.prd_rev_after}`); | |
| 162 | + logArtifact('status', r.body.status); | |
| 163 | + logArtifact('files', r.body.files_written?.length); | |
| 164 | + } | |
| 165 | + | |
| 166 | + // ─── Step 6: 重新跑一遍 ─── | |
| 167 | + logStep('Step 6 / POST /run (re-ship after feedback)'); | |
| 168 | + { | |
| 169 | + const r = await J(`${base}/api/devloop/projects/${pid}/run`, { | |
| 170 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 171 | + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }), | |
| 172 | + }); | |
| 173 | + logArtifact('final_state', r.body.final_state); | |
| 174 | + } | |
| 175 | + | |
| 176 | + // ─── Step 7: sign off ─── | |
| 177 | + logStep('Step 7 / POST /sign-off (human approves)'); | |
| 178 | + { | |
| 179 | + const r = await J(`${base}/api/devloop/projects/${pid}/sign-off`, { | |
| 180 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 181 | + body: JSON.stringify({ by: 'yibo' }), | |
| 182 | + }); | |
| 183 | + logArtifact('status', r.body.status); | |
| 184 | + } | |
| 185 | + | |
| 186 | + // ─── Step 8: lock-out check ─── | |
| 187 | + logStep('Step 8 / POST /feedback after sign-off (should reject)'); | |
| 188 | + { | |
| 189 | + const r = await J(`${base}/api/devloop/projects/${pid}/feedback`, { | |
| 190 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 191 | + body: JSON.stringify({ text: 'too late' }), | |
| 192 | + }); | |
| 193 | + logArtifact('status code', r.status); | |
| 194 | + logArtifact('error', r.body.error); | |
| 195 | + logArtifact('from', r.body.from); | |
| 196 | + } | |
| 197 | + | |
| 198 | + // ─── 总结 ─── | |
| 199 | + console.log('\n╭─ Self-Bootstrap Summary ─────────────────────────────────────────╮'); | |
| 200 | + const root = pathResolver.resolveProjectRoot(pid); | |
| 201 | + console.log(`│ project root: ${root}`); | |
| 202 | + const inventory = [ | |
| 203 | + 'project.json', | |
| 204 | + 'prd/index.html', | |
| 205 | + 'prd/feature/feature-tree.html', | |
| 206 | + 'sdd/index.html', | |
| 207 | + 'sdd/mvcp/control.html', | |
| 208 | + 'units.json', | |
| 209 | + 'integration/spec.json', | |
| 210 | + 'integration/runs/run-mock-baseline/result.json', | |
| 211 | + 'timeline.ndjson', | |
| 212 | + 'changelog.ndjson', | |
| 213 | + 'bugs.ndjson', | |
| 214 | + 'stream.log', | |
| 215 | + 'history/prd-rev-001.html', | |
| 216 | + 'history/prd-rev-002.html', | |
| 217 | + 'history/sdd-rev-001.html', | |
| 218 | + '.git', | |
| 219 | + ]; | |
| 220 | + for (const f of inventory) { | |
| 221 | + const p = path.join(root, f); | |
| 222 | + const exists = fs.existsSync(p); | |
| 223 | + console.log(`│ ${exists ? '✓' : '✗'} ${f}`); | |
| 224 | + } | |
| 225 | + | |
| 226 | + // git log | |
| 227 | + console.log('│'); | |
| 228 | + console.log(`│ git commits (auto-generated by LoopController.commitAfterAgent):`); | |
| 229 | + const { execSync } = require('child_process'); | |
| 230 | + try { | |
| 231 | + const log = execSync(`git -C "${root}" log --oneline -20`, { encoding: 'utf8' }).trim().split('\n'); | |
| 232 | + for (const line of log) console.log(`│ ${line}`); | |
| 233 | + } catch (e) { console.log(`│ (git log failed: ${e.message})`); } | |
| 234 | + | |
| 235 | + console.log('╰──────────────────────────────────────────────────────────────────╯\n'); | |
| 236 | + | |
| 237 | + console.log('Result: DevLoop 成功地为自己"开发"了一个新 feature, 通过完整 L1+L2+L3 pipeline'); | |
| 238 | + console.log('完成 → ship → human iterate (PRD rev 2) → ship → sign-off → 拒绝后续修改。\n'); | |
| 239 | + console.log('生产模式接 real Claude CLI: 删 LEMONBOSS_CLAUDE_MODE=mock 即可,流程不动。\n'); | |
| 240 | + | |
| 241 | + } finally { | |
| 242 | + srv.close(); | |
| 243 | + // 保留产物供查阅,不清理 | |
| 244 | + if (pid) { | |
| 245 | + console.log(`(artifacts retained at: data/companies/${cid}/projects/${pid}/)`); | |
| 246 | + console.log(`若要清理: rm -rf data/companies/${cid}`); | |
| 247 | + } | |
| 248 | + } | |
| 249 | +} | |
| 250 | + | |
| 251 | +main().catch(e => { | |
| 252 | + console.error('\n[self-bootstrap] FATAL:', e); | |
| 253 | + process.exit(1); | |
| 254 | +}); | ... | ... |
server/agent-server.js
| ... | ... | @@ -67,6 +67,7 @@ app.use('/api/company', require('./routes/company')); |
| 67 | 67 | app.use('/api/goals', require('./routes/goals')); |
| 68 | 68 | app.use('/api/tasks', require('./routes/tasks')); |
| 69 | 69 | app.use('/api/devloop', require('./routes/devloop')); |
| 70 | +app.use('/public', require('express').static(require('path').join(__dirname, 'public'))); | |
| 70 | 71 | |
| 71 | 72 | // Health |
| 72 | 73 | app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' })); | ... | ... |
server/index.js
| ... | ... | @@ -94,6 +94,7 @@ app.use('/api/goals', require('./routes/goals')); |
| 94 | 94 | app.use('/api/company', require('./routes/company')); |
| 95 | 95 | app.use('/api/tasks', require('./routes/tasks')); |
| 96 | 96 | app.use('/api/devloop', require('./routes/devloop')); |
| 97 | +app.use('/public', require('express').static(require('path').join(__dirname, 'public'))); | |
| 97 | 98 | |
| 98 | 99 | // ─── 系统端点 ─── |
| 99 | 100 | app.get('/api/health', (req, res) => res.json({ | ... | ... |
server/public/devloop-status.html
0 → 100644
| 1 | +<!doctype html> | |
| 2 | +<html lang="zh-CN"> | |
| 3 | +<head> | |
| 4 | +<meta charset="utf-8" /> | |
| 5 | +<title>DevLoop · Project Status</title> | |
| 6 | +<style> | |
| 7 | + body { font-family: system-ui, -apple-system, sans-serif; max-width: 960px; margin: 24px auto; padding: 0 16px; color: #222; } | |
| 8 | + header { display:flex; justify-content:space-between; align-items:baseline; border-bottom:1px solid #eee; padding-bottom:8px; margin-bottom:16px; } | |
| 9 | + h1 { font-size: 18px; margin: 0; } | |
| 10 | + .pid { color:#888; font-family:monospace; font-size:13px; } | |
| 11 | + .row { display:flex; gap:16px; flex-wrap:wrap; margin-bottom:16px; } | |
| 12 | + .card { background:#f7f7f9; border:1px solid #e3e3e8; border-radius:6px; padding:12px 16px; min-width:160px; } | |
| 13 | + .card label { font-size:11px; text-transform:uppercase; color:#888; display:block; margin-bottom:4px; } | |
| 14 | + .card .v { font-size:20px; font-weight:600; } | |
| 15 | + .units { margin-top:8px; } | |
| 16 | + .unit { display:flex; align-items:center; padding:6px 10px; background:#fff; border:1px solid #e3e3e8; border-radius:4px; margin-bottom:4px; gap:12px; } | |
| 17 | + .unit .id { flex:1; font-family:monospace; } | |
| 18 | + .unit .deps { color:#888; font-size:12px; } | |
| 19 | + .status { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; text-transform:uppercase; } | |
| 20 | + .status.pending { background:#eee; color:#666; } | |
| 21 | + .status.coding { background:#fff3cd; color:#856404; } | |
| 22 | + .status.testing { background:#cce5ff; color:#004085; } | |
| 23 | + .status.passed { background:#d4edda; color:#155724; } | |
| 24 | + .status.failed { background:#f8d7da; color:#721c24; } | |
| 25 | + .status.blocked { background:#d6d8db; color:#383d41; } | |
| 26 | + .timeline { font-family:monospace; font-size:12px; max-height:240px; overflow:auto; background:#0f1115; color:#e6e6e6; padding:12px; border-radius:6px; } | |
| 27 | + .timeline .ts { color:#7da; margin-right:8px; } | |
| 28 | + .timeline .kind { color:#ffce6a; margin-right:8px; } | |
| 29 | + input { font-family:monospace; padding:4px 8px; border:1px solid #ccc; border-radius:4px; } | |
| 30 | + button { padding:4px 12px; background:#222; color:white; border:none; border-radius:4px; cursor:pointer; } | |
| 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; } | |
| 47 | +</style> | |
| 48 | +</head> | |
| 49 | +<body> | |
| 50 | +<header> | |
| 51 | + <h1>DevLoop · Project Status</h1> | |
| 52 | + <div> | |
| 53 | + <input id="pidInput" placeholder="p-xxxxxx" /> | |
| 54 | + <button id="connectBtn">Connect</button> | |
| 55 | + </div> | |
| 56 | +</header> | |
| 57 | + | |
| 58 | +<div id="err" class="err"></div> | |
| 59 | + | |
| 60 | +<div id="content" style="display:none"> | |
| 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"> | |
| 75 | + <div class="card"><label>PRD rev</label><div class="v" id="prdRev">-</div></div> | |
| 76 | + <div class="card"><label>SDD rev</label><div class="v" id="sddRev">-</div></div> | |
| 77 | + <div class="card"><label>Units</label><div class="v" id="unitsCount">0</div></div> | |
| 78 | + </div> | |
| 79 | + <h2 style="font-size:14px;margin:16px 0 8px">Units (DAG)</h2> | |
| 80 | + <div class="units" id="units"></div> | |
| 81 | + <h2 style="font-size:14px;margin:16px 0 8px">Recent timeline events</h2> | |
| 82 | + <div class="timeline" id="timeline"></div> | |
| 83 | +</div> | |
| 84 | + | |
| 85 | +<script> | |
| 86 | +const $ = id => document.getElementById(id); | |
| 87 | +let pollTimer = null; | |
| 88 | + | |
| 89 | +async function fetchState(pid) { | |
| 90 | + const r = await fetch(`/api/devloop/projects/${pid}/state`); | |
| 91 | + if (!r.ok) throw new Error(`state: ${r.status}`); | |
| 92 | + return r.json(); | |
| 93 | +} | |
| 94 | + | |
| 95 | +function render(s) { | |
| 96 | + $('pidShow').textContent = s.pid; | |
| 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; | |
| 102 | + $('prdRev').textContent = s.prd_rev ?? '-'; | |
| 103 | + $('sddRev').textContent = s.sdd_rev ?? '-'; | |
| 104 | + const units = s.units || []; | |
| 105 | + $('unitsCount').textContent = units.length; | |
| 106 | + $('units').innerHTML = units.map(u => ` | |
| 107 | + <div class="unit"> | |
| 108 | + <span class="id">${u.id}</span> | |
| 109 | + <span class="deps">deps: ${(u.depends_on || []).join(', ') || '∅'}</span> | |
| 110 | + <span class="status ${u.status}">${u.status}</span> | |
| 111 | + </div>`).join(''); | |
| 112 | + $('timeline').innerHTML = (s.timeline_tail || []).map(e => ` | |
| 113 | + <div><span class="ts">${e.ts || ''}</span><span class="kind">[${e.kind}]</span>${e.summary || ''}</div> | |
| 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'; | |
| 120 | +} | |
| 121 | + | |
| 122 | +async function connect() { | |
| 123 | + const pid = $('pidInput').value.trim(); | |
| 124 | + if (!pid) return; | |
| 125 | + $('err').textContent = ''; | |
| 126 | + if (pollTimer) clearInterval(pollTimer); | |
| 127 | + async function poll() { | |
| 128 | + try { | |
| 129 | + const s = await fetchState(pid); | |
| 130 | + render(s); | |
| 131 | + $('content').style.display = ''; | |
| 132 | + } catch (e) { | |
| 133 | + $('err').textContent = e.message; | |
| 134 | + $('content').style.display = 'none'; | |
| 135 | + } | |
| 136 | + } | |
| 137 | + await poll(); | |
| 138 | + pollTimer = setInterval(poll, 2000); | |
| 139 | +} | |
| 140 | + | |
| 141 | +$('connectBtn').addEventListener('click', connect); | |
| 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 | + | |
| 182 | +const fromQuery = new URLSearchParams(location.search).get('pid'); | |
| 183 | +if (fromQuery) { $('pidInput').value = fromQuery; connect(); } | |
| 184 | +</script> | |
| 185 | +</body> | |
| 186 | +</html> | ... | ... |
server/routes/devloop.js
| ... | ... | @@ -20,6 +20,18 @@ const router = express.Router(); |
| 20 | 20 | |
| 21 | 21 | const loopController = require('../src/core/devloop/loop-controller'); |
| 22 | 22 | const pathResolver = require('../services/devloop/path-resolver'); |
| 23 | +const sseBroker = require('../services/devloop/sse-broker'); | |
| 24 | +const bugTracker = require('../services/devloop/bug-tracker'); | |
| 25 | +const fs = require('fs'); | |
| 26 | +const os = require('os'); | |
| 27 | +const path = require('path'); | |
| 28 | +const multer = require('multer'); | |
| 29 | + | |
| 30 | +// 单次 intake 上传:内存解析后我们自己写盘到项目目录。10MB / 文件,10 文件上限。 | |
| 31 | +const intakeUpload = multer({ | |
| 32 | + storage: multer.memoryStorage(), | |
| 33 | + limits: { fileSize: 10 * 1024 * 1024, files: 10 }, | |
| 34 | +}); | |
| 23 | 35 | |
| 24 | 36 | const h = (fn) => async (req, res) => { |
| 25 | 37 | try { await fn(req, res); } |
| ... | ... | @@ -31,25 +43,49 @@ const h = (fn) => async (req, res) => { |
| 31 | 43 | |
| 32 | 44 | // POST /api/devloop/projects |
| 33 | 45 | router.post('/projects', h(async (req, res) => { |
| 34 | - const { company_id, requirement, mode, target_path, mock, name } = req.body || {}; | |
| 46 | + const { company_id, requirement, mode, target_path, mock, name, mockFixtureDir } = req.body || {}; | |
| 35 | 47 | if (!company_id) { |
| 36 | 48 | return res.status(400).json({ error: 'missing field: company_id' }); |
| 37 | 49 | } |
| 38 | 50 | if (!requirement || typeof requirement !== 'string') { |
| 39 | 51 | return res.status(400).json({ error: 'missing field: requirement' }); |
| 40 | 52 | } |
| 53 | + if ((mode === 'linked-new' || mode === 'linked-existing') && !target_path) { | |
| 54 | + return res.status(400).json({ error: `missing field: target_path (required for ${mode})` }); | |
| 55 | + } | |
| 56 | + if (mode && !['sandbox', 'linked-new', 'linked-existing'].includes(mode)) { | |
| 57 | + return res.status(400).json({ error: `invalid mode: ${mode} (must be sandbox | linked-new | linked-existing)` }); | |
| 58 | + } | |
| 41 | 59 | |
| 42 | - // 1. 创建项目 (sandbox 默认) | |
| 43 | - const proj = await loopController.createProject({ | |
| 44 | - company_id, | |
| 45 | - mode: mode || 'sandbox', | |
| 46 | - target_path, | |
| 47 | - idea: name ? `${name}: ${requirement}` : requirement, | |
| 48 | - }); | |
| 60 | + // 1. 创建项目 (sandbox 默认 / linked-new 用 target_path) | |
| 61 | + let proj; | |
| 62 | + try { | |
| 63 | + proj = await loopController.createProject({ | |
| 64 | + company_id, | |
| 65 | + mode: mode || 'sandbox', | |
| 66 | + target_path, | |
| 67 | + idea: name ? `${name}: ${requirement}` : requirement, | |
| 68 | + }); | |
| 69 | + } catch (e) { | |
| 70 | + // 用户输入级错误返 400;其他保留 500 | |
| 71 | + if (e.code === 'SENSITIVE_PATH' | |
| 72 | + || e.code === 'DIRTY_WORKTREE' | |
| 73 | + || /target_path|parent dir|not empty|must be absolute|does not exist|not a directory|already contains|uncommitted changes/i.test(e.message)) { | |
| 74 | + return res.status(400).json({ error: e.message }); | |
| 75 | + } | |
| 76 | + throw e; | |
| 77 | + } | |
| 78 | + | |
| 79 | + // 2. 把 name 作为一等字段写入 project.json (供列表 / 改名共用) | |
| 80 | + if (name) { | |
| 81 | + try { pathResolver.patchProjectMeta(proj.pid, { name }); } | |
| 82 | + catch (e) { console.warn('[DevLoop] failed to set name:', e.message); } | |
| 83 | + } | |
| 49 | 84 | |
| 50 | - // 2. PM → Architect (mock 模式) | |
| 85 | + // 3. PM → Architect (mock 模式) — mockFixtureDir 可选,P3/P4 scenario 测试用 | |
| 51 | 86 | const phase = await loopController.runDocsPhase(proj.pid, requirement, { |
| 52 | 87 | mock: mock !== false, |
| 88 | + mockFixtureDir, | |
| 53 | 89 | }); |
| 54 | 90 | |
| 55 | 91 | res.json({ |
| ... | ... | @@ -70,44 +106,306 @@ router.post('/projects', h(async (req, res) => { |
| 70 | 106 | }); |
| 71 | 107 | })); |
| 72 | 108 | |
| 73 | -// GET /api/devloop/projects | |
| 74 | -router.get('/projects', h(async (_req, res) => { | |
| 75 | - const projects = pathResolver.listProjects(); | |
| 109 | +// GET /api/devloop/projects?include_archived=true | |
| 110 | +router.get('/projects', h(async (req, res) => { | |
| 111 | + const includeArchived = String(req.query.include_archived || '') === 'true'; | |
| 112 | + const projects = pathResolver.listProjects({ includeArchived }); | |
| 76 | 113 | res.json({ projects }); |
| 77 | 114 | })); |
| 78 | 115 | |
| 79 | -// POST /api/devloop/projects/:pid/run?only=<unitId> | |
| 80 | -// P2: only=<unitId> 跑单 unit;不带 only → 501 (P3 才补全 runProject) | |
| 116 | +// GET /api/devloop/fs/browse?path=/abs/path | |
| 117 | +// 列出指定绝对路径下的子目录(供新建项目时的"指定本机目录"选择用)。 | |
| 118 | +// - 不传 path → 用 $HOME | |
| 119 | +// - 拒绝 sensitive 路径 | |
| 120 | +// - 仅返回目录,不返回文件 | |
| 121 | +router.get('/fs/browse', h(async (req, res) => { | |
| 122 | + let p = req.query.path ? String(req.query.path) : os.homedir(); | |
| 123 | + if (!path.isAbsolute(p)) { | |
| 124 | + return res.status(400).json({ error: 'path must be absolute' }); | |
| 125 | + } | |
| 126 | + p = path.resolve(p); | |
| 127 | + if (pathResolver.isSensitivePath(p)) { | |
| 128 | + return res.status(403).json({ error: `sensitive_path: ${p} is blacklisted` }); | |
| 129 | + } | |
| 130 | + if (!fs.existsSync(p)) { | |
| 131 | + return res.status(404).json({ error: `not found: ${p}` }); | |
| 132 | + } | |
| 133 | + const stat = fs.statSync(p); | |
| 134 | + if (!stat.isDirectory()) { | |
| 135 | + return res.status(400).json({ error: `not a directory: ${p}` }); | |
| 136 | + } | |
| 137 | + let entries = []; | |
| 138 | + try { | |
| 139 | + entries = fs.readdirSync(p, { withFileTypes: true }) | |
| 140 | + .filter(d => d.isDirectory() && !d.name.startsWith('.')) | |
| 141 | + .map(d => { | |
| 142 | + const abs = path.join(p, d.name); | |
| 143 | + let is_empty = false; | |
| 144 | + try { is_empty = fs.readdirSync(abs).length === 0; } catch { /* permission denied 当作非空 */ } | |
| 145 | + return { name: d.name, abs_path: abs, is_empty }; | |
| 146 | + }) | |
| 147 | + .sort((a, b) => a.name.localeCompare(b.name)); | |
| 148 | + } catch (e) { | |
| 149 | + return res.status(403).json({ error: `cannot read: ${e.message}` }); | |
| 150 | + } | |
| 151 | + const parent = path.dirname(p); | |
| 152 | + res.json({ | |
| 153 | + path: p, | |
| 154 | + parent: parent === p ? null : parent, // 根目录 parent === p | |
| 155 | + home: os.homedir(), | |
| 156 | + entries, | |
| 157 | + }); | |
| 158 | +})); | |
| 159 | + | |
| 160 | +// PATCH /api/devloop/projects/:pid | |
| 161 | +// body: { name?, idea_summary? } 仅允许这两个字段(status 走 sign-off / abandon / feedback) | |
| 162 | +router.patch('/projects/:pid', h(async (req, res) => { | |
| 163 | + const { pid } = req.params; | |
| 164 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 165 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 166 | + const allowed = ['name', 'idea_summary']; | |
| 167 | + const patch = {}; | |
| 168 | + for (const k of allowed) { | |
| 169 | + if (req.body && Object.prototype.hasOwnProperty.call(req.body, k)) { | |
| 170 | + const v = req.body[k]; | |
| 171 | + if (typeof v !== 'string') return res.status(400).json({ error: `${k} must be string` }); | |
| 172 | + patch[k] = v.slice(0, 500); | |
| 173 | + } | |
| 174 | + } | |
| 175 | + if (Object.keys(patch).length === 0) { | |
| 176 | + return res.status(400).json({ error: 'no editable fields in body (allowed: name, idea_summary)' }); | |
| 177 | + } | |
| 178 | + const meta = pathResolver.patchProjectMeta(pid, patch); | |
| 179 | + res.json({ ok: true, pid, project: meta }); | |
| 180 | +})); | |
| 181 | + | |
| 182 | +// DELETE /api/devloop/projects/:pid?hard=true | |
| 183 | +// 默认软删 (archived=true);hard=true 时递归删目录 (sandbox) 或解注册 (linked) | |
| 184 | +router.delete('/projects/:pid', h(async (req, res) => { | |
| 185 | + const { pid } = req.params; | |
| 186 | + const hard = String(req.query.hard || '') === 'true'; | |
| 187 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 188 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 189 | + if (hard) { | |
| 190 | + const r = pathResolver.hardDeleteProject(pid); | |
| 191 | + return res.json({ ok: true, pid, hard: true, ...r }); | |
| 192 | + } | |
| 193 | + const meta = pathResolver.patchProjectMeta(pid, { | |
| 194 | + archived: true, | |
| 195 | + archived_at: new Date().toISOString(), | |
| 196 | + }); | |
| 197 | + res.json({ ok: true, pid, hard: false, archived: true, project: meta }); | |
| 198 | +})); | |
| 199 | + | |
| 200 | +// POST /api/devloop/projects/:pid/restore — 把 archived=true 的项目恢复 | |
| 201 | +router.post('/projects/:pid/restore', h(async (req, res) => { | |
| 202 | + const { pid } = req.params; | |
| 203 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 204 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 205 | + const meta = pathResolver.patchProjectMeta(pid, { archived: false, archived_at: null }); | |
| 206 | + res.json({ ok: true, pid, archived: false, project: meta }); | |
| 207 | +})); | |
| 208 | + | |
| 209 | +// POST /api/devloop/projects/:pid/run[?only=<unitId>] [body: { mockFixtureDir? }] | |
| 210 | +// - 不带 only: 跑完整 runProject (P3) | |
| 211 | +// - only=<unitId>: 单 unit drill-down (P2 兼容) | |
| 81 | 212 | router.post('/projects/:pid/run', h(async (req, res) => { |
| 82 | 213 | const { pid } = req.params; |
| 83 | 214 | const { only } = req.query; |
| 84 | - if (!only) { | |
| 85 | - return res.status(501).json({ error: 'P2 only supports ?only=<unitId>; full project run lands in P3' }); | |
| 86 | - } | |
| 215 | + const { mockFixtureDir } = req.body || {}; | |
| 87 | 216 | try { |
| 88 | - const result = await loopController.runUnit(pid, only, { mock: true }); | |
| 89 | - res.json({ | |
| 90 | - ok: result.ok, | |
| 91 | - pid, unit_id: only, | |
| 92 | - attempts: result.attempts, | |
| 217 | + if (only) { | |
| 218 | + const result = await loopController.runUnit(pid, only, { mock: true, mockFixtureDir }); | |
| 219 | + return res.json({ | |
| 220 | + ok: result.ok, pid, unit_id: only, | |
| 221 | + attempts: result.attempts, final_state: result.final_state, | |
| 222 | + history: result.history.map(h => ({ | |
| 223 | + agent: h.agent, attempt: h.attempt, | |
| 224 | + ok: h.ok, files: h.files?.length || 0, | |
| 225 | + commit: h.commit || null, | |
| 226 | + })), | |
| 227 | + }); | |
| 228 | + } | |
| 229 | + // 完整 runProject | |
| 230 | + const result = await loopController.runProject(pid, { mock: true, mockFixtureDir }); | |
| 231 | + return res.json({ | |
| 232 | + ok: result.ok, pid, | |
| 93 | 233 | final_state: result.final_state, |
| 94 | - history: result.history.map(h => ({ | |
| 95 | - agent: h.agent, attempt: h.attempt, | |
| 96 | - ok: h.ok, files: h.files?.length || 0, | |
| 97 | - commit: h.commit || null, | |
| 98 | - })), | |
| 234 | + l2_rounds: result.l2_rounds, | |
| 235 | + run_id: result.run_id || null, | |
| 236 | + reason: result.reason || null, | |
| 237 | + history: result.history, | |
| 99 | 238 | }); |
| 100 | 239 | } catch (e) { |
| 101 | 240 | if (/Unknown project/.test(e.message)) { |
| 102 | 241 | return res.status(404).json({ error: 'unknown project: ' + pid }); |
| 103 | 242 | } |
| 104 | 243 | if (/not found in project/.test(e.message)) { |
| 105 | - return res.status(404).json({ error: 'unknown unit: ' + only }); | |
| 244 | + return res.status(404).json({ error: 'unknown unit: ' + (only || '?') }); | |
| 106 | 245 | } |
| 107 | 246 | throw e; |
| 108 | 247 | } |
| 109 | 248 | })); |
| 110 | 249 | |
| 250 | +// GET /api/devloop/projects/:pid/stream — SSE | |
| 251 | +// client may send Last-Event-ID header → broker replays from there | |
| 252 | +router.get('/projects/:pid/stream', (req, res) => { | |
| 253 | + const { pid } = req.params; | |
| 254 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 255 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 256 | + | |
| 257 | + res.setHeader('Content-Type', 'text/event-stream'); | |
| 258 | + res.setHeader('Cache-Control', 'no-cache'); | |
| 259 | + res.setHeader('Connection', 'keep-alive'); | |
| 260 | + res.flushHeaders?.(); | |
| 261 | + | |
| 262 | + const lastEventId = req.headers['last-event-id'] || req.query.lastEventId || null; | |
| 263 | + sseBroker.subscribe(pid, res, { | |
| 264 | + replayFromOffset: lastEventId != null ? lastEventId : undefined, | |
| 265 | + }); | |
| 266 | +}); | |
| 267 | + | |
| 268 | +// P4: POST /api/devloop/projects/:pid/sign-off | |
| 269 | +// body: { by?, note? } | |
| 270 | +// 400 if status !== 'shipped' | |
| 271 | +router.post('/projects/:pid/sign-off', h(async (req, res) => { | |
| 272 | + const { pid } = req.params; | |
| 273 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 274 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 275 | + try { | |
| 276 | + const meta = await loopController.signOff(pid, req.body || {}); | |
| 277 | + res.json({ ok: true, pid, status: meta.status }); | |
| 278 | + } catch (e) { | |
| 279 | + if (e.name === 'InvalidProjectStatusTransition') { | |
| 280 | + return res.status(400).json({ error: e.message, from: e.from, to: e.to }); | |
| 281 | + } | |
| 282 | + throw e; | |
| 283 | + } | |
| 284 | +})); | |
| 285 | + | |
| 286 | +// P4: POST /api/devloop/projects/:pid/abandon | |
| 287 | +// body: { by?, reason?, note? } | |
| 288 | +router.post('/projects/:pid/abandon', h(async (req, res) => { | |
| 289 | + const { pid } = req.params; | |
| 290 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 291 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 292 | + try { | |
| 293 | + const meta = await loopController.abandon(pid, req.body || {}); | |
| 294 | + res.json({ ok: true, pid, status: meta.status }); | |
| 295 | + } catch (e) { | |
| 296 | + if (e.name === 'InvalidProjectStatusTransition') { | |
| 297 | + return res.status(400).json({ error: e.message, from: e.from, to: e.to }); | |
| 298 | + } | |
| 299 | + throw e; | |
| 300 | + } | |
| 301 | +})); | |
| 302 | + | |
| 303 | +// P4: POST /api/devloop/projects/:pid/feedback | |
| 304 | +// body: { text (required), by?, mockFixtureDir? } | |
| 305 | +// 400 if status ∈ {shipped-final, abandoned} | |
| 306 | +router.post('/projects/:pid/feedback', h(async (req, res) => { | |
| 307 | + const { pid } = req.params; | |
| 308 | + const { text, by, mockFixtureDir } = req.body || {}; | |
| 309 | + if (!text) return res.status(400).json({ error: 'missing field: text' }); | |
| 310 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 311 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 312 | + try { | |
| 313 | + const r = await loopController.submitFeedback(pid, text, { by, mockFixtureDir }); | |
| 314 | + res.json(r); | |
| 315 | + } catch (e) { | |
| 316 | + if (e.name === 'InvalidProjectStatusTransition') { | |
| 317 | + return res.status(400).json({ error: e.message, from: e.from, to: e.to }); | |
| 318 | + } | |
| 319 | + throw e; | |
| 320 | + } | |
| 321 | +})); | |
| 322 | + | |
| 323 | +// GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be | |
| 324 | +router.get('/projects/:pid/bugs', h(async (req, res) => { | |
| 325 | + const { pid } = req.params; | |
| 326 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 327 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 328 | + const filter = {}; | |
| 329 | + if (req.query.status) filter.status = String(req.query.status).split(','); | |
| 330 | + if (req.query.unit_id) filter.unit_id = req.query.unit_id; | |
| 331 | + if (req.query.severity) filter.severity = String(req.query.severity).split(','); | |
| 332 | + const bugs = bugTracker.list(pid, filter); | |
| 333 | + res.json({ bugs, total: bugs.length }); | |
| 334 | +})); | |
| 335 | + | |
| 336 | +// P5: GET /api/devloop/projects/:pid/timeline?limit=200 | |
| 337 | +// 读 timeline.ndjson 最近 N 条 (按时间倒序),给 LoopLogView 用 | |
| 338 | +router.get('/projects/:pid/timeline', h(async (req, res) => { | |
| 339 | + const fs = require('fs'); | |
| 340 | + const { pid } = req.params; | |
| 341 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 342 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 343 | + const limit = Math.max(1, Math.min(2000, parseInt(req.query.limit, 10) || 200)); | |
| 344 | + const p = pathResolver.resolveTimelineLog(pid); | |
| 345 | + if (!fs.existsSync(p)) return res.json({ events: [], total: 0 }); | |
| 346 | + const lines = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean); | |
| 347 | + const events = lines.slice(-limit).reverse().map(l => { | |
| 348 | + try { return JSON.parse(l); } catch { return null; } | |
| 349 | + }).filter(Boolean); | |
| 350 | + res.json({ events, total: events.length }); | |
| 351 | +})); | |
| 352 | + | |
| 353 | +// P5: GET /api/devloop/projects/:pid/changelog | |
| 354 | +// 读 changelog.ndjson 全部 (按时间倒序) | |
| 355 | +router.get('/projects/:pid/changelog', h(async (req, res) => { | |
| 356 | + const fs = require('fs'); | |
| 357 | + const { pid } = req.params; | |
| 358 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 359 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 360 | + const p = pathResolver.resolveChangelog(pid); | |
| 361 | + if (!fs.existsSync(p)) return res.json({ entries: [], total: 0 }); | |
| 362 | + const entries = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean) | |
| 363 | + .map(l => { try { return JSON.parse(l); } catch { return null; }}).filter(Boolean) | |
| 364 | + .reverse(); | |
| 365 | + res.json({ entries, total: entries.length }); | |
| 366 | +})); | |
| 367 | + | |
| 368 | +// P5: GET /api/devloop/projects/:pid/units/:unitId | |
| 369 | +// 返回 unit 元数据 + 最近 N 次 runs (Tester result.json) | |
| 370 | +router.get('/projects/:pid/units/:unitId', h(async (req, res) => { | |
| 371 | + const fs = require('fs'); const path = require('path'); | |
| 372 | + const { pid, unitId } = req.params; | |
| 373 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 374 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 375 | + const unitDir = pathResolver.resolveUnitDir(pid, unitId); | |
| 376 | + if (!fs.existsSync(unitDir)) return res.status(404).json({ error: 'unknown unit: ' + unitId }); | |
| 377 | + // 从 units.json 找 unit 元数据 | |
| 378 | + let unitMeta = null; | |
| 379 | + try { | |
| 380 | + const uj = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); | |
| 381 | + unitMeta = (uj.units || []).find(u => u.id === unitId) || null; | |
| 382 | + } catch { /* ignore */ } | |
| 383 | + // runs | |
| 384 | + const runsDir = path.join(unitDir, 'runs'); | |
| 385 | + let runs = []; | |
| 386 | + if (fs.existsSync(runsDir)) { | |
| 387 | + const ids = fs.readdirSync(runsDir).filter(n => { | |
| 388 | + try { return fs.statSync(path.join(runsDir, n)).isDirectory(); } catch { return false; } | |
| 389 | + }).sort().reverse().slice(0, 3); | |
| 390 | + for (const id of ids) { | |
| 391 | + const rp = path.join(runsDir, id, 'result.json'); | |
| 392 | + let r = null; | |
| 393 | + if (fs.existsSync(rp)) { | |
| 394 | + try { r = JSON.parse(fs.readFileSync(rp, 'utf8')); } catch {} | |
| 395 | + } | |
| 396 | + runs.push({ run_id: id, result: r }); | |
| 397 | + } | |
| 398 | + } | |
| 399 | + // coder.log 最后 1KB 节选 | |
| 400 | + let coderLog = null; | |
| 401 | + const clp = path.join(unitDir, 'coder.log'); | |
| 402 | + if (fs.existsSync(clp)) { | |
| 403 | + const raw = fs.readFileSync(clp, 'utf8'); | |
| 404 | + coderLog = raw.length > 1024 ? raw.slice(-1024) : raw; | |
| 405 | + } | |
| 406 | + res.json({ unit: unitMeta, runs, coder_log_tail: coderLog }); | |
| 407 | +})); | |
| 408 | + | |
| 111 | 409 | // GET /api/devloop/projects/:pid/state |
| 112 | 410 | router.get('/projects/:pid/state', h(async (req, res) => { |
| 113 | 411 | try { |
| ... | ... | @@ -121,4 +419,178 @@ router.get('/projects/:pid/state', h(async (req, res) => { |
| 121 | 419 | } |
| 122 | 420 | })); |
| 123 | 421 | |
| 422 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 423 | +// P6 · Requirements 列表 + 新增需求 intake (file upload / dir scan / chat draft) | |
| 424 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 425 | + | |
| 426 | +// GET /api/devloop/projects/:pid/requirements | |
| 427 | +// 返回需求列表:rev=1 为初始需求(取自 project.json.idea_summary), | |
| 428 | +// rev>=2 来自 changelog.ndjson 中 kind=prd_rev 的条目。 | |
| 429 | +router.get('/projects/:pid/requirements', h(async (req, res) => { | |
| 430 | + const { pid } = req.params; | |
| 431 | + let projectMeta; | |
| 432 | + try { | |
| 433 | + projectMeta = JSON.parse(fs.readFileSync(pathResolver.resolveProjectJson(pid), 'utf8')); | |
| 434 | + } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 435 | + | |
| 436 | + const items = []; | |
| 437 | + // rev 1: 初始需求 | |
| 438 | + items.push({ | |
| 439 | + rev: 1, | |
| 440 | + ts: projectMeta.created_at, | |
| 441 | + by: 'init', | |
| 442 | + trigger: 'project_create', | |
| 443 | + text: projectMeta.idea_summary || '', | |
| 444 | + }); | |
| 445 | + | |
| 446 | + // rev>=2: from changelog | |
| 447 | + const clp = pathResolver.resolveChangelog(pid); | |
| 448 | + if (fs.existsSync(clp)) { | |
| 449 | + const lines = fs.readFileSync(clp, 'utf8').trim().split('\n').filter(Boolean); | |
| 450 | + for (const l of lines) { | |
| 451 | + let e; try { e = JSON.parse(l); } catch { continue; } | |
| 452 | + if (e.kind !== 'prd_rev') continue; | |
| 453 | + items.push({ | |
| 454 | + rev: e.rev, | |
| 455 | + ts: e.ts, | |
| 456 | + by: e.by || 'human', | |
| 457 | + trigger: e.trigger || 'human_feedback', | |
| 458 | + text: e.diff?.feedback_excerpt || e.summary || '', | |
| 459 | + }); | |
| 460 | + } | |
| 461 | + } | |
| 462 | + | |
| 463 | + // 派生 status: 最新一条 = current(用项目状态),其余 = older | |
| 464 | + items.sort((a, b) => a.rev - b.rev); | |
| 465 | + const latestRev = items.length ? items[items.length - 1].rev : 0; | |
| 466 | + const projectStatus = projectMeta.status || 'draft'; | |
| 467 | + for (const it of items) { | |
| 468 | + it.status = it.rev === latestRev ? projectStatus : 'older'; | |
| 469 | + it.is_current = it.rev === latestRev; | |
| 470 | + } | |
| 471 | + | |
| 472 | + res.json({ | |
| 473 | + pid, | |
| 474 | + project_status: projectStatus, | |
| 475 | + current_rev: latestRev, | |
| 476 | + items: items.reverse(), // 倒序:最新在前 | |
| 477 | + }); | |
| 478 | +})); | |
| 479 | + | |
| 480 | +// POST /api/devloop/projects/:pid/intake/files | |
| 481 | +// multipart/form-data: files=<file>...; (.md / .txt / .html 才被采纳,其它跳过) | |
| 482 | +// 保存到 projects/{pid}/intake/{ts}/,返回每个文件的提取文本。 | |
| 483 | +router.post('/projects/:pid/intake/files', | |
| 484 | + intakeUpload.array('files', 10), | |
| 485 | + h(async (req, res) => { | |
| 486 | + const { pid } = req.params; | |
| 487 | + let root; | |
| 488 | + try { root = pathResolver.resolveProjectRoot(pid); } | |
| 489 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 490 | + | |
| 491 | + const ts = new Date().toISOString().replace(/[:.]/g, '-'); | |
| 492 | + const intakeDir = path.join(root, 'intake', ts); | |
| 493 | + fs.mkdirSync(intakeDir, { recursive: true }); | |
| 494 | + | |
| 495 | + const files = req.files || []; | |
| 496 | + const results = []; | |
| 497 | + for (const f of files) { | |
| 498 | + const ext = path.extname(f.originalname).toLowerCase(); | |
| 499 | + if (!['.md', '.txt', '.html', '.htm'].includes(ext)) { | |
| 500 | + results.push({ name: f.originalname, skipped: true, reason: `unsupported ext: ${ext}` }); | |
| 501 | + continue; | |
| 502 | + } | |
| 503 | + const safeName = f.originalname.replace(/[/\\]/g, '_').slice(0, 200); | |
| 504 | + const abs = path.join(intakeDir, safeName); | |
| 505 | + fs.writeFileSync(abs, f.buffer); | |
| 506 | + let text = f.buffer.toString('utf8'); | |
| 507 | + if (ext === '.html' || ext === '.htm') { | |
| 508 | + // 极简 strip:去脚本/样式块 + 拆 tag | |
| 509 | + text = text | |
| 510 | + .replace(/<script[\s\S]*?<\/script>/gi, '') | |
| 511 | + .replace(/<style[\s\S]*?<\/style>/gi, '') | |
| 512 | + .replace(/<[^>]+>/g, ' ') | |
| 513 | + .replace(/\s+/g, ' ') | |
| 514 | + .trim(); | |
| 515 | + } | |
| 516 | + results.push({ | |
| 517 | + name: f.originalname, | |
| 518 | + size: f.size, | |
| 519 | + saved_to: path.relative(root, abs), | |
| 520 | + text, | |
| 521 | + }); | |
| 522 | + } | |
| 523 | + res.json({ ok: true, intake_dir: path.relative(root, intakeDir), files: results }); | |
| 524 | + }) | |
| 525 | +); | |
| 526 | + | |
| 527 | +// POST /api/devloop/projects/:pid/intake/scan-dir | |
| 528 | +// body: { path: '/abs/dir', max_files?: 50, max_bytes?: 1MB } | |
| 529 | +// 递归找 md/txt/html,读文本。受 sensitive 黑名单拦截。 | |
| 530 | +router.post('/projects/:pid/intake/scan-dir', h(async (req, res) => { | |
| 531 | + const { pid } = req.params; | |
| 532 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 533 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 534 | + | |
| 535 | + const target = req.body?.path && String(req.body.path); | |
| 536 | + const maxFiles = Math.min(200, parseInt(req.body?.max_files, 10) || 50); | |
| 537 | + const maxBytes = Math.min(5 * 1024 * 1024, parseInt(req.body?.max_bytes, 10) || 1024 * 1024); | |
| 538 | + if (!target || !path.isAbsolute(target)) { | |
| 539 | + return res.status(400).json({ error: 'path required (absolute)' }); | |
| 540 | + } | |
| 541 | + if (pathResolver.isSensitivePath(target)) { | |
| 542 | + return res.status(403).json({ error: `sensitive_path: ${target}` }); | |
| 543 | + } | |
| 544 | + if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) { | |
| 545 | + return res.status(404).json({ error: `not a directory: ${target}` }); | |
| 546 | + } | |
| 547 | + | |
| 548 | + const found = []; | |
| 549 | + let totalBytes = 0; | |
| 550 | + let truncated = false; | |
| 551 | + function walk(dir, depth) { | |
| 552 | + if (depth > 6 || found.length >= maxFiles || truncated) return; | |
| 553 | + let entries; | |
| 554 | + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } | |
| 555 | + catch { return; } | |
| 556 | + for (const e of entries) { | |
| 557 | + if (e.name.startsWith('.') || e.name === 'node_modules') continue; | |
| 558 | + const full = path.join(dir, e.name); | |
| 559 | + if (e.isDirectory()) { walk(full, depth + 1); continue; } | |
| 560 | + if (!e.isFile()) continue; | |
| 561 | + const ext = path.extname(e.name).toLowerCase(); | |
| 562 | + if (!['.md', '.txt', '.html', '.htm'].includes(ext)) continue; | |
| 563 | + const stat = fs.statSync(full); | |
| 564 | + if (totalBytes + stat.size > maxBytes) { truncated = true; return; } | |
| 565 | + let text; | |
| 566 | + try { text = fs.readFileSync(full, 'utf8'); } catch { continue; } | |
| 567 | + if (ext === '.html' || ext === '.htm') { | |
| 568 | + text = text.replace(/<script[\s\S]*?<\/script>/gi, '') | |
| 569 | + .replace(/<style[\s\S]*?<\/style>/gi, '') | |
| 570 | + .replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); | |
| 571 | + } | |
| 572 | + totalBytes += stat.size; | |
| 573 | + found.push({ path: full, rel: path.relative(target, full), size: stat.size, text }); | |
| 574 | + if (found.length >= maxFiles) { truncated = true; return; } | |
| 575 | + } | |
| 576 | + } | |
| 577 | + walk(target, 0); | |
| 578 | + res.json({ ok: true, scanned_root: target, total_bytes: totalBytes, truncated, files: found }); | |
| 579 | +})); | |
| 580 | + | |
| 581 | +// POST /api/devloop/projects/:pid/draft-chat | |
| 582 | +// body: { messages: [{role:'user'|'assistant', content:string}, ...] } | |
| 583 | +// 返回 { role:'assistant', content:string } | |
| 584 | +// 占位实现:原样回显最后一条 user 消息 + 提示。LLM 接入后此函数体内换实现即可。 | |
| 585 | +router.post('/projects/:pid/draft-chat', h(async (req, res) => { | |
| 586 | + const { pid } = req.params; | |
| 587 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 588 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 589 | + const messages = Array.isArray(req.body?.messages) ? req.body.messages : []; | |
| 590 | + const lastUser = [...messages].reverse().find(m => m.role === 'user'); | |
| 591 | + const echoText = lastUser?.content?.toString().trim() || '(空消息)'; | |
| 592 | + const reply = `已收到你的描述(共 ${echoText.length} 字符):\n\n> ${echoText.slice(0, 200)}${echoText.length > 200 ? '…' : ''}\n\n(LLM 多轮调试暂未接入。请继续追加细节、上传文档、或扫描代码目录,然后点"提交需求"一次性发给 PM Agent。)`; | |
| 593 | + res.json({ role: 'assistant', content: reply, llm_stub: true }); | |
| 594 | +})); | |
| 595 | + | |
| 124 | 596 | module.exports = router; | ... | ... |
server/services/devloop/git-vault.js
| ... | ... | @@ -142,12 +142,11 @@ async function commit(pid, spec) { |
| 142 | 142 | } |
| 143 | 143 | if (!spec.summary) throw new Error('commit: summary required'); |
| 144 | 144 | |
| 145 | - // 1. 加 files(默认 -A,但受 .gitignore 控制) | |
| 146 | - const addArgs = ['add', '--']; | |
| 145 | + // 1. 加 files(显式列表则限定路径;否则 -A 全量。-A 受 .gitignore 控制) | |
| 147 | 146 | if (Array.isArray(spec.files) && spec.files.length) { |
| 148 | - addArgs.push(...spec.files); | |
| 147 | + const rAdd = await _run(root, ['add', '--', ...spec.files]); | |
| 148 | + if (!rAdd.ok) throw new Error(`git add files failed: ${rAdd.stderr}`); | |
| 149 | 149 | } else { |
| 150 | - // -A:把所有变更(包括删除)加进 index | |
| 151 | 150 | const rA = await _run(root, ['add', '-A']); |
| 152 | 151 | if (!rA.ok) throw new Error(`git add -A failed: ${rA.stderr}`); |
| 153 | 152 | } | ... | ... |
server/services/devloop/path-resolver.js
| ... | ... | @@ -259,9 +259,55 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { |
| 259 | 259 | }; |
| 260 | 260 | saveLinkedRegistry(reg); |
| 261 | 261 | } else if (mode === 'linked-existing') { |
| 262 | - const e = new Error('linked-existing mode is v2; not supported in v1'); | |
| 263 | - e.code = 'NOT_IMPLEMENTED'; | |
| 264 | - throw e; | |
| 262 | + if (!target_path) throw new Error('target_path required for linked-existing'); | |
| 263 | + if (!path.isAbsolute(target_path)) { | |
| 264 | + throw new Error('target_path must be absolute'); | |
| 265 | + } | |
| 266 | + if (isSensitivePath(target_path)) { | |
| 267 | + const e = new Error(`sensitive_path: ${target_path} is in blacklist`); | |
| 268 | + e.code = 'SENSITIVE_PATH'; | |
| 269 | + throw e; | |
| 270 | + } | |
| 271 | + if (!fs.existsSync(target_path)) { | |
| 272 | + throw new Error(`target_path does not exist: ${target_path} (use linked-new for empty/new dir)`); | |
| 273 | + } | |
| 274 | + const stat = fs.statSync(target_path); | |
| 275 | + if (!stat.isDirectory()) { | |
| 276 | + throw new Error(`target_path is not a directory: ${target_path}`); | |
| 277 | + } | |
| 278 | + // 已有 DevLoop 项目(目录里就有 project.json)→ 拒绝重复挂载 | |
| 279 | + if (fs.existsSync(path.join(target_path, 'project.json'))) { | |
| 280 | + throw new Error(`target_path already contains a DevLoop project.json: ${target_path}`); | |
| 281 | + } | |
| 282 | + // 已有 .git → 必须 working tree 干净(避免后续 Agent 的 git add -A 把用户脏改动扫进 DevLoop commit) | |
| 283 | + if (fs.existsSync(path.join(target_path, '.git'))) { | |
| 284 | + const { execFileSync } = require('child_process'); | |
| 285 | + let porcelain; | |
| 286 | + try { | |
| 287 | + porcelain = execFileSync('git', ['status', '--porcelain'], { cwd: target_path, encoding: 'utf8' }); | |
| 288 | + } catch (e) { | |
| 289 | + throw new Error(`failed to check git status at ${target_path}: ${e.message}`); | |
| 290 | + } | |
| 291 | + if (porcelain.trim().length > 0) { | |
| 292 | + const lines = porcelain.trim().split('\n').slice(0, 8).join('\n'); | |
| 293 | + const err = new Error( | |
| 294 | + `target_path has uncommitted changes; please commit or stash first.\n` + | |
| 295 | + `Reason: DevLoop agents auto-commit with git add -A and would sweep your dirty files into agent commits.\n` + | |
| 296 | + `--- git status --porcelain (truncated) ---\n${lines}` | |
| 297 | + ); | |
| 298 | + err.code = 'DIRTY_WORKTREE'; | |
| 299 | + throw err; | |
| 300 | + } | |
| 301 | + } | |
| 302 | + root_path = path.resolve(target_path); | |
| 303 | + const reg = loadLinkedRegistry(); | |
| 304 | + reg[pid] = { | |
| 305 | + root_path, | |
| 306 | + mode: 'linked-existing', | |
| 307 | + company_id, | |
| 308 | + registered_at: new Date().toISOString(), | |
| 309 | + }; | |
| 310 | + saveLinkedRegistry(reg); | |
| 265 | 311 | } else { |
| 266 | 312 | throw new Error(`unknown mode: ${mode}`); |
| 267 | 313 | } |
| ... | ... | @@ -282,9 +328,76 @@ function unregisterLinkedProject(pid) { |
| 282 | 328 | if (cache) cache.delete(pid); |
| 283 | 329 | } |
| 284 | 330 | |
| 285 | -function listProjects() { | |
| 331 | +/** | |
| 332 | + * 列出全部项目。读 project.json 注入 name/created_at/status/archived/idea_summary。 | |
| 333 | + * 默认过滤已 archived 的项目;includeArchived=true 时全返。 | |
| 334 | + */ | |
| 335 | +function listProjects({ includeArchived = false } = {}) { | |
| 336 | + ensureInit(); | |
| 337 | + const out = []; | |
| 338 | + for (const [pid, entry] of cache.entries()) { | |
| 339 | + const meta = _readMetaSafe(entry.root_path); | |
| 340 | + if (!includeArchived && meta && meta.archived) continue; | |
| 341 | + out.push({ | |
| 342 | + pid, | |
| 343 | + ...entry, | |
| 344 | + name: meta?.name || meta?.idea_summary || pid, | |
| 345 | + idea_summary: meta?.idea_summary || '', | |
| 346 | + status: meta?.status || 'draft', | |
| 347 | + created_at: meta?.created_at || null, | |
| 348 | + archived: !!meta?.archived, | |
| 349 | + archived_at: meta?.archived_at || null, | |
| 350 | + }); | |
| 351 | + } | |
| 352 | + // 倒序:最近创建的在前 | |
| 353 | + out.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); | |
| 354 | + return out; | |
| 355 | +} | |
| 356 | + | |
| 357 | +function _readMetaSafe(rootPath) { | |
| 358 | + try { | |
| 359 | + const p = path.join(rootPath, 'project.json'); | |
| 360 | + if (!fs.existsSync(p)) return null; | |
| 361 | + return JSON.parse(fs.readFileSync(p, 'utf8')); | |
| 362 | + } catch { return null; } | |
| 363 | +} | |
| 364 | + | |
| 365 | +/** | |
| 366 | + * 修改 project.json 的字段(name / idea_summary / archived 等)。 | |
| 367 | + * 调用方负责传合法字段集;不做 FSM 校验(status 用 loop-controller._setProjectStatus)。 | |
| 368 | + */ | |
| 369 | +function patchProjectMeta(pid, patch = {}) { | |
| 370 | + ensureInit(); | |
| 371 | + const root = resolveProjectRoot(pid); | |
| 372 | + const metaPath = path.join(root, 'project.json'); | |
| 373 | + if (!fs.existsSync(metaPath)) throw new Error(`project.json missing for ${pid}`); | |
| 374 | + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); | |
| 375 | + Object.assign(meta, patch); | |
| 376 | + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2)); | |
| 377 | + return meta; | |
| 378 | +} | |
| 379 | + | |
| 380 | +/** | |
| 381 | + * 硬删项目:sandbox 模式下递归删 root_path;linked 模式只解除注册(不删用户自有目录)。 | |
| 382 | + * 返回 { mode, removed_disk: boolean } | |
| 383 | + */ | |
| 384 | +function hardDeleteProject(pid) { | |
| 286 | 385 | ensureInit(); |
| 287 | - return Array.from(cache.entries()).map(([pid, entry]) => ({ pid, ...entry })); | |
| 386 | + const entry = cache.get(pid); | |
| 387 | + if (!entry) throw new Error(`Unknown project: ${pid}`); | |
| 388 | + let removed_disk = false; | |
| 389 | + if (entry.mode === 'sandbox') { | |
| 390 | + if (fs.existsSync(entry.root_path)) { | |
| 391 | + fs.rmSync(entry.root_path, { recursive: true, force: true }); | |
| 392 | + removed_disk = true; | |
| 393 | + } | |
| 394 | + } else if (entry.mode === 'linked-new' || entry.mode === 'linked-existing') { | |
| 395 | + // 只解注册,不动用户自己挑的目录 | |
| 396 | + const reg = loadLinkedRegistry(); | |
| 397 | + if (reg[pid]) { delete reg[pid]; saveLinkedRegistry(reg); } | |
| 398 | + } | |
| 399 | + cache.delete(pid); | |
| 400 | + return { mode: entry.mode, removed_disk }; | |
| 288 | 401 | } |
| 289 | 402 | |
| 290 | 403 | /** 测试用:重置缓存,强制下次重新扫描 */ |
| ... | ... | @@ -316,6 +429,8 @@ module.exports = { |
| 316 | 429 | allocateProjectRoot, |
| 317 | 430 | unregisterLinkedProject, |
| 318 | 431 | listProjects, |
| 432 | + patchProjectMeta, | |
| 433 | + hardDeleteProject, | |
| 319 | 434 | isSensitivePath, |
| 320 | 435 | _resetForTesting, |
| 321 | 436 | // 常量 | ... | ... |
server/src/core/devloop/loop-controller.js
| ... | ... | @@ -24,8 +24,17 @@ const pathResolver = require('../../../services/devloop/path-resolver'); |
| 24 | 24 | const gitVault = require('../../../services/devloop/git-vault'); |
| 25 | 25 | const timelineLogger = require('../../../services/devloop/timeline-logger'); |
| 26 | 26 | const unitRegistry = require('../../../services/devloop/unit-registry'); |
| 27 | +const bugTracker = require('../../../services/devloop/bug-tracker'); | |
| 28 | +const changelogWriter = require('../../../services/devloop/changelog-writer'); | |
| 29 | +const sseBroker = require('../../../services/devloop/sse-broker'); | |
| 30 | +const docStore = require('../../../services/devloop/doc-store'); | |
| 27 | 31 | const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol'); |
| 28 | 32 | |
| 33 | +// 注入依赖:让 unit-registry / bug-tracker 内的 SSE/Timeline 调用都能落地 | |
| 34 | +unitRegistry.attachSseBroker(sseBroker); | |
| 35 | +bugTracker.attachSseBroker(sseBroker); | |
| 36 | +bugTracker.attachTimelineLogger(timelineLogger); | |
| 37 | + | |
| 29 | 38 | const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates'); |
| 30 | 39 | |
| 31 | 40 | /** |
| ... | ... | @@ -42,13 +51,15 @@ async function createProject({ company_id, mode = 'sandbox', target_path, idea = |
| 42 | 51 | company_id, mode, target_path, |
| 43 | 52 | }); |
| 44 | 53 | |
| 45 | - // project.json | |
| 54 | + // project.json (含 P4 status FSM 字段) | |
| 46 | 55 | const meta = { |
| 47 | 56 | pid, |
| 48 | 57 | company_id, |
| 49 | 58 | mode, |
| 50 | 59 | created_at: new Date().toISOString(), |
| 51 | 60 | idea_summary: (idea || '').slice(0, 200), |
| 61 | + status: 'draft', // FSM: draft → running → shipped → shipped-final | abandoned | |
| 62 | + status_updated_at: new Date().toISOString(), | |
| 52 | 63 | }; |
| 53 | 64 | fs.writeFileSync( |
| 54 | 65 | pathResolver.resolveProjectJson(pid), |
| ... | ... | @@ -74,10 +85,12 @@ async function createProject({ company_id, mode = 'sandbox', target_path, idea = |
| 74 | 85 | } |
| 75 | 86 | |
| 76 | 87 | // 把 project.json + timeline.ndjson 一并提交进第二个 commit (工作树留干净) |
| 88 | + // 显式 files 列表:避免 linked-existing 模式把用户已有未提交文件拖进我们的 chore commit | |
| 77 | 89 | const firstCommit = await gitVault.commit(pid, { |
| 78 | 90 | kind: 'chore', |
| 79 | 91 | summary: `chore: project metadata + first timeline entry for ${pid}`, |
| 80 | 92 | author: { name: 'devloop-system', email: 'devloop@local' }, |
| 93 | + files: ['project.json', 'timeline.ndjson'], | |
| 81 | 94 | }); |
| 82 | 95 | |
| 83 | 96 | return { pid, root_path, mode, first_commit: firstCommit }; |
| ... | ... | @@ -147,19 +160,32 @@ async function commitAfterAgent(pid, agentName, summary = '') { |
| 147 | 160 | async function callAgent(pid, agentName, userPrompt, opts = {}) { |
| 148 | 161 | const projectRoot = pathResolver.resolveProjectRoot(pid); |
| 149 | 162 | |
| 163 | + const useMock = opts.mock !== false; | |
| 164 | + | |
| 150 | 165 | // 1. 系统提示 (可选 — 没文件就空串) |
| 151 | 166 | const promptPath = path.join(TEMPLATES_DIR, agentName, 'prompt.md'); |
| 152 | 167 | const systemPrompt = fs.existsSync(promptPath) |
| 153 | 168 | ? fs.readFileSync(promptPath, 'utf8') |
| 154 | 169 | : ''; |
| 155 | - const fullPrompt = systemPrompt | |
| 156 | - ? `${systemPrompt}\n\n## User Task\n${userPrompt}` | |
| 170 | + // mock 模式: 把 system + user 拼一起喂 fixture (fixture 不在意); | |
| 171 | + // real 模式: systemPrompt 走 --append-system-prompt, userPrompt 走 stdin。 | |
| 172 | + const fullPrompt = useMock | |
| 173 | + ? (systemPrompt ? `${systemPrompt}\n\n## User Task\n${userPrompt}` : userPrompt) | |
| 157 | 174 | : userPrompt; |
| 158 | 175 | |
| 159 | - // 2. 启动 protocol | |
| 176 | + // 2. real 模式: 先快照工作树 mtime,Agent 跑完后做 diff | |
| 177 | + const preSnapshot = useMock ? null : _snapshotMtimes(projectRoot); | |
| 178 | + | |
| 179 | + // 3. 启动 protocol | |
| 160 | 180 | const protocol = new ClaudeCliProtocol( |
| 161 | 181 | { name: agentName }, |
| 162 | - { mock: opts.mock !== false }, // 默认 mock=true (P1 阶段) | |
| 182 | + { | |
| 183 | + mock: useMock, | |
| 184 | + mockFixtureDir: opts.mockFixtureDir, | |
| 185 | + // real 模式专属: | |
| 186 | + systemPrompt: useMock ? undefined : systemPrompt, | |
| 187 | + maxBudgetUsd: opts.maxBudgetUsd, // 调用方可设上限 | |
| 188 | + }, | |
| 163 | 189 | ); |
| 164 | 190 | |
| 165 | 191 | let resultParams = null; |
| ... | ... | @@ -183,36 +209,44 @@ async function callAgent(pid, agentName, userPrompt, opts = {}) { |
| 183 | 209 | protocol.disconnect(); |
| 184 | 210 | } |
| 185 | 211 | |
| 186 | - // 3. 落盘 fixture.files (path 相对 projectRoot) | |
| 212 | + // 4. 落盘 — mock 用 fixture.files, real 用 fs diff | |
| 187 | 213 | const filesWritten = []; |
| 188 | - for (const f of filesFromMock) { | |
| 189 | - if (!f?.path || typeof f.content !== 'string') continue; | |
| 190 | - if (path.isAbsolute(f.path) || f.path.includes('..')) { | |
| 191 | - throw new Error(`unsafe mock file path: ${f.path}`); | |
| 214 | + if (useMock) { | |
| 215 | + for (const f of filesFromMock) { | |
| 216 | + if (!f?.path || typeof f.content !== 'string') continue; | |
| 217 | + if (path.isAbsolute(f.path) || f.path.includes('..')) { | |
| 218 | + throw new Error(`unsafe mock file path: ${f.path}`); | |
| 219 | + } | |
| 220 | + const abs = path.join(projectRoot, f.path); | |
| 221 | + fs.mkdirSync(path.dirname(abs), { recursive: true }); | |
| 222 | + let content = f.content; | |
| 223 | + if (f.path === 'units.json' || f.path.endsWith('/units.json')) { | |
| 224 | + try { | |
| 225 | + const u = JSON.parse(content); | |
| 226 | + u.project_id = pid; | |
| 227 | + content = JSON.stringify(u, null, 2); | |
| 228 | + } catch { /* leave as-is, downstream will fail validation */ } | |
| 229 | + } | |
| 230 | + fs.writeFileSync(abs, content); | |
| 231 | + filesWritten.push(f.path); | |
| 192 | 232 | } |
| 193 | - const abs = path.join(projectRoot, f.path); | |
| 194 | - fs.mkdirSync(path.dirname(abs), { recursive: true }); | |
| 195 | - let content = f.content; | |
| 196 | - // 把 fixture 里的占位符替换成实际值 ({{pid}} / "_placeholder_") | |
| 197 | - if (f.path === 'units.json' || f.path.endsWith('/units.json')) { | |
| 198 | - try { | |
| 199 | - const u = JSON.parse(content); | |
| 200 | - u.project_id = pid; | |
| 201 | - content = JSON.stringify(u, null, 2); | |
| 202 | - } catch { /* leave as-is, downstream will fail validation */ } | |
| 233 | + } else { | |
| 234 | + // real 模式: 扫工作树找新增/修改文件 (相对项目根) | |
| 235 | + const postSnapshot = _snapshotMtimes(projectRoot); | |
| 236 | + for (const [rel, mtime] of postSnapshot) { | |
| 237 | + const before = preSnapshot.get(rel); | |
| 238 | + if (before === undefined || before < mtime) filesWritten.push(rel); | |
| 203 | 239 | } |
| 204 | - fs.writeFileSync(abs, content); | |
| 205 | - filesWritten.push(f.path); | |
| 206 | 240 | } |
| 207 | 241 | |
| 208 | - // 4. timeline + commit | |
| 242 | + // 5. timeline + commit | |
| 209 | 243 | try { |
| 210 | 244 | timelineLogger.append(pid, { |
| 211 | 245 | source: agentName, |
| 212 | 246 | kind: 'agent.call', |
| 213 | - ref: resultParams?.session_id || 'mock', | |
| 214 | - summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (mock)`, | |
| 215 | - payload: { files_written: filesWritten.length, mock: !!resultParams?.mock }, | |
| 247 | + ref: resultParams?.session_id || (useMock ? 'mock' : 'real'), | |
| 248 | + summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (${useMock ? 'mock' : 'real'})`, | |
| 249 | + payload: { files_written: filesWritten.length, mock: !!resultParams?.mock, real: !useMock }, | |
| 216 | 250 | }); |
| 217 | 251 | } catch { /* swallow */ } |
| 218 | 252 | |
| ... | ... | @@ -246,17 +280,26 @@ async function runDocsPhase(pid, requirement, opts = {}) { |
| 246 | 280 | throw new Error('requirement (string) required'); |
| 247 | 281 | } |
| 248 | 282 | |
| 283 | + // Docs 是项目第一步 — reset mock 计数,确保 PM/Architect 从 seq 0 开始 | |
| 284 | + if (opts.resetMockSequence !== false) { | |
| 285 | + ClaudeCliProtocol.resetMockState(); | |
| 286 | + } | |
| 287 | + | |
| 249 | 288 | const onChunk = opts.onChunk |
| 250 | 289 | ? (agent) => (text) => opts.onChunk(agent, text) |
| 251 | 290 | : null; |
| 291 | + const passthrough = { | |
| 292 | + mock: opts.mock !== false, | |
| 293 | + mockFixtureDir: opts.mockFixtureDir, | |
| 294 | + }; | |
| 252 | 295 | |
| 253 | 296 | const pm = await callAgent(pid, 'devloop-pm', |
| 254 | 297 | `Requirement:\n${requirement}\n\nmode: init`, |
| 255 | - { onChunk: onChunk ? onChunk('devloop-pm') : undefined }); | |
| 298 | + { ...passthrough, onChunk: onChunk ? onChunk('devloop-pm') : undefined }); | |
| 256 | 299 | |
| 257 | 300 | const architect = await callAgent(pid, 'devloop-architect', |
| 258 | 301 | `Read prd/ from project root and produce SDD + units.json + integration/spec.json.\nRequirement context:\n${requirement}`, |
| 259 | - { onChunk: onChunk ? onChunk('devloop-architect') : undefined }); | |
| 302 | + { ...passthrough, onChunk: onChunk ? onChunk('devloop-architect') : undefined }); | |
| 260 | 303 | |
| 261 | 304 | // timeline 总结 |
| 262 | 305 | try { |
| ... | ... | @@ -272,6 +315,9 @@ async function runDocsPhase(pid, requirement, opts = {}) { |
| 272 | 315 | }); |
| 273 | 316 | } catch { /* swallow */ } |
| 274 | 317 | |
| 318 | + // P4: 项目进入 running 态(完成 docs 后,准备进 L1/L2) | |
| 319 | + _setProjectStatus(pid, 'running', { reason: 'docs_complete' }); | |
| 320 | + | |
| 275 | 321 | return { ok: true, pm, architect }; |
| 276 | 322 | } |
| 277 | 323 | |
| ... | ... | @@ -290,7 +336,10 @@ function getProjectState(pid) { |
| 290 | 336 | |
| 291 | 337 | const projectJson = pathResolver.resolveProjectJson(pid); |
| 292 | 338 | if (fs.existsSync(projectJson)) { |
| 293 | - try { out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); } catch {} | |
| 339 | + try { | |
| 340 | + out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); | |
| 341 | + out.status = out.project.status || 'unknown'; | |
| 342 | + } catch {} | |
| 294 | 343 | } |
| 295 | 344 | |
| 296 | 345 | const unitsJson = pathResolver.resolveUnitsJson(pid); |
| ... | ... | @@ -464,11 +513,487 @@ function _findLatestTesterResult(pid, unitId) { |
| 464 | 513 | } |
| 465 | 514 | } |
| 466 | 515 | |
| 516 | +// ────────────────────────────────────────────────────────────── | |
| 517 | +// P3 · L2 Integration Loop · runProject | |
| 518 | +// ────────────────────────────────────────────────────────────── | |
| 519 | + | |
| 520 | +/** | |
| 521 | + * Topological wave grouping (Kahn). Returns string[][] — units per wave. | |
| 522 | + */ | |
| 523 | +function _groupUnitsByWave(units) { | |
| 524 | + const groups = []; | |
| 525 | + const remaining = new Set(units.map(u => u.id)); | |
| 526 | + const deps = new Map(units.map(u => [u.id, (u.depends_on || []).slice()])); | |
| 527 | + while (remaining.size > 0) { | |
| 528 | + const wave = []; | |
| 529 | + for (const id of remaining) { | |
| 530 | + if (deps.get(id).every(d => !remaining.has(d))) wave.push(id); | |
| 531 | + } | |
| 532 | + if (wave.length === 0) { | |
| 533 | + throw new Error('cycle in units.json (no unit has all dependencies cleared)'); | |
| 534 | + } | |
| 535 | + for (const id of wave) remaining.delete(id); | |
| 536 | + groups.push(wave); | |
| 537 | + } | |
| 538 | + return groups; | |
| 539 | +} | |
| 540 | + | |
| 541 | +/** find latest run dir under integration/runs/ */ | |
| 542 | +function _findLatestIntegrationRunDir(pid) { | |
| 543 | + const dir = path.join(pathResolver.resolveProjectRoot(pid), 'integration', 'runs'); | |
| 544 | + if (!fs.existsSync(dir)) return null; | |
| 545 | + const ids = fs.readdirSync(dir).filter(n => fs.statSync(path.join(dir, n)).isDirectory()); | |
| 546 | + if (!ids.length) return null; | |
| 547 | + ids.sort(); | |
| 548 | + return ids[ids.length - 1]; | |
| 549 | +} | |
| 550 | + | |
| 551 | +function _readJsonSafe(p) { | |
| 552 | + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } | |
| 553 | +} | |
| 554 | + | |
| 555 | +/** | |
| 556 | + * runProject — P3 主循环 (mock 模式)。 | |
| 557 | + * | |
| 558 | + * 流程: | |
| 559 | + * while not done and not blocked: | |
| 560 | + * 1. 按 DAG wave 跑 L1 (per-unit runUnit) | |
| 561 | + * 2. 若 l2GateOpen → 跑 Integrator | |
| 562 | + * 3. result.ok=true → 项目完成 | |
| 563 | + * 4. result.ok=false → Triager → dispatch: | |
| 564 | + * - contract_mismatch + conf≥0.5 → Architect(patch) + reset affected (架构师自己改 units.json) | |
| 565 | + * → l2_rounds++; 超 l2_budget → l2_exhausted | |
| 566 | + * - impl_bug + conf≥0.5 → reset affected (unit-registry.reset) → 不烧预算 | |
| 567 | + * - infra_flake → 直接重跑 (不 reset, 不烧预算) | |
| 568 | + * - spec_ambiguous OR conf<0.5 → l2_blocked + SSE human_intervention_needed | |
| 569 | + * | |
| 570 | + * @param {string} pid | |
| 571 | + * @param {object} opts | |
| 572 | + * - mockFixtureDir?: string (P3 scenario 测试用) | |
| 573 | + * - maxRetries?: number (safety net 防 mock 死循环;默认 10) | |
| 574 | + * @returns { ok, pid, final_state: 'shipped'|'l2_blocked'|'l2_exhausted', l2_rounds, history } | |
| 575 | + */ | |
| 576 | +async function runProject(pid, opts = {}) { | |
| 577 | + // 注意: 不 resetMockState — runProject 是 runDocsPhase 的延续, | |
| 578 | + // 计数继续往下走才能让 Architect 第二次调用进入 patch 分支 (seq[1])。 | |
| 579 | + // 调用方若想全新状态,在调 runDocsPhase 前自己 reset。 | |
| 580 | + const projectRoot = pathResolver.resolveProjectRoot(pid); | |
| 581 | + const history = []; | |
| 582 | + const callOpts = { | |
| 583 | + mock: opts.mock !== false, | |
| 584 | + mockFixtureDir: opts.mockFixtureDir, | |
| 585 | + resetMockSequence: false, // 一次 runProject 只 reset 一次 | |
| 586 | + }; | |
| 587 | + const safety = opts.maxRetries || 10; | |
| 588 | + let iterations = 0; | |
| 589 | + | |
| 590 | + // SSE start | |
| 591 | + sseBroker.emit(pid, 'project.run_started', { pid, ts: Date.now() }); | |
| 592 | + | |
| 593 | + while (iterations < safety) { | |
| 594 | + iterations++; | |
| 595 | + | |
| 596 | + // ── (1) L1 wave 调度: 找所有 pending units 按 DAG 分波 ──────── | |
| 597 | + const allUnits = unitRegistry.list(pid); | |
| 598 | + const pendingUnits = allUnits.filter(u => u.status === 'pending'); | |
| 599 | + if (pendingUnits.length > 0) { | |
| 600 | + const waves = _groupUnitsByWave(pendingUnits); | |
| 601 | + for (let w = 0; w < waves.length; w++) { | |
| 602 | + const wave = waves[w]; | |
| 603 | + sseBroker.emit(pid, 'l1.wave_start', { wave_index: w, units: wave }); | |
| 604 | + // 串行跑(mock 同步快,不需并行) | |
| 605 | + for (const uid of wave) { | |
| 606 | + const r = await runUnit(pid, uid, { ...callOpts }); | |
| 607 | + history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts }); | |
| 608 | + if (!r.ok) { | |
| 609 | + sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts }); | |
| 610 | + _safeSetStatus(pid, 'l2_blocked', { reason: `unit ${uid} blocked` }); | |
| 611 | + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` }; | |
| 612 | + } | |
| 613 | + } | |
| 614 | + } | |
| 615 | + } | |
| 616 | + | |
| 617 | + // ── (2) L2 gate ──────────────────────────────────────────── | |
| 618 | + if (!unitRegistry.l2GateOpen(pid)) { | |
| 619 | + sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' }); | |
| 620 | + _safeSetStatus(pid, 'l2_blocked', { reason: 'l2GateClosed' }); | |
| 621 | + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' }; | |
| 622 | + } | |
| 623 | + | |
| 624 | + // ── (3) 跑 Integrator ────────────────────────────────────── | |
| 625 | + sseBroker.emit(pid, 'integration.run_start', { l2_round: _l2Rounds(pid) }); | |
| 626 | + const integ = await callAgent(pid, 'devloop-integrator', | |
| 627 | + `Mode: ${_l2Rounds(pid) === 0 ? 'run' : 'retry'}\nRun integration tests per integration/spec.json. Spawn be units on free ports, execute cases, write result.json.`, | |
| 628 | + callOpts); | |
| 629 | + const runId = _findLatestIntegrationRunDir(pid); | |
| 630 | + const intResult = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'result.json')) : null; | |
| 631 | + history.push({ phase: 'L2.integrate', l2_round: _l2Rounds(pid), run_id: runId, ok: intResult?.ok, crashed: intResult?.crashed }); | |
| 632 | + sseBroker.emit(pid, 'integration.result', { run_id: runId, ok: !!intResult?.ok, crashed: !!intResult?.crashed }); | |
| 633 | + | |
| 634 | + // ── (4) 成功 → 项目完成 ──────────────────────────────────── | |
| 635 | + if (intResult?.ok === true) { | |
| 636 | + // 若有未关 bug → 转 fixed | |
| 637 | + const openBugs = bugTracker.list(pid, { status: ['open', 'fixing'] }); | |
| 638 | + for (const b of openBugs) { | |
| 639 | + try { bugTracker.transition(pid, b.id, { to: 'fixed', by: 'devloop-integrator', note: 'integration passed' }); } | |
| 640 | + catch { /* skip invalid transitions */ } | |
| 641 | + } | |
| 642 | + sseBroker.emit(pid, 'project.shipped', { l2_rounds: _l2Rounds(pid), run_id: runId }); | |
| 643 | + try { | |
| 644 | + timelineLogger.append(pid, { | |
| 645 | + source: 'system', kind: 'project.shipped', ref: runId, | |
| 646 | + summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`, | |
| 647 | + }); | |
| 648 | + } catch {} | |
| 649 | + _setProjectStatus(pid, 'shipped', { run_id: runId }); | |
| 650 | + return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId }; | |
| 651 | + } | |
| 652 | + | |
| 653 | + // ── (5) 失败 → 开 bug + 调 Triager ────────────────────────── | |
| 654 | + const bug = bugTracker.open(pid, { | |
| 655 | + source: 'integration_test', | |
| 656 | + run_id: runId, | |
| 657 | + title: `integration ${runId} failed${intResult?.crashed ? ' (crashed)' : ''}`, | |
| 658 | + severity: intResult?.crashed ? 'high' : 'medium', | |
| 659 | + category: 'unknown', | |
| 660 | + detail: (intResult?.failures?.[0]?.stderr_excerpt || intResult?.crash_reason || 'unknown').slice(0, 200), | |
| 661 | + affected_units: intResult?.failures?.[0]?.involves || [], | |
| 662 | + by: 'devloop-integrator', | |
| 663 | + }); | |
| 664 | + bugTracker.transition(pid, bug.id, { to: 'fixing', by: 'devloop-triager', note: 'triage in progress' }); | |
| 665 | + | |
| 666 | + await callAgent(pid, 'devloop-triager', | |
| 667 | + `Mode: triage\nrun_id: ${runId}\nAnalyze integration/runs/${runId}/result.json + process.log; write triage.json.`, | |
| 668 | + callOpts); | |
| 669 | + const triage = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'triage.json')) : null; | |
| 670 | + history.push({ phase: 'L2.triage', l2_round: _l2Rounds(pid), run_id: runId, category: triage?.category, confidence: triage?.confidence }); | |
| 671 | + sseBroker.emit(pid, 'triage.result', { | |
| 672 | + run_id: runId, | |
| 673 | + category: triage?.category, | |
| 674 | + confidence: triage?.confidence, | |
| 675 | + affected_units: triage?.affected_units || [], | |
| 676 | + }); | |
| 677 | + | |
| 678 | + if (!triage) { | |
| 679 | + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId }); | |
| 680 | + _safeSetStatus(pid, 'l2_blocked', { reason: 'triage_missing' }); | |
| 681 | + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' }; | |
| 682 | + } | |
| 683 | + | |
| 684 | + // ── (6) 分支调度 ──────────────────────────────────────────── | |
| 685 | + const lowConfidence = (triage.confidence ?? 0) < 0.5; | |
| 686 | + const category = triage.category; | |
| 687 | + | |
| 688 | + if (lowConfidence || category === 'spec_ambiguous') { | |
| 689 | + sseBroker.emit(pid, 'human_intervention_needed', { | |
| 690 | + reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous', | |
| 691 | + run_id: runId, category, confidence: triage.confidence, | |
| 692 | + }); | |
| 693 | + _safeSetStatus(pid, 'l2_blocked', { reason: lowConfidence ? 'low_confidence' : 'spec_ambiguous' }); | |
| 694 | + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` }; | |
| 695 | + } | |
| 696 | + | |
| 697 | + if (category === 'infra_flake') { | |
| 698 | + // 不 reset 不烧预算,直接下一轮重跑 Integrator | |
| 699 | + history.push({ phase: 'L2.dispatch', action: 'rerun_integrator (infra_flake)' }); | |
| 700 | + continue; | |
| 701 | + } | |
| 702 | + | |
| 703 | + if (category === 'impl_bug') { | |
| 704 | + // reset affected units (不烧 l2_budget) | |
| 705 | + const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u)); | |
| 706 | + if (affected.length === 0) { | |
| 707 | + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId }); | |
| 708 | + _safeSetStatus(pid, 'l2_blocked', { reason: 'impl_bug_no_affected' }); | |
| 709 | + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history }; | |
| 710 | + } | |
| 711 | + unitRegistry.reset(pid, affected); | |
| 712 | + history.push({ phase: 'L2.dispatch', action: 'reset_units (impl_bug)', units: affected }); | |
| 713 | + sseBroker.emit(pid, 'units.reset', { units: affected, cause: 'impl_bug' }); | |
| 714 | + continue; | |
| 715 | + } | |
| 716 | + | |
| 717 | + if (category === 'contract_mismatch') { | |
| 718 | + // 调 Architect patch (fixture 自己写 units.json,会把 affected_units 标 pending + l2_rounds++) | |
| 719 | + // 预检 l2_budget | |
| 720 | + const currentRounds = _l2Rounds(pid); | |
| 721 | + const l2Budget = _l2Budget(pid); | |
| 722 | + if (currentRounds + 1 > l2Budget) { | |
| 723 | + sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget }); | |
| 724 | + _safeSetStatus(pid, 'l2_exhausted', { reason: 'budget_exceeded' }); | |
| 725 | + return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history }; | |
| 726 | + } | |
| 727 | + const oldSddRev = _readSddRev(pid); | |
| 728 | + await callAgent(pid, 'devloop-architect', | |
| 729 | + `Mode: patch\nrun_id: ${runId}\nApply triage.proposed_sdd_patch; bump units.json contract_hash for affected units; increment l2_rounds.`, | |
| 730 | + callOpts); | |
| 731 | + const newSddRev = _readSddRev(pid); | |
| 732 | + if (newSddRev > oldSddRev) { | |
| 733 | + try { | |
| 734 | + changelogWriter.append(pid, { | |
| 735 | + kind: 'sdd_rev', | |
| 736 | + rev: newSddRev, | |
| 737 | + by: 'devloop-architect', | |
| 738 | + trigger: 'l2_triage', | |
| 739 | + summary: `SDD patched from rev ${oldSddRev} to ${newSddRev} per triage ${category}`, | |
| 740 | + diff: { changed_sections: [triage.proposed_sdd_patch?.target_section || '(unknown)'] }, | |
| 741 | + }); | |
| 742 | + } catch { /* swallow */ } | |
| 743 | + sseBroker.emit(pid, 'sdd.updated', { rev: newSddRev, summary: `patch applied (round ${_l2Rounds(pid)})` }); | |
| 744 | + } | |
| 745 | + history.push({ phase: 'L2.dispatch', action: 'sdd_patch + reset', sdd_rev: newSddRev }); | |
| 746 | + continue; | |
| 747 | + } | |
| 748 | + | |
| 749 | + // Unknown category | |
| 750 | + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category }); | |
| 751 | + _safeSetStatus(pid, 'l2_blocked', { reason: 'unknown_category' }); | |
| 752 | + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` }; | |
| 753 | + } | |
| 754 | + | |
| 755 | + // safety net 超过 | |
| 756 | + sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations }); | |
| 757 | + _safeSetStatus(pid, 'l2_exhausted', { reason: 'safety_net' }); | |
| 758 | + return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history }; | |
| 759 | +} | |
| 760 | + | |
| 761 | +/** Status set that swallows invalid-transition errors (already in target state etc.) */ | |
| 762 | +function _safeSetStatus(pid, to, extra) { | |
| 763 | + try { return _setProjectStatus(pid, to, extra); } | |
| 764 | + catch (e) { | |
| 765 | + if (e.name === 'InvalidProjectStatusTransition') return null; | |
| 766 | + throw e; | |
| 767 | + } | |
| 768 | +} | |
| 769 | + | |
| 770 | +function _l2Rounds(pid) { | |
| 771 | + const u = docStore.readUnits(pid); | |
| 772 | + return u?.l2_rounds || 0; | |
| 773 | +} | |
| 774 | +function _l2Budget(pid) { | |
| 775 | + const u = docStore.readUnits(pid); | |
| 776 | + return u?.l2_budget || 3; | |
| 777 | +} | |
| 778 | +function _readSddRev(pid) { | |
| 779 | + const root = pathResolver.resolveProjectRoot(pid); | |
| 780 | + const idx = path.join(root, 'sdd', 'index.html'); | |
| 781 | + if (!fs.existsSync(idx)) return 0; | |
| 782 | + const m = fs.readFileSync(idx, 'utf8').match(/<article[^>]*data-doc="sdd"[^>]*data-rev="(\d+)"/); | |
| 783 | + if (m) return parseInt(m[1], 10); | |
| 784 | + // fallback: try mvcp/control.html (where patch goes) | |
| 785 | + const ctrl = path.join(root, 'sdd', 'mvcp', 'control.html'); | |
| 786 | + if (fs.existsSync(ctrl)) { | |
| 787 | + const m2 = fs.readFileSync(ctrl, 'utf8').match(/<article[^>]*data-doc="sdd"[^>]*data-rev="(\d+)"/); | |
| 788 | + if (m2) return parseInt(m2[1], 10); | |
| 789 | + } | |
| 790 | + return 1; | |
| 791 | +} | |
| 792 | + | |
| 793 | +// ────────────────────────────────────────────────────────────── | |
| 794 | +// P4 · L3 Human Feedback · status FSM + signOff / abandon / feedback | |
| 795 | +// ────────────────────────────────────────────────────────────── | |
| 796 | + | |
| 797 | +const STATUS_TRANSITIONS = { | |
| 798 | + draft: ['running', 'abandoned'], | |
| 799 | + running: ['shipped', 'l2_blocked', 'l2_exhausted', 'abandoned'], | |
| 800 | + l2_blocked: ['running', 'abandoned'], | |
| 801 | + l2_exhausted: ['running', 'abandoned'], | |
| 802 | + shipped: ['running', 'shipped-final', 'abandoned'], | |
| 803 | + 'shipped-final': [], // 终态 | |
| 804 | + abandoned: [], // 终态 | |
| 805 | +}; | |
| 806 | + | |
| 807 | +class InvalidProjectStatusTransition extends Error { | |
| 808 | + constructor(from, to) { | |
| 809 | + super(`Invalid project status transition: ${from} → ${to}`); | |
| 810 | + this.name = 'InvalidProjectStatusTransition'; | |
| 811 | + this.from = from; | |
| 812 | + this.to = to; | |
| 813 | + } | |
| 814 | +} | |
| 815 | + | |
| 816 | +function _readProjectMeta(pid) { | |
| 817 | + const p = pathResolver.resolveProjectJson(pid); | |
| 818 | + if (!fs.existsSync(p)) throw new Error(`project.json missing for ${pid}`); | |
| 819 | + return JSON.parse(fs.readFileSync(p, 'utf8')); | |
| 820 | +} | |
| 821 | + | |
| 822 | +function _writeProjectMeta(pid, meta) { | |
| 823 | + fs.writeFileSync(pathResolver.resolveProjectJson(pid), JSON.stringify(meta, null, 2)); | |
| 824 | +} | |
| 825 | + | |
| 826 | +/** | |
| 827 | + * 改 project.json.status,做 FSM 校验 + 发 SSE + 写 timeline。 | |
| 828 | + * 非法转换抛 InvalidProjectStatusTransition (供路由层映射 400)。 | |
| 829 | + */ | |
| 830 | +function _setProjectStatus(pid, to, extra = {}) { | |
| 831 | + const meta = _readProjectMeta(pid); | |
| 832 | + const from = meta.status || 'draft'; | |
| 833 | + if (from === to) return meta; // idempotent | |
| 834 | + const allowed = STATUS_TRANSITIONS[from] || []; | |
| 835 | + if (!allowed.includes(to)) { | |
| 836 | + throw new InvalidProjectStatusTransition(from, to); | |
| 837 | + } | |
| 838 | + meta.status = to; | |
| 839 | + meta.status_updated_at = new Date().toISOString(); | |
| 840 | + if (extra.note) meta.status_note = extra.note; | |
| 841 | + _writeProjectMeta(pid, meta); | |
| 842 | + | |
| 843 | + try { | |
| 844 | + sseBroker.emit(pid, 'project.status', { from, to, ...extra }); | |
| 845 | + timelineLogger.append(pid, { | |
| 846 | + source: extra.by || 'system', | |
| 847 | + kind: 'project.status', | |
| 848 | + ref: pid, | |
| 849 | + summary: `Project status ${from} → ${to}${extra.reason ? ` (${extra.reason})` : ''}`, | |
| 850 | + payload: { from, to, ...extra }, | |
| 851 | + }); | |
| 852 | + } catch { /* swallow */ } | |
| 853 | + return meta; | |
| 854 | +} | |
| 855 | + | |
| 856 | +/** | |
| 857 | + * Sign Off — 把已 shipped 的项目锁为 shipped-final。 | |
| 858 | + * 之后再发 feedback 会被拒绝。 | |
| 859 | + */ | |
| 860 | +async function signOff(pid, opts = {}) { | |
| 861 | + const meta = _readProjectMeta(pid); | |
| 862 | + if (meta.status !== 'shipped') { | |
| 863 | + throw new InvalidProjectStatusTransition(meta.status || 'unknown', 'shipped-final'); | |
| 864 | + } | |
| 865 | + _setProjectStatus(pid, 'shipped-final', { by: opts.by || 'human', note: opts.note || 'signed off' }); | |
| 866 | + // sign-off commit (留 git 痕) | |
| 867 | + try { | |
| 868 | + await gitVault.commit(pid, { | |
| 869 | + kind: 'chore', | |
| 870 | + summary: `chore: sign-off project ${pid}`, | |
| 871 | + author: { name: opts.by || 'human', email: 'human@devloop.local' }, | |
| 872 | + }); | |
| 873 | + } catch { /* swallow if nothing to commit */ } | |
| 874 | + return _readProjectMeta(pid); | |
| 875 | +} | |
| 876 | + | |
| 877 | +/** | |
| 878 | + * Abandon — 任意状态 → abandoned。发 SSE close 帧关闭所有订阅。 | |
| 879 | + */ | |
| 880 | +async function abandon(pid, opts = {}) { | |
| 881 | + const meta = _readProjectMeta(pid); | |
| 882 | + if (meta.status === 'shipped-final' || meta.status === 'abandoned') { | |
| 883 | + throw new InvalidProjectStatusTransition(meta.status, 'abandoned'); | |
| 884 | + } | |
| 885 | + _setProjectStatus(pid, 'abandoned', { by: opts.by || 'human', reason: opts.reason || 'manual abandon', note: opts.note }); | |
| 886 | + try { | |
| 887 | + await gitVault.commit(pid, { | |
| 888 | + kind: 'chore', | |
| 889 | + summary: `chore: abandon project ${pid}${opts.reason ? ' · ' + opts.reason : ''}`, | |
| 890 | + author: { name: opts.by || 'human', email: 'human@devloop.local' }, | |
| 891 | + }); | |
| 892 | + } catch { /* swallow */ } | |
| 893 | + // 关闭所有 SSE 订阅(close 帧) | |
| 894 | + try { sseBroker.close(pid); } catch { /* ignore */ } | |
| 895 | + return _readProjectMeta(pid); | |
| 896 | +} | |
| 897 | + | |
| 898 | +/** | |
| 899 | + * Submit human feedback — 触发 PM incremental 模式。 | |
| 900 | + * 仅当 status ∈ {shipped, running, l2_blocked, l2_exhausted} 时允许。 | |
| 901 | + * shipped-final / abandoned → 拒绝。 | |
| 902 | + */ | |
| 903 | +async function submitFeedback(pid, text, opts = {}) { | |
| 904 | + if (!text || typeof text !== 'string') { | |
| 905 | + throw new Error('feedback text required'); | |
| 906 | + } | |
| 907 | + const meta = _readProjectMeta(pid); | |
| 908 | + if (meta.status === 'shipped-final' || meta.status === 'abandoned') { | |
| 909 | + throw new InvalidProjectStatusTransition(meta.status, 'feedback'); | |
| 910 | + } | |
| 911 | + | |
| 912 | + const prdRevBefore = _readPrdRev(pid); | |
| 913 | + const pm = await callAgent(pid, 'devloop-pm', | |
| 914 | + `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.`, | |
| 915 | + { mock: opts.mock !== false, mockFixtureDir: opts.mockFixtureDir }, | |
| 916 | + ); | |
| 917 | + const prdRevAfter = _readPrdRev(pid); | |
| 918 | + | |
| 919 | + // 若 PRD rev bumped → 写 changelog | |
| 920 | + if (prdRevAfter > prdRevBefore) { | |
| 921 | + try { | |
| 922 | + changelogWriter.append(pid, { | |
| 923 | + kind: 'prd_rev', | |
| 924 | + rev: prdRevAfter, | |
| 925 | + by: opts.by || 'human', | |
| 926 | + trigger: 'human_feedback', | |
| 927 | + summary: `PRD bumped to rev ${prdRevAfter} via human feedback`, | |
| 928 | + diff: { feedback_excerpt: text.slice(0, 120) }, | |
| 929 | + }); | |
| 930 | + sseBroker.emit(pid, 'prd.updated', { rev: prdRevAfter, summary: 'human feedback applied' }); | |
| 931 | + } catch { /* swallow */ } | |
| 932 | + } | |
| 933 | + | |
| 934 | + // 若当前是 shipped, 改回 running (人类反馈触发新一轮) | |
| 935 | + if (meta.status === 'shipped') { | |
| 936 | + _setProjectStatus(pid, 'running', { reason: 'human_feedback' }); | |
| 937 | + } | |
| 938 | + | |
| 939 | + return { | |
| 940 | + ok: true, | |
| 941 | + pid, | |
| 942 | + files_written: pm.files_written, | |
| 943 | + prd_rev_before: prdRevBefore, | |
| 944 | + prd_rev_after: prdRevAfter, | |
| 945 | + status: _readProjectMeta(pid).status, | |
| 946 | + }; | |
| 947 | +} | |
| 948 | + | |
| 949 | +/** | |
| 950 | + * 扫项目根递归收集 (relPath → mtimeMs) Map。 | |
| 951 | + * 忽略 .git / node_modules / agent-state/snapshots (高频写) 子树。 | |
| 952 | + * real 模式 callAgent 用这个做 pre/post 快照,自动识别 Agent 写出来的新文件。 | |
| 953 | + */ | |
| 954 | +function _snapshotMtimes(root) { | |
| 955 | + const map = new Map(); | |
| 956 | + const IGNORE_DIRS = new Set(['.git', 'node_modules', '.cache', '.tmp']); | |
| 957 | + function walk(dir, prefix) { | |
| 958 | + let entries; | |
| 959 | + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } | |
| 960 | + catch { return; } | |
| 961 | + for (const e of entries) { | |
| 962 | + if (IGNORE_DIRS.has(e.name)) continue; | |
| 963 | + const rel = prefix ? `${prefix}/${e.name}` : e.name; | |
| 964 | + const full = path.join(dir, e.name); | |
| 965 | + if (e.isDirectory()) { | |
| 966 | + walk(full, rel); | |
| 967 | + } else if (e.isFile()) { | |
| 968 | + try { | |
| 969 | + map.set(rel, fs.statSync(full).mtimeMs); | |
| 970 | + } catch { /* ignore */ } | |
| 971 | + } | |
| 972 | + } | |
| 973 | + } | |
| 974 | + walk(root, ''); | |
| 975 | + return map; | |
| 976 | +} | |
| 977 | + | |
| 978 | +function _readPrdRev(pid) { | |
| 979 | + const root = pathResolver.resolveProjectRoot(pid); | |
| 980 | + const idx = path.join(root, 'prd', 'index.html'); | |
| 981 | + if (!fs.existsSync(idx)) return 0; | |
| 982 | + const m = fs.readFileSync(idx, 'utf8').match(/<article[^>]*data-doc="prd"[^>]*data-rev="(\d+)"/); | |
| 983 | + return m ? parseInt(m[1], 10) : 1; | |
| 984 | +} | |
| 985 | + | |
| 467 | 986 | module.exports = { |
| 468 | 987 | createProject, |
| 469 | 988 | commitAfterAgent, |
| 470 | 989 | callAgent, |
| 471 | 990 | runDocsPhase, |
| 472 | 991 | runUnit, |
| 992 | + runProject, | |
| 473 | 993 | getProjectState, |
| 994 | + // P4 | |
| 995 | + signOff, | |
| 996 | + abandon, | |
| 997 | + submitFeedback, | |
| 998 | + InvalidProjectStatusTransition, | |
| 474 | 999 | }; | ... | ... |
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 | +}); | ... | ... |
server/tests/devloop/path-resolver.test.js
| ... | ... | @@ -109,13 +109,54 @@ describe('pathResolver', () => { |
| 109 | 109 | }); |
| 110 | 110 | }); |
| 111 | 111 | |
| 112 | - describe('allocateProjectRoot · linked-existing rejected in v1', () => { | |
| 113 | - it('throws NOT_IMPLEMENTED', () => { | |
| 112 | + describe('allocateProjectRoot · linked-existing', () => { | |
| 113 | + it('rejects nonexistent target_path', () => { | |
| 114 | + const fake = path.join(os.tmpdir(), 'devloop-le-nope-' + Math.random().toString(36).slice(2,8)); | |
| 114 | 115 | expect(() => |
| 115 | 116 | pathResolver.allocateProjectRoot({ |
| 116 | - company_id: 'c-test', mode: 'linked-existing', target_path: '/tmp', | |
| 117 | + company_id: 'c-test', mode: 'linked-existing', target_path: fake, | |
| 117 | 118 | }) |
| 118 | - ).toThrow(/v2/); | |
| 119 | + ).toThrow(/does not exist/); | |
| 120 | + }); | |
| 121 | + | |
| 122 | + it('rejects target_path that already contains a DevLoop project.json', () => { | |
| 123 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dup-')); | |
| 124 | + fs.writeFileSync(path.join(dir, 'project.json'), '{}'); | |
| 125 | + expect(() => | |
| 126 | + pathResolver.allocateProjectRoot({ | |
| 127 | + company_id: 'c-test', mode: 'linked-existing', target_path: dir, | |
| 128 | + }) | |
| 129 | + ).toThrow(/already contains/); | |
| 130 | + }); | |
| 131 | + | |
| 132 | + it('accepts existing non-empty plain dir (no .git)', () => { | |
| 133 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-plain-')); | |
| 134 | + fs.writeFileSync(path.join(dir, 'README.md'), '# user'); | |
| 135 | + const r = pathResolver.allocateProjectRoot({ | |
| 136 | + company_id: 'c-test', mode: 'linked-existing', target_path: dir, | |
| 137 | + }); | |
| 138 | + expect(r.pid).toMatch(/^p-/); | |
| 139 | + expect(r.root_path).toBe(dir); | |
| 140 | + expect(r.mode).toBe('linked-existing'); | |
| 141 | + pathResolver.unregisterLinkedProject(r.pid); | |
| 142 | + }); | |
| 143 | + | |
| 144 | + it('rejects existing git repo with dirty worktree', () => { | |
| 145 | + const { execFileSync } = require('child_process'); | |
| 146 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dirty-')); | |
| 147 | + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }); | |
| 148 | + execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir }); | |
| 149 | + execFileSync('git', ['config', 'user.name', 't'], { cwd: dir }); | |
| 150 | + fs.writeFileSync(path.join(dir, 'a.txt'), 'one'); | |
| 151 | + execFileSync('git', ['add', '-A'], { cwd: dir }); | |
| 152 | + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); | |
| 153 | + // dirty: untracked | |
| 154 | + fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked'); | |
| 155 | + expect(() => | |
| 156 | + pathResolver.allocateProjectRoot({ | |
| 157 | + company_id: 'c-test', mode: 'linked-existing', target_path: dir, | |
| 158 | + }) | |
| 159 | + ).toThrow(/uncommitted changes/); | |
| 119 | 160 | }); |
| 120 | 161 | }); |
| 121 | 162 | ... | ... |
server/tests/devloop/routes-crud.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * routes/devloop.js · 项目 CRUD 路由测试 | |
| 3 | + * | |
| 4 | + * 覆盖 (新增的) PATCH / DELETE (soft + hard) / restore + listProjects 过滤。 | |
| 5 | + */ | |
| 6 | + | |
| 7 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | |
| 8 | + | |
| 9 | +const fs = require('fs'); | |
| 10 | +const path = require('path'); | |
| 11 | +const os = require('os'); | |
| 12 | +const express = require('express'); | |
| 13 | + | |
| 14 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-crud-')); | |
| 15 | +process.env.HOME = TEST_HOME; | |
| 16 | + | |
| 17 | +const pathResolver = require('../../services/devloop/path-resolver'); | |
| 18 | +const devloopRouter = require('../../routes/devloop'); | |
| 19 | + | |
| 20 | +function startApp() { | |
| 21 | + const app = express(); | |
| 22 | + app.use(express.json()); | |
| 23 | + app.use('/api/devloop', devloopRouter); | |
| 24 | + return new Promise((resolve) => { | |
| 25 | + const srv = app.listen(0, '127.0.0.1', () => { | |
| 26 | + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); | |
| 27 | + }); | |
| 28 | + }); | |
| 29 | +} | |
| 30 | + | |
| 31 | +async function createProject(base, cid, requirement = 'crud test') { | |
| 32 | + const r = await fetch(`${base}/api/devloop/projects`, { | |
| 33 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 34 | + body: JSON.stringify({ company_id: cid, requirement }), | |
| 35 | + }); | |
| 36 | + const j = await r.json(); | |
| 37 | + return j.pid; | |
| 38 | +} | |
| 39 | + | |
| 40 | +describe('routes/devloop · CRUD', () => { | |
| 41 | + let app, base; | |
| 42 | + const cids = []; | |
| 43 | + | |
| 44 | + beforeAll(async () => { | |
| 45 | + pathResolver._resetForTesting(); | |
| 46 | + ({ srv: app, base } = await startApp()); | |
| 47 | + }); | |
| 48 | + | |
| 49 | + afterAll(() => { | |
| 50 | + app.close(); | |
| 51 | + for (const c of cids) { | |
| 52 | + try { | |
| 53 | + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); | |
| 54 | + } catch { /* ignore */ } | |
| 55 | + } | |
| 56 | + }); | |
| 57 | + | |
| 58 | + describe('PATCH /projects/:pid', () => { | |
| 59 | + let pid, cid; | |
| 60 | + beforeAll(async () => { | |
| 61 | + cid = 'c-patch-' + Math.random().toString(36).slice(2, 7); | |
| 62 | + cids.push(cid); | |
| 63 | + pid = await createProject(base, cid); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it('updates name + idea_summary', async () => { | |
| 67 | + const r = await fetch(`${base}/api/devloop/projects/${pid}`, { | |
| 68 | + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, | |
| 69 | + body: JSON.stringify({ name: 'renamed project', idea_summary: 'new summary' }), | |
| 70 | + }); | |
| 71 | + expect(r.status).toBe(200); | |
| 72 | + const j = await r.json(); | |
| 73 | + expect(j.project.name).toBe('renamed project'); | |
| 74 | + expect(j.project.idea_summary).toBe('new summary'); | |
| 75 | + }); | |
| 76 | + | |
| 77 | + it('rejects empty body (no allowed fields)', async () => { | |
| 78 | + const r = await fetch(`${base}/api/devloop/projects/${pid}`, { | |
| 79 | + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, | |
| 80 | + body: JSON.stringify({}), | |
| 81 | + }); | |
| 82 | + expect(r.status).toBe(400); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it('rejects non-string fields', async () => { | |
| 86 | + const r = await fetch(`${base}/api/devloop/projects/${pid}`, { | |
| 87 | + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, | |
| 88 | + body: JSON.stringify({ name: 123 }), | |
| 89 | + }); | |
| 90 | + expect(r.status).toBe(400); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it('ignores fields not in allowlist (status / archived not changeable here)', async () => { | |
| 94 | + const r = await fetch(`${base}/api/devloop/projects/${pid}`, { | |
| 95 | + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, | |
| 96 | + body: JSON.stringify({ status: 'shipped-final', archived: true }), | |
| 97 | + }); | |
| 98 | + expect(r.status).toBe(400); // 没有任何 allowed 字段 → 400 | |
| 99 | + }); | |
| 100 | + | |
| 101 | + it('404 on unknown pid', async () => { | |
| 102 | + const r = await fetch(`${base}/api/devloop/projects/p-nope`, { | |
| 103 | + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, | |
| 104 | + body: JSON.stringify({ name: 'x' }), | |
| 105 | + }); | |
| 106 | + expect(r.status).toBe(404); | |
| 107 | + }); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + describe('DELETE soft (archive) + GET list', () => { | |
| 111 | + let pidA, pidB, cid; | |
| 112 | + beforeAll(async () => { | |
| 113 | + cid = 'c-del-' + Math.random().toString(36).slice(2, 7); | |
| 114 | + cids.push(cid); | |
| 115 | + pidA = await createProject(base, cid, 'kept'); | |
| 116 | + pidB = await createProject(base, cid, 'will be archived'); | |
| 117 | + }); | |
| 118 | + | |
| 119 | + it('soft delete sets archived=true (default no hard query)', async () => { | |
| 120 | + const r = await fetch(`${base}/api/devloop/projects/${pidB}`, { method: 'DELETE' }); | |
| 121 | + expect(r.status).toBe(200); | |
| 122 | + const j = await r.json(); | |
| 123 | + expect(j.hard).toBe(false); | |
| 124 | + expect(j.archived).toBe(true); | |
| 125 | + expect(j.project.archived).toBe(true); | |
| 126 | + }); | |
| 127 | + | |
| 128 | + it('default listProjects excludes archived', async () => { | |
| 129 | + const r = await fetch(`${base}/api/devloop/projects`); | |
| 130 | + const j = await r.json(); | |
| 131 | + const ids = j.projects.map(p => p.pid); | |
| 132 | + expect(ids).toContain(pidA); | |
| 133 | + expect(ids).not.toContain(pidB); | |
| 134 | + }); | |
| 135 | + | |
| 136 | + it('?include_archived=true returns archived too', async () => { | |
| 137 | + const r = await fetch(`${base}/api/devloop/projects?include_archived=true`); | |
| 138 | + const j = await r.json(); | |
| 139 | + const ids = j.projects.map(p => p.pid); | |
| 140 | + expect(ids).toContain(pidA); | |
| 141 | + expect(ids).toContain(pidB); | |
| 142 | + const arch = j.projects.find(p => p.pid === pidB); | |
| 143 | + expect(arch.archived).toBe(true); | |
| 144 | + }); | |
| 145 | + | |
| 146 | + it('POST /restore un-archives', async () => { | |
| 147 | + const r = await fetch(`${base}/api/devloop/projects/${pidB}/restore`, { method: 'POST' }); | |
| 148 | + expect(r.status).toBe(200); | |
| 149 | + const j = await r.json(); | |
| 150 | + expect(j.archived).toBe(false); | |
| 151 | + // 现在默认 list 应能见到 | |
| 152 | + const list = await (await fetch(`${base}/api/devloop/projects`)).json(); | |
| 153 | + expect(list.projects.map(p => p.pid)).toContain(pidB); | |
| 154 | + }); | |
| 155 | + }); | |
| 156 | + | |
| 157 | + describe('DELETE hard', () => { | |
| 158 | + it('?hard=true rm -rf 项目目录 (sandbox)', async () => { | |
| 159 | + const cid = 'c-hard-' + Math.random().toString(36).slice(2, 7); | |
| 160 | + cids.push(cid); | |
| 161 | + const pid = await createProject(base, cid, 'hard delete me'); | |
| 162 | + const rootBefore = pathResolver.resolveProjectRoot(pid); | |
| 163 | + expect(fs.existsSync(rootBefore)).toBe(true); | |
| 164 | + | |
| 165 | + const r = await fetch(`${base}/api/devloop/projects/${pid}?hard=true`, { method: 'DELETE' }); | |
| 166 | + expect(r.status).toBe(200); | |
| 167 | + const j = await r.json(); | |
| 168 | + expect(j.hard).toBe(true); | |
| 169 | + expect(j.removed_disk).toBe(true); | |
| 170 | + expect(fs.existsSync(rootBefore)).toBe(false); | |
| 171 | + | |
| 172 | + // 后续读 state 应 404 (pid 已从 cache 移除) | |
| 173 | + const s = await fetch(`${base}/api/devloop/projects/${pid}/state`); | |
| 174 | + expect(s.status).toBe(404); | |
| 175 | + }); | |
| 176 | + | |
| 177 | + it('hard delete unknown pid → 404', async () => { | |
| 178 | + const r = await fetch(`${base}/api/devloop/projects/p-nope?hard=true`, { method: 'DELETE' }); | |
| 179 | + expect(r.status).toBe(404); | |
| 180 | + }); | |
| 181 | + }); | |
| 182 | + | |
| 183 | + describe('GET /projects?include_archived=false (default)', () => { | |
| 184 | + it('returns projects array (could be empty)', async () => { | |
| 185 | + const r = await fetch(`${base}/api/devloop/projects`); | |
| 186 | + expect(r.status).toBe(200); | |
| 187 | + const j = await r.json(); | |
| 188 | + expect(Array.isArray(j.projects)).toBe(true); | |
| 189 | + }); | |
| 190 | + }); | |
| 191 | +}); | ... | ... |
server/tests/devloop/routes.test.js
| ... | ... | @@ -106,9 +106,13 @@ describe('routes/devloop', () => { |
| 106 | 106 | expect(s.units).toHaveLength(3); |
| 107 | 107 | }); |
| 108 | 108 | |
| 109 | - it('POST /api/devloop/projects/:pid/run requires ?only (501 without)', async () => { | |
| 110 | - const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { method: 'POST' }); | |
| 111 | - expect(r.status).toBe(501); | |
| 109 | + it('POST /api/devloop/projects/:pid/run without ?only on unknown pid returns 404 (full-project mode)', async () => { | |
| 110 | + const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { | |
| 111 | + method: 'POST', | |
| 112 | + headers: { 'Content-Type': 'application/json' }, | |
| 113 | + body: JSON.stringify({}), | |
| 114 | + }); | |
| 115 | + expect(r.status).toBe(404); | |
| 112 | 116 | }); |
| 113 | 117 | |
| 114 | 118 | it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => { |
| ... | ... | @@ -133,6 +137,32 @@ describe('routes/devloop', () => { |
| 133 | 137 | expect(j2.history).toHaveLength(4); |
| 134 | 138 | }); |
| 135 | 139 | |
| 140 | + it('POST /run (full project, no ?only) drives runProject', async () => { | |
| 141 | + const cid = 'c-route-p3-' + Math.random().toString(36).slice(2, 7); | |
| 142 | + created.push(cid); | |
| 143 | + const r1 = await fetch(`${base}/api/devloop/projects`, { | |
| 144 | + method: 'POST', | |
| 145 | + headers: { 'Content-Type': 'application/json' }, | |
| 146 | + body: JSON.stringify({ company_id: cid, requirement: 'TODO demo' }), | |
| 147 | + }); | |
| 148 | + const pid = (await r1.json()).pid; | |
| 149 | + // ship via full project run (baseline fixtures = happy path) | |
| 150 | + const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run`, { | |
| 151 | + method: 'POST', | |
| 152 | + headers: { 'Content-Type': 'application/json' }, | |
| 153 | + body: JSON.stringify({}), | |
| 154 | + }); | |
| 155 | + expect(r2.status).toBe(200); | |
| 156 | + const j = await r2.json(); | |
| 157 | + expect(['shipped', 'l2_blocked', 'l2_exhausted']).toContain(j.final_state); | |
| 158 | + expect(typeof j.l2_rounds).toBe('number'); | |
| 159 | + }); | |
| 160 | + | |
| 161 | + it('GET /api/devloop/projects/:pid/bugs returns 404 on unknown pid', async () => { | |
| 162 | + const r = await fetch(`${base}/api/devloop/projects/p-nope/bugs`); | |
| 163 | + expect(r.status).toBe(404); | |
| 164 | + }); | |
| 165 | + | |
| 136 | 166 | it('POST /run?only on unknown unit returns 404', async () => { |
| 137 | 167 | const cid = 'c-route-p2u-' + Math.random().toString(36).slice(2, 7); |
| 138 | 168 | created.push(cid); |
| ... | ... | @@ -159,4 +189,52 @@ describe('routes/devloop', () => { |
| 159 | 189 | const j = await r.json(); |
| 160 | 190 | expect(Array.isArray(j.projects)).toBe(true); |
| 161 | 191 | }); |
| 192 | + | |
| 193 | + // P5 — UI-supporting endpoints | |
| 194 | + describe('P5 UI endpoints', () => { | |
| 195 | + let pid; | |
| 196 | + beforeAll(async () => { | |
| 197 | + const cid = 'c-p5-routes-' + Math.random().toString(36).slice(2, 7); | |
| 198 | + created.push(cid); | |
| 199 | + const r = await fetch(`${base}/api/devloop/projects`, { | |
| 200 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 201 | + body: JSON.stringify({ company_id: cid, requirement: 'p5 test' }), | |
| 202 | + }); | |
| 203 | + pid = (await r.json()).pid; | |
| 204 | + }); | |
| 205 | + | |
| 206 | + it('GET /timeline returns events array', async () => { | |
| 207 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline`); | |
| 208 | + expect(r.status).toBe(200); | |
| 209 | + const j = await r.json(); | |
| 210 | + expect(Array.isArray(j.events)).toBe(true); | |
| 211 | + expect(j.events.length).toBeGreaterThan(0); | |
| 212 | + }); | |
| 213 | + | |
| 214 | + it('GET /changelog returns entries array', async () => { | |
| 215 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`); | |
| 216 | + expect(r.status).toBe(200); | |
| 217 | + const j = await r.json(); | |
| 218 | + expect(Array.isArray(j.entries)).toBe(true); | |
| 219 | + }); | |
| 220 | + | |
| 221 | + it('GET /units/:unitId returns unit + runs + coder_log_tail', async () => { | |
| 222 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`); | |
| 223 | + expect(r.status).toBe(200); | |
| 224 | + const j = await r.json(); | |
| 225 | + expect(j).toHaveProperty('unit'); | |
| 226 | + expect(j).toHaveProperty('runs'); | |
| 227 | + expect(Array.isArray(j.runs)).toBe(true); | |
| 228 | + }); | |
| 229 | + | |
| 230 | + it('GET /units/:unitId 404 on unknown unit', async () => { | |
| 231 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/no-such-unit`); | |
| 232 | + expect(r.status).toBe(404); | |
| 233 | + }); | |
| 234 | + | |
| 235 | + it('GET /timeline 404 on unknown pid', async () => { | |
| 236 | + const r = await fetch(`${base}/api/devloop/projects/p-nope/timeline`); | |
| 237 | + expect(r.status).toBe(404); | |
| 238 | + }); | |
| 239 | + }); | |
| 162 | 240 | }); | ... | ... |
server/tests/devloop/run-project.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * loop-controller.runProject (P3 L2 联动) 单测 | |
| 3 | + * | |
| 4 | + * 跑 4 个 L2 分支: | |
| 5 | + * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 | |
| 6 | + * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 | |
| 7 | + * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE | |
| 8 | + * ④ infra_flake → shipped · sdd_rev=1 · l2_rounds=0 | |
| 9 | + */ | |
| 10 | + | |
| 11 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | |
| 12 | + | |
| 13 | +const fs = require('fs'); | |
| 14 | +const path = require('path'); | |
| 15 | +const os = require('os'); | |
| 16 | + | |
| 17 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rp-')); | |
| 18 | +process.env.HOME = TEST_HOME; | |
| 19 | + | |
| 20 | +const pathResolver = require('../../services/devloop/path-resolver'); | |
| 21 | +const sseBroker = require('../../services/devloop/sse-broker'); | |
| 22 | +const bugTracker = require('../../services/devloop/bug-tracker'); | |
| 23 | +const lc = require('../../src/core/devloop/loop-controller'); | |
| 24 | + | |
| 25 | +const SCENARIO_BASE = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios'); | |
| 26 | + | |
| 27 | +async function setupScenario(name) { | |
| 28 | + pathResolver._resetForTesting(); | |
| 29 | + sseBroker._resetForTesting(); | |
| 30 | + const cid = 'c-p3-' + name + '-' + Math.random().toString(36).slice(2, 7); | |
| 31 | + const dir = path.join(SCENARIO_BASE, name); | |
| 32 | + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p3 ' + name }); | |
| 33 | + await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir }); | |
| 34 | + return { pid: proj.pid, cid, dir }; | |
| 35 | +} | |
| 36 | + | |
| 37 | +function cleanup(cid) { | |
| 38 | + try { | |
| 39 | + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); | |
| 40 | + } catch { /* ignore */ } | |
| 41 | +} | |
| 42 | + | |
| 43 | +describe('runProject · L2 branches', () => { | |
| 44 | + describe('① contract_mismatch', () => { | |
| 45 | + let pid, cid, dir, result; | |
| 46 | + const sseEvents = []; | |
| 47 | + | |
| 48 | + beforeAll(async () => { | |
| 49 | + ({ pid, cid, dir } = await setupScenario('contract_mismatch')); | |
| 50 | + const fakeRes = { | |
| 51 | + setHeader() {}, flushHeaders() {}, end() {}, | |
| 52 | + on() {}, // sse-broker subscribes res.on('close', ...) | |
| 53 | + write(line) { | |
| 54 | + const m = line.match(/^event: (.+)$/m); | |
| 55 | + if (m) sseEvents.push(m[1]); | |
| 56 | + }, | |
| 57 | + }; | |
| 58 | + sseBroker.subscribe(pid, fakeRes, {}); | |
| 59 | + result = await lc.runProject(pid, { mockFixtureDir: dir }); | |
| 60 | + }); | |
| 61 | + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); | |
| 62 | + | |
| 63 | + it('ships after 1 L2 round', () => { | |
| 64 | + expect(result.ok).toBe(true); | |
| 65 | + expect(result.final_state).toBe('shipped'); | |
| 66 | + }); | |
| 67 | + | |
| 68 | + it('units.json l2_rounds=1 and sdd_rev bumped to 2', () => { | |
| 69 | + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); | |
| 70 | + expect(u.l2_rounds).toBe(1); | |
| 71 | + expect(u.sdd_rev).toBe('2'); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + it('emits sdd.updated event', () => { | |
| 75 | + expect(sseEvents).toContain('sdd.updated'); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + it('changelog has sdd_rev entry', () => { | |
| 79 | + const cl = pathResolver.resolveChangelog(pid); | |
| 80 | + expect(fs.existsSync(cl)).toBe(true); | |
| 81 | + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); | |
| 82 | + expect(lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)).toBe(true); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it('bug opened then transitioned to fixed', () => { | |
| 86 | + const bugs = bugTracker.list(pid, {}); | |
| 87 | + expect(bugs).toHaveLength(1); | |
| 88 | + expect(bugs[0].status).toBe('fixed'); | |
| 89 | + }); | |
| 90 | + }); | |
| 91 | + | |
| 92 | + describe('② impl_bug', () => { | |
| 93 | + let pid, cid, dir, result; | |
| 94 | + | |
| 95 | + beforeAll(async () => { | |
| 96 | + ({ pid, cid, dir } = await setupScenario('impl_bug')); | |
| 97 | + result = await lc.runProject(pid, { mockFixtureDir: dir }); | |
| 98 | + }); | |
| 99 | + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); | |
| 100 | + | |
| 101 | + it('ships without SDD change', () => { | |
| 102 | + expect(result.ok).toBe(true); | |
| 103 | + expect(result.final_state).toBe('shipped'); | |
| 104 | + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); | |
| 105 | + expect(u.l2_rounds).toBe(0); | |
| 106 | + expect(u.sdd_rev).toBe('1'); | |
| 107 | + }); | |
| 108 | + | |
| 109 | + it('history shows impl_bug reset_units (no sdd_patch)', () => { | |
| 110 | + const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); | |
| 111 | + expect(actions.some(a => /impl_bug/.test(a))).toBe(true); | |
| 112 | + expect(actions.some(a => /sdd_patch/.test(a))).toBe(false); | |
| 113 | + }); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + describe('③ spec_ambiguous', () => { | |
| 117 | + let pid, cid, dir, result; | |
| 118 | + const sseEvents = []; | |
| 119 | + | |
| 120 | + beforeAll(async () => { | |
| 121 | + ({ pid, cid, dir } = await setupScenario('spec_ambiguous')); | |
| 122 | + const fakeRes = { | |
| 123 | + setHeader() {}, flushHeaders() {}, end() {}, | |
| 124 | + on() {}, // sse-broker subscribes res.on('close', ...) | |
| 125 | + write(line) { | |
| 126 | + const m = line.match(/^event: (.+)$/m); | |
| 127 | + if (m) sseEvents.push(m[1]); | |
| 128 | + }, | |
| 129 | + }; | |
| 130 | + sseBroker.subscribe(pid, fakeRes, {}); | |
| 131 | + result = await lc.runProject(pid, { mockFixtureDir: dir }); | |
| 132 | + }); | |
| 133 | + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); | |
| 134 | + | |
| 135 | + it('returns l2_blocked', () => { | |
| 136 | + expect(result.ok).toBe(false); | |
| 137 | + expect(result.final_state).toBe('l2_blocked'); | |
| 138 | + }); | |
| 139 | + | |
| 140 | + it('emits human_intervention_needed SSE', () => { | |
| 141 | + expect(sseEvents).toContain('human_intervention_needed'); | |
| 142 | + }); | |
| 143 | + }); | |
| 144 | + | |
| 145 | + describe('④ infra_flake', () => { | |
| 146 | + let pid, cid, dir, result; | |
| 147 | + | |
| 148 | + beforeAll(async () => { | |
| 149 | + ({ pid, cid, dir } = await setupScenario('infra_flake')); | |
| 150 | + result = await lc.runProject(pid, { mockFixtureDir: dir }); | |
| 151 | + }); | |
| 152 | + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); | |
| 153 | + | |
| 154 | + it('ships after re-run without budget burn', () => { | |
| 155 | + expect(result.ok).toBe(true); | |
| 156 | + expect(result.final_state).toBe('shipped'); | |
| 157 | + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); | |
| 158 | + expect(u.l2_rounds).toBe(0); | |
| 159 | + }); | |
| 160 | + | |
| 161 | + it('history records rerun_integrator', () => { | |
| 162 | + const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); | |
| 163 | + expect(actions.some(a => /rerun_integrator/.test(a))).toBe(true); | |
| 164 | + }); | |
| 165 | + }); | |
| 166 | +}); | ... | ... |
start.sh
| ... | ... | @@ -37,9 +37,9 @@ for PORT in 3010 5173; do |
| 37 | 37 | fi |
| 38 | 38 | done |
| 39 | 39 | |
| 40 | -# 启动后端 Agent Chat Server | |
| 41 | -echo "🔌 启动 Agent Server (端口 3010)..." | |
| 42 | -PORT=3010 node server/agent-server.js & | |
| 40 | +# 启动后端主服务 (全量: Agent + DevLoop + Master + Skills + Arena + Auth + WS) | |
| 41 | +echo "🔌 启动 LemonBoss Server (端口 3010)..." | |
| 42 | +PORT=3010 node server/index.js & | |
| 43 | 43 | SERVER_PID=$! |
| 44 | 44 | |
| 45 | 45 | # 等待后端启动 | ... | ... |
web/src/components/devloop/DashboardHeader.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <header class="dl-header"> | |
| 3 | + <div class="left"> | |
| 4 | + <router-link to="/devloop" class="back">← 项目列表</router-link> | |
| 5 | + <h1>DevLoop · {{ pid }}</h1> | |
| 6 | + <span class="status-badge" :class="status">{{ status }}</span> | |
| 7 | + </div> | |
| 8 | + <div class="right"> | |
| 9 | + <button class="btn primary" @click="$emit('new-requirement')" :disabled="terminal">+ 新增需求</button> | |
| 10 | + <button class="btn signoff" @click="$emit('signoff')" :disabled="status !== 'shipped'">Sign Off</button> | |
| 11 | + <button class="btn abandon" @click="$emit('abandon')" :disabled="terminal">Abandon</button> | |
| 12 | + <span class="sep">|</span> | |
| 13 | + <button class="btn link" @click="$emit('ops', 'bugs')">Ops →</button> | |
| 14 | + </div> | |
| 15 | + </header> | |
| 16 | +</template> | |
| 17 | + | |
| 18 | +<script setup> | |
| 19 | +import { computed } from 'vue'; | |
| 20 | +const props = defineProps({ | |
| 21 | + pid: { type: String, required: true }, | |
| 22 | + state: { type: Object, default: null }, | |
| 23 | +}); | |
| 24 | +defineEmits(['new-requirement', 'signoff', 'abandon', 'ops']); | |
| 25 | +const status = computed(() => props.state?.status || props.state?.project?.status || 'unknown'); | |
| 26 | +const terminal = computed(() => status.value === 'shipped-final' || status.value === 'abandoned'); | |
| 27 | +</script> | |
| 28 | + | |
| 29 | +<style scoped> | |
| 30 | +.dl-header { | |
| 31 | + display:flex; align-items:center; justify-content:space-between; | |
| 32 | + padding:12px 0; border-bottom:1px solid #eee; | |
| 33 | +} | |
| 34 | +.left { display:flex; align-items:center; gap:14px; } | |
| 35 | +.right { display:flex; align-items:center; gap:8px; } | |
| 36 | +.back { color:#888; text-decoration:none; font-size:13px; } | |
| 37 | +.back:hover { color:#222; } | |
| 38 | +h1 { font-size:18px; font-weight:600; margin:0; font-family: ui-monospace, monospace; } | |
| 39 | +.status-badge { | |
| 40 | + font-size:11px; padding:3px 10px; border-radius:12px; text-transform:uppercase; font-weight:600; | |
| 41 | +} | |
| 42 | +.status-badge.draft { background:#eee; color:#666; } | |
| 43 | +.status-badge.running { background:#cce5ff; color:#004085; } | |
| 44 | +.status-badge.shipped { background:#d4edda; color:#155724; } | |
| 45 | +.status-badge.shipped-final { background:#0a6; color:#fff; } | |
| 46 | +.status-badge.abandoned { background:#222; color:#fff; } | |
| 47 | +.status-badge.l2_blocked { background:#fff3cd; color:#856404; } | |
| 48 | +.status-badge.l2_exhausted { background:#f8d7da; color:#721c24; } | |
| 49 | +.status-badge.unknown { background:#fafafa; color:#999; border:1px solid #eee; } | |
| 50 | +.btn { padding:6px 14px; border:none; border-radius:4px; cursor:pointer; font-size:13px; } | |
| 51 | +.btn:disabled { background:#ddd; color:#999; cursor:not-allowed; } | |
| 52 | +.btn.primary { background:#222; color:#fff; } | |
| 53 | +.btn.signoff { background:#0a6; color:#fff; } | |
| 54 | +.btn.abandon { background:#822; color:#fff; } | |
| 55 | +.btn.link { background:transparent; color:#0a6; padding:6px 0; } | |
| 56 | +.sep { color:#ccc; } | |
| 57 | +</style> | ... | ... |
web/src/components/devloop/FeedbackModal.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="modal-backdrop" @click.self="$emit('close')"> | |
| 3 | + <div class="modal"> | |
| 4 | + <header> | |
| 5 | + <h2>+ NEW REQUIREMENT</h2> | |
| 6 | + <button class="x" @click="$emit('close')">×</button> | |
| 7 | + </header> | |
| 8 | + <p class="hint">提交一段新的需求 / 反馈 → PM 进 incremental 模式,追加 PRD 章节并 bump rev。</p> | |
| 9 | + <textarea | |
| 10 | + v-model="text" | |
| 11 | + :disabled="submitting" | |
| 12 | + placeholder="例如:增加 toggle done 功能;或:把 POST /todos 改成支持 text+priority" | |
| 13 | + /> | |
| 14 | + <div v-if="err" class="err">{{ err }}</div> | |
| 15 | + <div v-if="ok" class="ok">{{ ok }}</div> | |
| 16 | + <footer> | |
| 17 | + <button class="btn cancel" @click="$emit('close')" :disabled="submitting">Cancel</button> | |
| 18 | + <button class="btn submit" @click="submit" :disabled="!text.trim() || submitting">{{ submitting ? '...' : 'Submit' }}</button> | |
| 19 | + </footer> | |
| 20 | + </div> | |
| 21 | + </div> | |
| 22 | +</template> | |
| 23 | + | |
| 24 | +<script setup> | |
| 25 | +import { ref } from 'vue'; | |
| 26 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 27 | +const emit = defineEmits(['close', 'submitted']); | |
| 28 | + | |
| 29 | +const text = ref(''); | |
| 30 | +const submitting = ref(false); | |
| 31 | +const err = ref(''); | |
| 32 | +const ok = ref(''); | |
| 33 | + | |
| 34 | +async function submit() { | |
| 35 | + if (!text.value.trim()) return; | |
| 36 | + submitting.value = true; | |
| 37 | + err.value = ''; ok.value = ''; | |
| 38 | + try { | |
| 39 | + const r = await fetch(`/api/devloop/projects/${props.pid}/feedback`, { | |
| 40 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 41 | + body: JSON.stringify({ text: text.value.trim(), by: 'human' }), | |
| 42 | + }); | |
| 43 | + const j = await r.json(); | |
| 44 | + if (!r.ok) { | |
| 45 | + err.value = j.error || `HTTP ${r.status}`; | |
| 46 | + } else { | |
| 47 | + ok.value = `PRD bumped: rev ${j.prd_rev_before} → ${j.prd_rev_after} · status=${j.status}`; | |
| 48 | + setTimeout(() => emit('submitted'), 800); | |
| 49 | + } | |
| 50 | + } catch (e) { | |
| 51 | + err.value = e.message; | |
| 52 | + } finally { | |
| 53 | + submitting.value = false; | |
| 54 | + } | |
| 55 | +} | |
| 56 | +</script> | |
| 57 | + | |
| 58 | +<style scoped> | |
| 59 | +.modal-backdrop { | |
| 60 | + position:fixed; inset:0; background:rgba(0,0,0,.4); z-index:1000; | |
| 61 | + display:flex; align-items:center; justify-content:center; | |
| 62 | +} | |
| 63 | +.modal { | |
| 64 | + background:#fff; border-radius:8px; width:560px; max-width:90vw; | |
| 65 | + padding:20px 24px; box-shadow:0 8px 32px rgba(0,0,0,.2); | |
| 66 | +} | |
| 67 | +header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; } | |
| 68 | +h2 { margin:0; font-size:16px; } | |
| 69 | +.x { background:none; border:none; font-size:24px; cursor:pointer; color:#888; line-height:1; } | |
| 70 | +.hint { color:#888; font-size:12px; margin:0 0 12px 0; } | |
| 71 | +textarea { | |
| 72 | + width:100%; min-height:120px; padding:10px; box-sizing:border-box; | |
| 73 | + border:1px solid #ccc; border-radius:4px; font-family:inherit; resize:vertical; | |
| 74 | +} | |
| 75 | +.err { color:#c00; font-size:12px; margin-top:8px; } | |
| 76 | +.ok { color:#070; font-size:12px; margin-top:8px; } | |
| 77 | +footer { margin-top:12px; display:flex; justify-content:flex-end; gap:8px; } | |
| 78 | +.btn { padding:6px 14px; border:none; border-radius:4px; cursor:pointer; font-size:13px; } | |
| 79 | +.btn:disabled { background:#ddd !important; color:#999 !important; cursor:not-allowed; } | |
| 80 | +.btn.cancel { background:#eee; color:#222; } | |
| 81 | +.btn.submit { background:#222; color:#fff; } | |
| 82 | +</style> | ... | ... |
web/src/components/devloop/LiveAgentStream.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="stream"> | |
| 3 | + <div class="bar"> | |
| 4 | + <span class="conn" :class="status">● {{ status }}</span> | |
| 5 | + <span class="lei" v-if="lastEventId">last-event-id: {{ lastEventId }}</span> | |
| 6 | + <span class="grow" /> | |
| 7 | + <button class="btn" @click="clear">clear</button> | |
| 8 | + </div> | |
| 9 | + <div ref="logEl" class="log"> | |
| 10 | + <div v-for="(e, i) in events" :key="i" class="evt"> | |
| 11 | + <span class="ts">{{ formatTs(e.ts) }}</span> | |
| 12 | + <span class="kind">[{{ e.kind }}]</span> | |
| 13 | + <span class="payload">{{ e.payload }}</span> | |
| 14 | + </div> | |
| 15 | + <div v-if="!events.length" class="empty">waiting for events…</div> | |
| 16 | + </div> | |
| 17 | + </div> | |
| 18 | +</template> | |
| 19 | + | |
| 20 | +<script setup> | |
| 21 | +import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'; | |
| 22 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 23 | + | |
| 24 | +const events = ref([]); | |
| 25 | +const status = ref('connecting'); | |
| 26 | +const lastEventId = ref(null); | |
| 27 | +const logEl = ref(null); | |
| 28 | +let es = null; | |
| 29 | +let reconnectTimer = null; | |
| 30 | +const MAX_EVENTS = 250; | |
| 31 | + | |
| 32 | +function connect() { | |
| 33 | + if (es) try { es.close(); } catch {} | |
| 34 | + status.value = 'connecting'; | |
| 35 | + const url = `/api/devloop/projects/${props.pid}/stream`; | |
| 36 | + // EventSource 自带 Last-Event-ID; 用户网络断开自动重连。 | |
| 37 | + // (本应用浏览器会自动从 lastEventId 续传) | |
| 38 | + try { | |
| 39 | + es = new EventSource(url); | |
| 40 | + } catch (e) { | |
| 41 | + status.value = 'error'; | |
| 42 | + return; | |
| 43 | + } | |
| 44 | + es.onopen = () => { status.value = 'connected'; }; | |
| 45 | + es.onerror = () => { | |
| 46 | + status.value = 'reconnecting'; | |
| 47 | + // EventSource 内置 reconnect, 但浏览器不一定保证 5s 内重连; | |
| 48 | + // 手动 5s 重试兜底:断后 5s 关掉重新打开,带 Last-Event-ID 由浏览器自动注入。 | |
| 49 | + try { es.close(); } catch {} | |
| 50 | + es = null; | |
| 51 | + if (reconnectTimer) clearTimeout(reconnectTimer); | |
| 52 | + reconnectTimer = setTimeout(connect, 5000); | |
| 53 | + }; | |
| 54 | + // 监听几个核心事件类型(SSE event name) | |
| 55 | + const listenTo = [ | |
| 56 | + 'project.status', 'project.shipped', 'project.run_started', | |
| 57 | + 'l1.wave_start', 'unit.state', | |
| 58 | + 'integration.run_start', 'integration.result', | |
| 59 | + 'triage.result', 'sdd.updated', 'prd.updated', | |
| 60 | + 'human_intervention_needed', 'project.l2_blocked', 'project.l2_exhausted', | |
| 61 | + 'units.reset', 'bug.opened', 'bug.transition', | |
| 62 | + ]; | |
| 63 | + for (const k of listenTo) { | |
| 64 | + es.addEventListener(k, (ev) => addEvent(k, ev)); | |
| 65 | + } | |
| 66 | + // generic message handler 兜底 | |
| 67 | + es.onmessage = (ev) => addEvent(ev.type || 'message', ev); | |
| 68 | +} | |
| 69 | + | |
| 70 | +function addEvent(kind, ev) { | |
| 71 | + let payload; | |
| 72 | + try { payload = JSON.stringify(JSON.parse(ev.data)); } catch { payload = ev.data; } | |
| 73 | + if (ev.lastEventId) lastEventId.value = ev.lastEventId; | |
| 74 | + events.value.push({ ts: Date.now(), kind, payload }); | |
| 75 | + if (events.value.length > MAX_EVENTS) events.value.splice(0, events.value.length - MAX_EVENTS); | |
| 76 | + nextTick(() => { | |
| 77 | + if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight; | |
| 78 | + }); | |
| 79 | +} | |
| 80 | + | |
| 81 | +function formatTs(ts) { | |
| 82 | + const d = new Date(ts); | |
| 83 | + return d.toTimeString().slice(0, 8); | |
| 84 | +} | |
| 85 | + | |
| 86 | +function clear() { events.value = []; } | |
| 87 | + | |
| 88 | +onMounted(connect); | |
| 89 | +onBeforeUnmount(() => { | |
| 90 | + if (reconnectTimer) clearTimeout(reconnectTimer); | |
| 91 | + if (es) try { es.close(); } catch {} | |
| 92 | +}); | |
| 93 | +</script> | |
| 94 | + | |
| 95 | +<style scoped> | |
| 96 | +.stream { display:flex; flex-direction:column; height:240px; } | |
| 97 | +.bar { display:flex; gap:10px; align-items:center; font-size:12px; color:#666; margin-bottom:6px; } | |
| 98 | +.bar .grow { flex:1; } | |
| 99 | +.conn { font-weight:600; } | |
| 100 | +.conn.connecting { color:#888; } | |
| 101 | +.conn.connected { color:#0a6; } | |
| 102 | +.conn.reconnecting { color:#c70; } | |
| 103 | +.conn.error { color:#c00; } | |
| 104 | +.lei { font-family: ui-monospace, monospace; color:#aaa; } | |
| 105 | +.log { | |
| 106 | + flex:1; overflow-y:auto; | |
| 107 | + background:#0f1115; color:#e6e6e6; | |
| 108 | + font-family: ui-monospace, monospace; font-size:12px; | |
| 109 | + padding:10px; border-radius:6px; | |
| 110 | +} | |
| 111 | +.evt { display:flex; gap:8px; margin-bottom:4px; } | |
| 112 | +.ts { color:#7da; flex:0 0 70px; } | |
| 113 | +.kind { color:#ffce6a; flex:0 0 auto; } | |
| 114 | +.payload { color:#bbd; overflow-wrap:anywhere; flex:1; } | |
| 115 | +.empty { color:#666; } | |
| 116 | +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:3px 10px; cursor:pointer; font-size:11px; } | |
| 117 | +</style> | ... | ... |
web/src/components/devloop/LoopStatusBar.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="loop-bar"> | |
| 3 | + <div class="phase" :class="{active: phaseAt('docs')}"> | |
| 4 | + <span class="dot" />Docs<small>PRD rev {{ state.prd_rev ?? '-' }} · SDD rev {{ state.sdd_rev ?? '-' }}</small> | |
| 5 | + </div> | |
| 6 | + <div class="arrow">→</div> | |
| 7 | + <div class="phase" :class="{active: phaseAt('l1')}"> | |
| 8 | + <span class="dot" />L1 Units<small>{{ l1Summary }}</small> | |
| 9 | + </div> | |
| 10 | + <div class="arrow">→</div> | |
| 11 | + <div class="phase" :class="{active: phaseAt('l2')}"> | |
| 12 | + <span class="dot" />L2 Integration<small>l2_rounds {{ l2Rounds }}/{{ l2Budget }}</small> | |
| 13 | + </div> | |
| 14 | + <div class="arrow">→</div> | |
| 15 | + <div class="phase" :class="{active: phaseAt('ship')}"> | |
| 16 | + <span class="dot" />Ship<small>{{ statusLabel }}</small> | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | +</template> | |
| 20 | + | |
| 21 | +<script setup> | |
| 22 | +import { computed } from 'vue'; | |
| 23 | +const props = defineProps({ state: { type: Object, required: true } }); | |
| 24 | + | |
| 25 | +const status = computed(() => props.state.status || props.state.project?.status || 'unknown'); | |
| 26 | +const units = computed(() => props.state.units || []); | |
| 27 | +const l1Summary = computed(() => { | |
| 28 | + const u = units.value; | |
| 29 | + if (!u.length) return 'no units'; | |
| 30 | + const passed = u.filter(x => x.status === 'passed').length; | |
| 31 | + return `${passed}/${u.length} passed`; | |
| 32 | +}); | |
| 33 | +const l2Rounds = computed(() => props.state.project?.status === 'shipped' ? props.state.units_rev || 0 : props.state.units_rev || 0); | |
| 34 | +const l2Budget = computed(() => 3); | |
| 35 | +const statusLabel = computed(() => status.value); | |
| 36 | + | |
| 37 | +function phaseAt(p) { | |
| 38 | + const s = status.value; | |
| 39 | + if (p === 'docs') return s !== 'unknown' && s !== 'draft'; | |
| 40 | + if (p === 'l1') return ['running', 'shipped', 'shipped-final', 'l2_blocked', 'l2_exhausted'].includes(s); | |
| 41 | + if (p === 'l2') return ['shipped', 'shipped-final', 'l2_blocked', 'l2_exhausted'].includes(s); | |
| 42 | + if (p === 'ship') return ['shipped', 'shipped-final'].includes(s); | |
| 43 | + return false; | |
| 44 | +} | |
| 45 | +</script> | |
| 46 | + | |
| 47 | +<style scoped> | |
| 48 | +.loop-bar { display:flex; align-items:stretch; gap:8px; } | |
| 49 | +.phase { | |
| 50 | + flex:1; padding:12px 16px; border:1px solid #e3e3e8; border-radius:6px; | |
| 51 | + display:flex; flex-direction:column; gap:4px; background:#f9f9fb; | |
| 52 | + font-weight:500; color:#999; | |
| 53 | +} | |
| 54 | +.phase.active { background:#fff; border-color:#0a6; color:#222; } | |
| 55 | +.phase small { font-weight:400; font-size:11px; color:#888; } | |
| 56 | +.phase .dot { | |
| 57 | + width:8px; height:8px; border-radius:50%; background:#ccc; display:inline-block; margin-right:6px; | |
| 58 | +} | |
| 59 | +.phase.active .dot { background:#0a6; } | |
| 60 | +.arrow { display:flex; align-items:center; color:#aaa; font-size:18px; } | |
| 61 | +</style> | ... | ... |
web/src/components/devloop/PrdDiffPanel.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="prd-diff"> | |
| 3 | + <div class="meta"> | |
| 4 | + <span class="rev">PRD rev <b>{{ state?.prd_rev ?? '-' }}</b></span> | |
| 5 | + <span class="rev">SDD rev <b>{{ state?.sdd_rev ?? '-' }}</b></span> | |
| 6 | + <span class="grow" /> | |
| 7 | + <button class="btn" @click="reload" :disabled="loading">{{ loading ? '...' : 'refresh' }}</button> | |
| 8 | + </div> | |
| 9 | + | |
| 10 | + <div v-if="entries.length === 0" class="empty">no changelog yet</div> | |
| 11 | + | |
| 12 | + <ul v-else class="entries"> | |
| 13 | + <li v-for="e in entries.slice(0, 12)" :key="e.ts + ':' + e.kind" :class="['entry', e.kind]"> | |
| 14 | + <span class="kind">{{ e.kind }}</span> | |
| 15 | + <span class="rev-num">rev {{ e.rev }}</span> | |
| 16 | + <span class="trigger">[{{ e.trigger || '?' }}]</span> | |
| 17 | + <span class="summary">{{ e.summary }}</span> | |
| 18 | + </li> | |
| 19 | + </ul> | |
| 20 | + </div> | |
| 21 | +</template> | |
| 22 | + | |
| 23 | +<script setup> | |
| 24 | +import { ref, onMounted, watch } from 'vue'; | |
| 25 | +const props = defineProps({ | |
| 26 | + pid: { type: String, required: true }, | |
| 27 | + state: { type: Object, default: null }, | |
| 28 | +}); | |
| 29 | + | |
| 30 | +const entries = ref([]); | |
| 31 | +const loading = ref(false); | |
| 32 | + | |
| 33 | +async function reload() { | |
| 34 | + loading.value = true; | |
| 35 | + try { | |
| 36 | + const r = await fetch(`/api/devloop/projects/${props.pid}/changelog`); | |
| 37 | + const j = await r.json(); | |
| 38 | + entries.value = j.entries || []; | |
| 39 | + } catch (e) { | |
| 40 | + /* swallow */ | |
| 41 | + } finally { | |
| 42 | + loading.value = false; | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +onMounted(reload); | |
| 47 | +// Refresh changelog when prd_rev or sdd_rev changes | |
| 48 | +watch(() => props.state?.prd_rev, reload); | |
| 49 | +watch(() => props.state?.sdd_rev, reload); | |
| 50 | +</script> | |
| 51 | + | |
| 52 | +<style scoped> | |
| 53 | +.prd-diff { display:flex; flex-direction:column; gap:8px; } | |
| 54 | +.meta { display:flex; align-items:center; gap:14px; font-size:12px; color:#666; } | |
| 55 | +.meta .grow { flex:1; } | |
| 56 | +.meta .rev b { color:#222; font-size:14px; } | |
| 57 | +.empty { padding:20px; color:#888; text-align:center; } | |
| 58 | +.entries { list-style:none; padding:0; margin:0; max-height:200px; overflow-y:auto; } | |
| 59 | +.entry { | |
| 60 | + display:flex; gap:10px; padding:6px 8px; | |
| 61 | + border-bottom:1px solid #f0f0f3; font-size:13px; | |
| 62 | +} | |
| 63 | +.entry:last-child { border-bottom:none; } | |
| 64 | +.entry .kind { | |
| 65 | + font-family: ui-monospace, monospace; font-size:11px; padding:2px 8px; border-radius:10px; | |
| 66 | + background:#eef; color:#446; flex:0 0 auto; | |
| 67 | +} | |
| 68 | +.entry.sdd_rev .kind { background:#ffeede; color:#a64a00; } | |
| 69 | +.entry.prd_rev .kind { background:#deecff; color:#1d4d99; } | |
| 70 | +.entry .rev-num { font-weight:600; color:#222; flex:0 0 50px; } | |
| 71 | +.entry .trigger { color:#888; font-size:11px; flex:0 0 auto; } | |
| 72 | +.entry .summary { color:#444; flex:1; overflow:hidden; text-overflow:ellipsis; } | |
| 73 | +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:3px 10px; cursor:pointer; font-size:11px; } | |
| 74 | +.btn:disabled { background:#bbb; } | |
| 75 | +</style> | ... | ... |
web/src/components/devloop/RequirementListSection.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <section class="req-section"> | |
| 3 | + <header class="req-hdr"> | |
| 4 | + <h2>需求列表</h2> | |
| 5 | + <div class="hdr-meta"> | |
| 6 | + <span v-if="data" class="meta"> | |
| 7 | + 共 {{ data.items.length }} 项 · 当前 rev {{ data.current_rev }} · 项目状态 | |
| 8 | + <span :class="['pill', `s-${data.project_status}`]">{{ data.project_status }}</span> | |
| 9 | + </span> | |
| 10 | + <button class="primary" @click="goNew">+ 新增需求</button> | |
| 11 | + </div> | |
| 12 | + </header> | |
| 13 | + | |
| 14 | + <div v-if="error" class="err">{{ error }}</div> | |
| 15 | + | |
| 16 | + <div v-if="loading && !data" class="empty-mini">加载中…</div> | |
| 17 | + <table v-else-if="data && data.items.length" class="rt"> | |
| 18 | + <thead> | |
| 19 | + <tr> | |
| 20 | + <th>rev</th><th>时间</th><th>来源</th><th>触发</th><th>需求摘要</th><th>状态</th> | |
| 21 | + </tr> | |
| 22 | + </thead> | |
| 23 | + <tbody> | |
| 24 | + <tr v-for="it in data.items" :key="it.rev" :class="{ current: it.is_current }"> | |
| 25 | + <td><code>r{{ it.rev }}</code></td> | |
| 26 | + <td class="ts">{{ formatTime(it.ts) }}</td> | |
| 27 | + <td><code>{{ it.by }}</code></td> | |
| 28 | + <td>{{ humanTrigger(it.trigger) }}</td> | |
| 29 | + <td class="text-cell" :title="it.text">{{ truncate(it.text, 100) }}</td> | |
| 30 | + <td> | |
| 31 | + <span v-if="it.is_current" :class="['pill', `s-${it.status}`]">{{ it.status }}</span> | |
| 32 | + <span v-else class="pill older">已被新需求覆盖</span> | |
| 33 | + </td> | |
| 34 | + </tr> | |
| 35 | + </tbody> | |
| 36 | + </table> | |
| 37 | + <div v-else class="empty-mini">尚无需求记录。</div> | |
| 38 | + </section> | |
| 39 | +</template> | |
| 40 | + | |
| 41 | +<script setup> | |
| 42 | +import { ref, onMounted, onBeforeUnmount } from 'vue'; | |
| 43 | +import { useRouter } from 'vue-router'; | |
| 44 | + | |
| 45 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 46 | +const router = useRouter(); | |
| 47 | + | |
| 48 | +const data = ref(null); | |
| 49 | +const error = ref(''); | |
| 50 | +const loading = ref(false); | |
| 51 | +let pollTimer = null; | |
| 52 | + | |
| 53 | +async function fetchList() { | |
| 54 | + loading.value = true; | |
| 55 | + try { | |
| 56 | + const r = await fetch(`/api/devloop/projects/${props.pid}/requirements`); | |
| 57 | + if (!r.ok) throw new Error(`HTTP ${r.status}`); | |
| 58 | + data.value = await r.json(); | |
| 59 | + error.value = ''; | |
| 60 | + } catch (e) { | |
| 61 | + error.value = e.message; | |
| 62 | + } finally { | |
| 63 | + loading.value = false; | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +function goNew() { | |
| 68 | + router.push(`/devloop/${props.pid}/new-requirement`); | |
| 69 | +} | |
| 70 | + | |
| 71 | +function formatTime(iso) { | |
| 72 | + if (!iso) return '—'; | |
| 73 | + try { | |
| 74 | + const d = new Date(iso); | |
| 75 | + return `${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; | |
| 76 | + } catch { return iso; } | |
| 77 | +} | |
| 78 | +function truncate(s, n) { | |
| 79 | + if (!s) return '—'; | |
| 80 | + return s.length > n ? s.slice(0, n) + '…' : s; | |
| 81 | +} | |
| 82 | +function humanTrigger(t) { | |
| 83 | + return ({ project_create: '初始', human_feedback: '人类反馈' }[t]) || t; | |
| 84 | +} | |
| 85 | + | |
| 86 | +onMounted(() => { | |
| 87 | + fetchList(); | |
| 88 | + pollTimer = setInterval(fetchList, 5000); | |
| 89 | +}); | |
| 90 | +onBeforeUnmount(() => { if (pollTimer) clearInterval(pollTimer); }); | |
| 91 | +</script> | |
| 92 | + | |
| 93 | +<style scoped> | |
| 94 | +.req-section { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:16px; } | |
| 95 | +.req-hdr { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; } | |
| 96 | +.req-hdr h2 { font-size:14px; font-weight:600; margin:0; color:#555; } | |
| 97 | +.hdr-meta { display:flex; align-items:center; gap:14px; } | |
| 98 | +.meta { font-size:12px; color:#888; } | |
| 99 | +.err { color:#c00; font-size:12px; padding:8px; background:#fee; border-radius:4px; margin-bottom:8px; } | |
| 100 | +.empty-mini { color:#888; padding:24px; text-align:center; font-size:13px; } | |
| 101 | + | |
| 102 | +button.primary { background:#0a6; color:#fff; border:1px solid #0a6; padding:5px 14px; border-radius:5px; cursor:pointer; font-size:12px; } | |
| 103 | +button.primary:hover { background:#085; } | |
| 104 | + | |
| 105 | +.rt { width:100%; border-collapse:collapse; font-size:13px; } | |
| 106 | +.rt th, .rt td { padding:8px 10px; border-bottom:1px solid #f0f0f0; text-align:left; vertical-align:top; } | |
| 107 | +.rt th { background:#fafafa; color:#666; font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; } | |
| 108 | +.rt tr.current { background:#f5fbf7; } | |
| 109 | +.rt code { background:#f4f4f6; padding:1px 5px; border-radius:3px; font-family: ui-monospace, monospace; font-size:11px; } | |
| 110 | +.rt .ts { white-space:nowrap; color:#666; font-size:12px; } | |
| 111 | +.rt .text-cell { max-width:480px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } | |
| 112 | +.pill { font-size:11px; padding:2px 8px; border-radius:10px; font-weight:600; background:#eee; color:#555; } | |
| 113 | +.pill.older { background:#fafafa; color:#999; font-weight:400; } | |
| 114 | +.pill.s-draft { background:#eef; color:#446; } | |
| 115 | +.pill.s-running { background:#fff3cd; color:#7a5; } | |
| 116 | +.pill.s-shipped { background:#d4f4dd; color:#0a6; } | |
| 117 | +.pill.s-shipped-final { background:#0a6; color:#fff; } | |
| 118 | +.pill.s-abandoned { background:#fbe; color:#922; } | |
| 119 | +.pill.s-l2_blocked { background:#fdd; color:#a44; } | |
| 120 | +</style> | ... | ... |
web/src/components/devloop/UnitDrawer.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="drawer-backdrop" @click.self="$emit('close')"> | |
| 3 | + <aside class="drawer"> | |
| 4 | + <header> | |
| 5 | + <h2>{{ unitId }}</h2> | |
| 6 | + <button class="x" @click="$emit('close')">×</button> | |
| 7 | + </header> | |
| 8 | + | |
| 9 | + <div v-if="loading" class="loading">loading…</div> | |
| 10 | + <div v-else-if="err" class="err">{{ err }}</div> | |
| 11 | + <div v-else class="body"> | |
| 12 | + <section> | |
| 13 | + <h3>Metadata</h3> | |
| 14 | + <div class="meta"> | |
| 15 | + <div><label>status</label><span :class="['badge', unit?.status]">{{ unit?.status || '?' }}</span></div> | |
| 16 | + <div><label>type</label><span>{{ unit?.type || unit?.kind || '?' }}</span></div> | |
| 17 | + <div><label>retries</label><span>{{ unit?.retries ?? 0 }}/{{ unit?.retry_budget ?? '?' }}</span></div> | |
| 18 | + <div><label>contract</label><span class="hash">{{ unit?.contract_hash || '?' }}</span></div> | |
| 19 | + <div><label>depends_on</label><span>{{ (unit?.depends_on || []).join(', ') || '∅' }}</span></div> | |
| 20 | + </div> | |
| 21 | + </section> | |
| 22 | + | |
| 23 | + <section> | |
| 24 | + <h3>Recent runs ({{ runs.length }})</h3> | |
| 25 | + <ul class="runs"> | |
| 26 | + <li v-for="r in runs" :key="r.run_id" :class="['run', r.result?.ok ? 'ok' : 'fail']"> | |
| 27 | + <div class="row"> | |
| 28 | + <span class="rid">{{ r.run_id }}</span> | |
| 29 | + <span class="dot" :class="{ok: r.result?.ok, fail: r.result?.ok === false, crashed: r.result?.crashed }" /> | |
| 30 | + <span class="sum"> | |
| 31 | + {{ r.result ? `${r.result.summary?.passed || 0}/${r.result.summary?.total || 0}` : 'no result' }} | |
| 32 | + </span> | |
| 33 | + </div> | |
| 34 | + <div v-for="(f, i) in (r.result?.failures || [])" :key="i" class="failure"> | |
| 35 | + <code>{{ f.test || f.case_id }}</code>: {{ f.hint || f.stack?.slice(0, 100) || 'no hint' }} | |
| 36 | + </div> | |
| 37 | + </li> | |
| 38 | + <li v-if="!runs.length" class="empty">no runs yet</li> | |
| 39 | + </ul> | |
| 40 | + </section> | |
| 41 | + | |
| 42 | + <section v-if="coderLog"> | |
| 43 | + <h3>coder.log (tail)</h3> | |
| 44 | + <pre class="coder-log">{{ coderLog }}</pre> | |
| 45 | + </section> | |
| 46 | + </div> | |
| 47 | + </aside> | |
| 48 | + </div> | |
| 49 | +</template> | |
| 50 | + | |
| 51 | +<script setup> | |
| 52 | +import { ref, onMounted } from 'vue'; | |
| 53 | +const props = defineProps({ | |
| 54 | + pid: { type: String, required: true }, | |
| 55 | + unitId: { type: String, required: true }, | |
| 56 | +}); | |
| 57 | +defineEmits(['close']); | |
| 58 | + | |
| 59 | +const loading = ref(true); | |
| 60 | +const err = ref(''); | |
| 61 | +const unit = ref(null); | |
| 62 | +const runs = ref([]); | |
| 63 | +const coderLog = ref(''); | |
| 64 | + | |
| 65 | +onMounted(async () => { | |
| 66 | + try { | |
| 67 | + const r = await fetch(`/api/devloop/projects/${props.pid}/units/${props.unitId}`); | |
| 68 | + if (!r.ok) { err.value = `HTTP ${r.status}`; return; } | |
| 69 | + const j = await r.json(); | |
| 70 | + unit.value = j.unit; | |
| 71 | + runs.value = j.runs || []; | |
| 72 | + coderLog.value = j.coder_log_tail || ''; | |
| 73 | + } catch (e) { | |
| 74 | + err.value = e.message; | |
| 75 | + } finally { | |
| 76 | + loading.value = false; | |
| 77 | + } | |
| 78 | +}); | |
| 79 | +</script> | |
| 80 | + | |
| 81 | +<style scoped> | |
| 82 | +.drawer-backdrop { | |
| 83 | + position:fixed; inset:0; background:rgba(0,0,0,.35); z-index:900; | |
| 84 | + display:flex; justify-content:flex-end; | |
| 85 | +} | |
| 86 | +.drawer { | |
| 87 | + background:#fff; width:520px; max-width:95vw; height:100vh; overflow-y:auto; | |
| 88 | + padding:20px 24px; box-shadow:-8px 0 32px rgba(0,0,0,.15); | |
| 89 | +} | |
| 90 | +header { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; } | |
| 91 | +h2 { margin:0; font-family: ui-monospace, monospace; } | |
| 92 | +.x { background:none; border:none; font-size:24px; cursor:pointer; color:#888; } | |
| 93 | +section { margin:18px 0; } | |
| 94 | +h3 { font-size:13px; color:#666; margin:0 0 8px; text-transform:uppercase; } | |
| 95 | +.loading, .err { padding:20px; text-align:center; color:#888; } | |
| 96 | +.err { color:#c00; } | |
| 97 | +.meta { display:grid; grid-template-columns: 110px 1fr; gap:6px 12px; font-size:13px; } | |
| 98 | +.meta label { color:#888; } | |
| 99 | +.meta .hash { font-family: ui-monospace, monospace; font-size:11px; color:#555; } | |
| 100 | +.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; text-transform:uppercase; background:#eee; } | |
| 101 | +.badge.passed { background:#d4edda; color:#155724; } | |
| 102 | +.badge.failed { background:#f8d7da; color:#721c24; } | |
| 103 | +.badge.coding { background:#fff3cd; color:#856404; } | |
| 104 | +.badge.testing { background:#cce5ff; color:#004085; } | |
| 105 | +.badge.pending { background:#eee; color:#666; } | |
| 106 | +.badge.blocked { background:#d6d8db; color:#383d41; } | |
| 107 | +.runs { list-style:none; padding:0; margin:0; } | |
| 108 | +.run { border:1px solid #eee; border-radius:4px; padding:8px 10px; margin-bottom:8px; } | |
| 109 | +.run.ok { background:#fafefa; } | |
| 110 | +.run.fail { background:#fef9f9; } | |
| 111 | +.run .row { display:flex; align-items:center; gap:10px; } | |
| 112 | +.rid { font-family: ui-monospace, monospace; font-size:12px; color:#555; } | |
| 113 | +.dot { width:8px; height:8px; border-radius:50%; background:#ccc; } | |
| 114 | +.dot.ok { background:#0a6; } | |
| 115 | +.dot.fail { background:#c00; } | |
| 116 | +.dot.crashed { background:#822; } | |
| 117 | +.sum { color:#666; font-size:12px; } | |
| 118 | +.failure { margin-top:4px; padding:4px 8px; background:#fff8f8; border-left:2px solid #c00; font-size:11px; color:#444; } | |
| 119 | +.failure code { background:#eee; padding:1px 4px; border-radius:2px; font-size:11px; } | |
| 120 | +.coder-log { background:#0f1115; color:#e6e6e6; padding:10px; border-radius:4px; font-size:11px; max-height:240px; overflow-y:auto; } | |
| 121 | +.empty { color:#888; padding:10px; } | |
| 122 | +</style> | ... | ... |
web/src/components/devloop/UnitsDAG.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="dag-wrap"> | |
| 3 | + <svg v-if="units.length" :viewBox="`0 0 ${W} ${H}`" :width="W" :height="H"> | |
| 4 | + <defs> | |
| 5 | + <marker id="arr" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> | |
| 6 | + <path d="M0,0 L10,5 L0,10 z" fill="#999" /> | |
| 7 | + </marker> | |
| 8 | + </defs> | |
| 9 | + <line | |
| 10 | + v-for="(e, i) in edges" | |
| 11 | + :key="'e'+i" | |
| 12 | + :x1="e.x1" :y1="e.y1" :x2="e.x2" :y2="e.y2" | |
| 13 | + stroke="#999" stroke-width="1.5" marker-end="url(#arr)" | |
| 14 | + /> | |
| 15 | + <g | |
| 16 | + v-for="(n, i) in nodes" | |
| 17 | + :key="'n'+n.id" | |
| 18 | + :transform="`translate(${n.x},${n.y})`" | |
| 19 | + class="node-g" | |
| 20 | + @click="$emit('select', n.id)" | |
| 21 | + > | |
| 22 | + <rect :width="NW" :height="NH" rx="6" :class="['node', n.status]" /> | |
| 23 | + <text :x="NW/2" :y="20" text-anchor="middle" class="id-label">{{ n.id }}</text> | |
| 24 | + <text :x="NW/2" :y="38" text-anchor="middle" class="status-label">{{ n.status }}</text> | |
| 25 | + </g> | |
| 26 | + </svg> | |
| 27 | + <div v-else class="empty">no units yet</div> | |
| 28 | + <p class="hint">click a unit node to inspect recent runs</p> | |
| 29 | + </div> | |
| 30 | +</template> | |
| 31 | + | |
| 32 | +<script setup> | |
| 33 | +import { computed } from 'vue'; | |
| 34 | +const props = defineProps({ units: { type: Array, default: () => [] } }); | |
| 35 | +defineEmits(['select']); | |
| 36 | + | |
| 37 | +const NW = 130, NH = 52, GAP_X = 40, GAP_Y = 80, PAD = 20; | |
| 38 | + | |
| 39 | +const waves = computed(() => { | |
| 40 | + const remaining = new Set(props.units.map(u => u.id)); | |
| 41 | + const deps = new Map(props.units.map(u => [u.id, (u.depends_on || []).slice()])); | |
| 42 | + const out = []; | |
| 43 | + let safety = 100; | |
| 44 | + while (remaining.size > 0 && safety-- > 0) { | |
| 45 | + const wave = []; | |
| 46 | + for (const id of remaining) { | |
| 47 | + if (deps.get(id).every(d => !remaining.has(d))) wave.push(id); | |
| 48 | + } | |
| 49 | + if (!wave.length) break; | |
| 50 | + for (const id of wave) remaining.delete(id); | |
| 51 | + out.push(wave); | |
| 52 | + } | |
| 53 | + return out; | |
| 54 | +}); | |
| 55 | + | |
| 56 | +const nodes = computed(() => { | |
| 57 | + const out = []; | |
| 58 | + waves.value.forEach((wave, wi) => { | |
| 59 | + wave.forEach((id, ni) => { | |
| 60 | + const u = props.units.find(x => x.id === id); | |
| 61 | + out.push({ | |
| 62 | + id, status: u?.status || 'unknown', | |
| 63 | + x: PAD + wi * (NW + GAP_X), | |
| 64 | + y: PAD + ni * (NH + 20), | |
| 65 | + wave: wi, | |
| 66 | + }); | |
| 67 | + }); | |
| 68 | + }); | |
| 69 | + return out; | |
| 70 | +}); | |
| 71 | + | |
| 72 | +const edges = computed(() => { | |
| 73 | + const out = []; | |
| 74 | + for (const u of props.units) { | |
| 75 | + for (const dep of (u.depends_on || [])) { | |
| 76 | + const a = nodes.value.find(n => n.id === dep); | |
| 77 | + const b = nodes.value.find(n => n.id === u.id); | |
| 78 | + if (a && b) out.push({ x1: a.x + NW, y1: a.y + NH/2, x2: b.x, y2: b.y + NH/2 }); | |
| 79 | + } | |
| 80 | + } | |
| 81 | + return out; | |
| 82 | +}); | |
| 83 | + | |
| 84 | +const W = computed(() => Math.max(400, PAD * 2 + waves.value.length * (NW + GAP_X) - GAP_X)); | |
| 85 | +const H = computed(() => { | |
| 86 | + const maxRow = Math.max(...waves.value.map(w => w.length), 1); | |
| 87 | + return PAD * 2 + maxRow * (NH + 20) - 20; | |
| 88 | +}); | |
| 89 | +</script> | |
| 90 | + | |
| 91 | +<style scoped> | |
| 92 | +.dag-wrap svg { display:block; max-width:100%; background:#fafafb; border:1px dashed #eee; border-radius:6px; } | |
| 93 | +.node-g { cursor: pointer; } | |
| 94 | +.node { fill:#fff; stroke:#888; stroke-width:1.5; } | |
| 95 | +.node:hover { stroke:#222; } | |
| 96 | +.node.pending { fill:#f5f5f5; stroke:#888; } | |
| 97 | +.node.coding { fill:#fff3cd; stroke:#856404; } | |
| 98 | +.node.testing { fill:#cce5ff; stroke:#004085; } | |
| 99 | +.node.passed { fill:#d4edda; stroke:#155724; } | |
| 100 | +.node.failed { fill:#f8d7da; stroke:#721c24; } | |
| 101 | +.node.blocked { fill:#d6d8db; stroke:#383d41; } | |
| 102 | +.node.unknown { fill:#fff; stroke:#aaa; } | |
| 103 | +.id-label { font-size:13px; font-weight:600; font-family: ui-monospace, monospace; pointer-events:none; } | |
| 104 | +.status-label { font-size:10px; fill:#666; text-transform:uppercase; pointer-events:none; } | |
| 105 | +.empty { padding:24px; color:#888; text-align:center; } | |
| 106 | +.hint { font-size:11px; color:#aaa; margin-top:6px; } | |
| 107 | +</style> | ... | ... |
web/src/components/devloop/ops/BuglistView.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="bug-view"> | |
| 3 | + <div class="filters"> | |
| 4 | + <label>Status: | |
| 5 | + <select v-model="filterStatus" @change="reload"> | |
| 6 | + <option value="">all</option> | |
| 7 | + <option>open</option><option>fixing</option> | |
| 8 | + <option>fixed</option><option>verified</option> | |
| 9 | + <option>wontfix</option><option>duplicate</option> | |
| 10 | + </select> | |
| 11 | + </label> | |
| 12 | + <span class="total">Total: {{ bugs.length }}</span> | |
| 13 | + <button class="btn" @click="reload">Refresh</button> | |
| 14 | + </div> | |
| 15 | + | |
| 16 | + <table v-if="bugs.length"> | |
| 17 | + <thead> | |
| 18 | + <tr> | |
| 19 | + <th>ID</th> | |
| 20 | + <th>Status</th> | |
| 21 | + <th>Unit</th> | |
| 22 | + <th>Severity</th> | |
| 23 | + <th>Category</th> | |
| 24 | + <th>Title</th> | |
| 25 | + <th>Updated</th> | |
| 26 | + </tr> | |
| 27 | + </thead> | |
| 28 | + <tbody> | |
| 29 | + <tr v-for="b in bugs" :key="b.id" :class="b.status"> | |
| 30 | + <td class="id">{{ b.id }}</td> | |
| 31 | + <td><span :class="['badge', b.status]">{{ b.status }}</span></td> | |
| 32 | + <td>{{ b.unit_id || '-' }}</td> | |
| 33 | + <td>{{ b.severity || '-' }}</td> | |
| 34 | + <td>{{ b.category || 'unknown' }}</td> | |
| 35 | + <td class="title">{{ b.title }}</td> | |
| 36 | + <td class="ts">{{ formatTs(b.last_updated_at || b.discovered_at) }}</td> | |
| 37 | + </tr> | |
| 38 | + </tbody> | |
| 39 | + </table> | |
| 40 | + <p v-else class="empty">no bugs (or none match filter)</p> | |
| 41 | + </div> | |
| 42 | +</template> | |
| 43 | + | |
| 44 | +<script setup> | |
| 45 | +import { ref, onMounted } from 'vue'; | |
| 46 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 47 | + | |
| 48 | +const bugs = ref([]); | |
| 49 | +const filterStatus = ref(''); | |
| 50 | + | |
| 51 | +async function reload() { | |
| 52 | + const qs = filterStatus.value ? `?status=${filterStatus.value}` : ''; | |
| 53 | + const r = await fetch(`/api/devloop/projects/${props.pid}/bugs${qs}`); | |
| 54 | + if (!r.ok) { bugs.value = []; return; } | |
| 55 | + const j = await r.json(); | |
| 56 | + bugs.value = j.bugs || []; | |
| 57 | +} | |
| 58 | + | |
| 59 | +function formatTs(s) { | |
| 60 | + if (!s) return '-'; | |
| 61 | + try { return new Date(s).toLocaleString(); } catch { return s; } | |
| 62 | +} | |
| 63 | + | |
| 64 | +onMounted(reload); | |
| 65 | +</script> | |
| 66 | + | |
| 67 | +<style scoped> | |
| 68 | +.bug-view {} | |
| 69 | +.filters { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; } | |
| 70 | +.filters select { padding:4px 8px; } | |
| 71 | +.filters .total { color:#666; } | |
| 72 | +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; } | |
| 73 | +table { width:100%; border-collapse:collapse; font-size:13px; } | |
| 74 | +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; } | |
| 75 | +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; } | |
| 76 | +tr:hover { background:#fafafa; } | |
| 77 | +.id { font-family: ui-monospace, monospace; color:#555; font-size:11px; } | |
| 78 | +.title { color:#222; } | |
| 79 | +.ts { color:#888; font-size:11px; } | |
| 80 | +.badge { | |
| 81 | + display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; text-transform:uppercase; | |
| 82 | +} | |
| 83 | +.badge.open { background:#fef0c8; color:#8d6708; } | |
| 84 | +.badge.fixing { background:#cce5ff; color:#004085; } | |
| 85 | +.badge.fixed { background:#d4edda; color:#155724; } | |
| 86 | +.badge.verified { background:#a3e0b8; color:#0a4a1d; } | |
| 87 | +.badge.wontfix { background:#ddd; color:#444; } | |
| 88 | +.badge.duplicate { background:#eee; color:#666; } | |
| 89 | +.empty { padding:30px; text-align:center; color:#888; } | |
| 90 | +</style> | ... | ... |
web/src/components/devloop/ops/ChangelogView.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="changelog-view"> | |
| 3 | + <div class="bar"> | |
| 4 | + <span>Total: {{ entries.length }} changes</span> | |
| 5 | + <span class="grow" /> | |
| 6 | + <button class="btn" @click="reload">Refresh</button> | |
| 7 | + </div> | |
| 8 | + | |
| 9 | + <table v-if="entries.length"> | |
| 10 | + <thead> | |
| 11 | + <tr> | |
| 12 | + <th>When</th> | |
| 13 | + <th>Kind</th> | |
| 14 | + <th>Rev</th> | |
| 15 | + <th>By</th> | |
| 16 | + <th>Trigger</th> | |
| 17 | + <th>Summary</th> | |
| 18 | + </tr> | |
| 19 | + </thead> | |
| 20 | + <tbody> | |
| 21 | + <tr v-for="(e, i) in entries" :key="i"> | |
| 22 | + <td class="ts">{{ formatTs(e.ts) }}</td> | |
| 23 | + <td><span :class="['k', e.kind]">{{ e.kind }}</span></td> | |
| 24 | + <td>{{ e.rev }}</td> | |
| 25 | + <td>{{ e.by || '-' }}</td> | |
| 26 | + <td class="trigger">[{{ e.trigger || '-' }}]</td> | |
| 27 | + <td class="summary">{{ e.summary }}</td> | |
| 28 | + </tr> | |
| 29 | + </tbody> | |
| 30 | + </table> | |
| 31 | + <p v-else class="empty">no changelog entries</p> | |
| 32 | + </div> | |
| 33 | +</template> | |
| 34 | + | |
| 35 | +<script setup> | |
| 36 | +import { ref, onMounted } from 'vue'; | |
| 37 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 38 | +const entries = ref([]); | |
| 39 | + | |
| 40 | +async function reload() { | |
| 41 | + const r = await fetch(`/api/devloop/projects/${props.pid}/changelog`); | |
| 42 | + if (!r.ok) return; | |
| 43 | + const j = await r.json(); | |
| 44 | + entries.value = j.entries || []; | |
| 45 | +} | |
| 46 | + | |
| 47 | +function formatTs(s) { | |
| 48 | + if (!s) return '-'; | |
| 49 | + try { return new Date(s).toLocaleString(); } catch { return s; } | |
| 50 | +} | |
| 51 | + | |
| 52 | +onMounted(reload); | |
| 53 | +</script> | |
| 54 | + | |
| 55 | +<style scoped> | |
| 56 | +.changelog-view {} | |
| 57 | +.bar { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; } | |
| 58 | +.bar .grow { flex:1; } | |
| 59 | +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; } | |
| 60 | +table { width:100%; border-collapse:collapse; font-size:13px; } | |
| 61 | +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; } | |
| 62 | +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; } | |
| 63 | +.ts { color:#888; font-size:11px; } | |
| 64 | +.k { | |
| 65 | + display:inline-block; padding:1px 8px; border-radius:10px; | |
| 66 | + font-family: ui-monospace, monospace; font-size:11px; | |
| 67 | +} | |
| 68 | +.k.prd_rev { background:#deecff; color:#1d4d99; } | |
| 69 | +.k.sdd_rev { background:#ffeede; color:#a64a00; } | |
| 70 | +.trigger { color:#888; font-size:11px; } | |
| 71 | +.summary { color:#444; } | |
| 72 | +.empty { padding:30px; text-align:center; color:#888; } | |
| 73 | +</style> | ... | ... |
web/src/components/devloop/ops/LoopLogView.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="loops-view"> | |
| 3 | + <div class="bar"> | |
| 4 | + <span>Total: {{ events.length }} events</span> | |
| 5 | + <span class="grow" /> | |
| 6 | + <label>Filter: | |
| 7 | + <input v-model="filterText" placeholder="kind / summary substring" /> | |
| 8 | + </label> | |
| 9 | + <button class="btn" @click="reload">Refresh</button> | |
| 10 | + </div> | |
| 11 | + | |
| 12 | + <section v-for="(grp, key) in groups" :key="key" class="group"> | |
| 13 | + <h3>{{ key }} <span class="count">({{ grp.length }})</span></h3> | |
| 14 | + <ul> | |
| 15 | + <li v-for="e in grp" :key="e.ts + e.kind"> | |
| 16 | + <span class="ts">{{ formatTs(e.ts) }}</span> | |
| 17 | + <span :class="['kind', e.kind.replace(/\./g, '-')]">{{ e.kind }}</span> | |
| 18 | + <span class="src">[{{ e.source || 'system' }}]</span> | |
| 19 | + <span class="summary">{{ e.summary }}</span> | |
| 20 | + </li> | |
| 21 | + </ul> | |
| 22 | + </section> | |
| 23 | + | |
| 24 | + <p v-if="!events.length" class="empty">no timeline events</p> | |
| 25 | + </div> | |
| 26 | +</template> | |
| 27 | + | |
| 28 | +<script setup> | |
| 29 | +import { ref, computed, onMounted } from 'vue'; | |
| 30 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 31 | +const events = ref([]); | |
| 32 | +const filterText = ref(''); | |
| 33 | + | |
| 34 | +async function reload() { | |
| 35 | + const r = await fetch(`/api/devloop/projects/${props.pid}/timeline?limit=500`); | |
| 36 | + if (!r.ok) return; | |
| 37 | + const j = await r.json(); | |
| 38 | + events.value = j.events || []; | |
| 39 | +} | |
| 40 | + | |
| 41 | +const filtered = computed(() => { | |
| 42 | + const q = filterText.value.toLowerCase().trim(); | |
| 43 | + if (!q) return events.value; | |
| 44 | + return events.value.filter(e => | |
| 45 | + (e.kind || '').toLowerCase().includes(q) || | |
| 46 | + (e.summary || '').toLowerCase().includes(q)); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +const groups = computed(() => { | |
| 50 | + const out = { 'L1 (unit)': [], 'L2 (integration / triage)': [], 'L3 (human / status)': [], 'System / Docs / Git': [] }; | |
| 51 | + for (const e of filtered.value) { | |
| 52 | + const k = e.kind || ''; | |
| 53 | + if (/^unit\.|^L1/.test(k)) out['L1 (unit)'].push(e); | |
| 54 | + else if (/integration|triage|sdd\.|units\.reset|l2_/.test(k)) out['L2 (integration / triage)'].push(e); | |
| 55 | + else if (/^project\.status|^project\.shipped|^project\.abandoned|human_intervention|prd\.updated/.test(k)) out['L3 (human / status)'].push(e); | |
| 56 | + else out['System / Docs / Git'].push(e); | |
| 57 | + } | |
| 58 | + return out; | |
| 59 | +}); | |
| 60 | + | |
| 61 | +function formatTs(ts) { | |
| 62 | + if (!ts) return '-'; | |
| 63 | + try { return new Date(ts).toLocaleTimeString(); } catch { return String(ts); } | |
| 64 | +} | |
| 65 | + | |
| 66 | +onMounted(reload); | |
| 67 | +</script> | |
| 68 | + | |
| 69 | +<style scoped> | |
| 70 | +.loops-view {} | |
| 71 | +.bar { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; } | |
| 72 | +.bar .grow { flex:1; } | |
| 73 | +.bar input { padding:4px 8px; border:1px solid #ccc; border-radius:4px; width:200px; } | |
| 74 | +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; } | |
| 75 | +.group { margin-bottom:18px; } | |
| 76 | +.group h3 { font-size:12px; color:#888; margin:0 0 6px; text-transform:uppercase; letter-spacing:0.5px; } | |
| 77 | +.group h3 .count { color:#bbb; font-weight:400; } | |
| 78 | +ul { list-style:none; padding:0; margin:0; } | |
| 79 | +li { | |
| 80 | + display:flex; gap:8px; padding:5px 8px; font-size:12px; | |
| 81 | + border-bottom:1px solid #f5f5f7; | |
| 82 | +} | |
| 83 | +li:hover { background:#fafafa; } | |
| 84 | +.ts { color:#888; flex:0 0 100px; font-family: ui-monospace, monospace; } | |
| 85 | +.kind { | |
| 86 | + flex:0 0 auto; font-family: ui-monospace, monospace; font-size:11px; | |
| 87 | + padding:1px 6px; border-radius:4px; background:#eef; color:#446; | |
| 88 | +} | |
| 89 | +.kind.unit-state { background:#cce5ff; color:#004085; } | |
| 90 | +.kind.integration-result { background:#ffeede; color:#a64a00; } | |
| 91 | +.kind.triage-result { background:#fde6f5; color:#8b1d72; } | |
| 92 | +.kind.sdd-updated, .kind.prd-updated { background:#ffd; color:#7a6b00; } | |
| 93 | +.kind.project-shipped { background:#d4edda; color:#155724; } | |
| 94 | +.kind.project-status { background:#eee; color:#444; } | |
| 95 | +.src { color:#999; font-size:11px; flex:0 0 auto; } | |
| 96 | +.summary { color:#444; flex:1; overflow:hidden; text-overflow:ellipsis; } | |
| 97 | +.empty { padding:30px; text-align:center; color:#888; } | |
| 98 | +</style> | ... | ... |
web/src/components/devloop/ops/ProgressView.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="progress-view"> | |
| 3 | + <div v-if="!state" class="empty">loading…</div> | |
| 4 | + <div v-else class="cards"> | |
| 5 | + <div class="card big"> | |
| 6 | + <label>Project Status</label> | |
| 7 | + <div class="value"> | |
| 8 | + <span :class="['badge', currentStatus]">{{ currentStatus }}</span> | |
| 9 | + </div> | |
| 10 | + <div class="meta">created {{ formatTs(state.project?.created_at) }}</div> | |
| 11 | + </div> | |
| 12 | + | |
| 13 | + <div class="card"> | |
| 14 | + <label>PRD rev</label> | |
| 15 | + <div class="value mono">{{ state.prd_rev ?? '-' }}</div> | |
| 16 | + </div> | |
| 17 | + | |
| 18 | + <div class="card"> | |
| 19 | + <label>SDD rev</label> | |
| 20 | + <div class="value mono">{{ state.sdd_rev ?? '-' }}</div> | |
| 21 | + </div> | |
| 22 | + | |
| 23 | + <div class="card"> | |
| 24 | + <label>Units progress</label> | |
| 25 | + <div class="value">{{ unitsPassed }}/{{ units.length }} passed</div> | |
| 26 | + <div class="bar"><div :style="{ width: pctPassed + '%' }" class="fill" /></div> | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + <div class="card"> | |
| 30 | + <label>L2 rounds</label> | |
| 31 | + <div class="value">{{ l2Rounds }} / {{ l2Budget }}</div> | |
| 32 | + <div class="bar"><div :style="{ width: pctL2 + '%' }" class="fill l2" /></div> | |
| 33 | + </div> | |
| 34 | + | |
| 35 | + <div class="card"> | |
| 36 | + <label>Bugs (open)</label> | |
| 37 | + <div class="value">{{ bugsOpen }} <small>/ {{ bugsTotal }} total</small></div> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <h3>Per-unit breakdown</h3> | |
| 42 | + <table v-if="units.length"> | |
| 43 | + <thead> | |
| 44 | + <tr><th>Unit</th><th>Status</th><th>Retries</th><th>Contract hash</th></tr> | |
| 45 | + </thead> | |
| 46 | + <tbody> | |
| 47 | + <tr v-for="u in units" :key="u.id"> | |
| 48 | + <td class="mono">{{ u.id }}</td> | |
| 49 | + <td><span :class="['badge', u.status]">{{ u.status }}</span></td> | |
| 50 | + <td>{{ u.retries ?? 0 }} / {{ u.retry_budget ?? '-' }}</td> | |
| 51 | + <td class="mono hash">{{ u.contract_hash || '-' }}</td> | |
| 52 | + </tr> | |
| 53 | + </tbody> | |
| 54 | + </table> | |
| 55 | + </div> | |
| 56 | +</template> | |
| 57 | + | |
| 58 | +<script setup> | |
| 59 | +import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; | |
| 60 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 61 | + | |
| 62 | +const state = ref(null); | |
| 63 | +const bugs = ref([]); | |
| 64 | +let timer = null; | |
| 65 | + | |
| 66 | +async function load() { | |
| 67 | + try { | |
| 68 | + const [sR, bR] = await Promise.all([ | |
| 69 | + fetch(`/api/devloop/projects/${props.pid}/state`), | |
| 70 | + fetch(`/api/devloop/projects/${props.pid}/bugs`), | |
| 71 | + ]); | |
| 72 | + if (sR.ok) state.value = await sR.json(); | |
| 73 | + if (bR.ok) bugs.value = (await bR.json()).bugs || []; | |
| 74 | + } catch { /* ignore */ } | |
| 75 | +} | |
| 76 | + | |
| 77 | +const units = computed(() => state.value?.units || []); | |
| 78 | +const unitsPassed = computed(() => units.value.filter(u => u.status === 'passed').length); | |
| 79 | +const pctPassed = computed(() => units.value.length ? Math.round(unitsPassed.value / units.value.length * 100) : 0); | |
| 80 | +const currentStatus = computed(() => state.value?.status || state.value?.project?.status || 'unknown'); | |
| 81 | +const l2Rounds = computed(() => { | |
| 82 | + // pull from units.json via the existing /state endpoint structure | |
| 83 | + // state.units_rev exists but rounds field needs separate fetch; for now read project.json if surfaced | |
| 84 | + return state.value?.project?.l2_rounds ?? 0; | |
| 85 | +}); | |
| 86 | +const l2Budget = computed(() => 3); | |
| 87 | +const pctL2 = computed(() => Math.min(100, (l2Rounds.value / l2Budget.value) * 100)); | |
| 88 | +const bugsOpen = computed(() => bugs.value.filter(b => b.status === 'open' || b.status === 'fixing').length); | |
| 89 | +const bugsTotal = computed(() => bugs.value.length); | |
| 90 | + | |
| 91 | +function formatTs(s) { if (!s) return '-'; try { return new Date(s).toLocaleString(); } catch { return s; } } | |
| 92 | + | |
| 93 | +onMounted(() => { | |
| 94 | + load(); | |
| 95 | + timer = setInterval(load, 3000); | |
| 96 | +}); | |
| 97 | +onBeforeUnmount(() => { if (timer) clearInterval(timer); }); | |
| 98 | +</script> | |
| 99 | + | |
| 100 | +<style scoped> | |
| 101 | +.progress-view {} | |
| 102 | +.cards { display:grid; grid-template-columns: repeat(3, 1fr); gap:14px; } | |
| 103 | +.card { background:#fafafb; border:1px solid #e7e7eb; border-radius:8px; padding:14px 16px; } | |
| 104 | +.card.big { grid-column: 1 / -1; } | |
| 105 | +.card label { font-size:11px; text-transform:uppercase; color:#888; } | |
| 106 | +.card .value { font-size:22px; font-weight:600; margin-top:4px; } | |
| 107 | +.card .value small { font-size:13px; color:#888; font-weight:400; } | |
| 108 | +.card .meta { font-size:11px; color:#aaa; } | |
| 109 | +.mono { font-family: ui-monospace, monospace; } | |
| 110 | +.bar { width:100%; height:6px; background:#eee; border-radius:3px; margin-top:6px; overflow:hidden; } | |
| 111 | +.fill { height:6px; background:#0a6; border-radius:3px; transition: width .3s; } | |
| 112 | +.fill.l2 { background:#a64a00; } | |
| 113 | +.empty { padding:30px; text-align:center; color:#888; } | |
| 114 | +h3 { font-size:13px; color:#666; margin:24px 0 8px; text-transform:uppercase; } | |
| 115 | +table { width:100%; border-collapse:collapse; font-size:13px; } | |
| 116 | +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; } | |
| 117 | +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; } | |
| 118 | +.hash { font-size:11px; color:#666; } | |
| 119 | +.badge { display:inline-block; padding:2px 10px; border-radius:12px; font-size:11px; text-transform:uppercase; font-weight:600; } | |
| 120 | +.badge.draft { background:#eee; color:#666; } | |
| 121 | +.badge.running { background:#cce5ff; color:#004085; } | |
| 122 | +.badge.shipped { background:#d4edda; color:#155724; } | |
| 123 | +.badge.shipped-final { background:#0a6; color:#fff; } | |
| 124 | +.badge.abandoned { background:#222; color:#fff; } | |
| 125 | +.badge.l2_blocked { background:#fff3cd; color:#856404; } | |
| 126 | +.badge.l2_exhausted { background:#f8d7da; color:#721c24; } | |
| 127 | +.badge.pending { background:#eee; color:#666; } | |
| 128 | +.badge.coding { background:#fff3cd; color:#856404; } | |
| 129 | +.badge.testing { background:#cce5ff; color:#004085; } | |
| 130 | +.badge.passed { background:#d4edda; color:#155724; } | |
| 131 | +.badge.failed { background:#f8d7da; color:#721c24; } | |
| 132 | +.badge.blocked { background:#d6d8db; color:#383d41; } | |
| 133 | +.badge.unknown { background:#fafafa; color:#888; } | |
| 134 | +</style> | ... | ... |
web/src/router/index.js
| ... | ... | @@ -69,6 +69,29 @@ const routes = [ |
| 69 | 69 | name: 'QuotaMonitor', |
| 70 | 70 | component: () => import('@/views/QuotaMonitor.vue'), |
| 71 | 71 | }, |
| 72 | + // P5 · DevLoop Projects 列表 + Dashboard + Ops | |
| 73 | + { | |
| 74 | + path: '/devloop', | |
| 75 | + name: 'DevLoopProjects', | |
| 76 | + component: () => import('@/views/DevLoopProjects.vue'), | |
| 77 | + }, | |
| 78 | + { | |
| 79 | + path: '/devloop/:pid', | |
| 80 | + name: 'DevLoopDashboard', | |
| 81 | + component: () => import('@/views/DevLoopDashboard.vue'), | |
| 82 | + props: true, | |
| 83 | + }, | |
| 84 | + { | |
| 85 | + path: '/devloop/:pid/ops/:tab(bugs|loops|changelog|progress)', | |
| 86 | + name: 'DevLoopOps', | |
| 87 | + component: () => import('@/views/DevLoopOps.vue'), | |
| 88 | + props: true, | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + path: '/devloop/:pid/new-requirement', | |
| 92 | + name: 'DevLoopNewRequirement', | |
| 93 | + component: () => import('@/views/DevLoopNewRequirement.vue'), | |
| 94 | + }, | |
| 72 | 95 | ] |
| 73 | 96 | |
| 74 | 97 | const router = createRouter({ | ... | ... |
web/src/views/DevLoopDashboard.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="devloop-dashboard"> | |
| 3 | + <DashboardHeader | |
| 4 | + :pid="pid" | |
| 5 | + :state="state" | |
| 6 | + @ops="goOps" | |
| 7 | + @new-requirement="goNewRequirement" | |
| 8 | + @signoff="onSignOff" | |
| 9 | + @abandon="onAbandon" | |
| 10 | + /> | |
| 11 | + | |
| 12 | + <div v-if="error" class="error-banner">{{ error }}</div> | |
| 13 | + | |
| 14 | + <div v-if="state" class="grid"> | |
| 15 | + <section class="loop-status"> | |
| 16 | + <LoopStatusBar :state="state" /> | |
| 17 | + </section> | |
| 18 | + | |
| 19 | + <div class="reqs"> | |
| 20 | + <RequirementListSection :pid="pid" /> | |
| 21 | + </div> | |
| 22 | + | |
| 23 | + <section class="dag"> | |
| 24 | + <h2>Units DAG</h2> | |
| 25 | + <UnitsDAG | |
| 26 | + :units="state.units || []" | |
| 27 | + @select="onUnitClick" | |
| 28 | + /> | |
| 29 | + </section> | |
| 30 | + | |
| 31 | + <section class="live"> | |
| 32 | + <h2>Live agent stream</h2> | |
| 33 | + <LiveAgentStream :pid="pid" /> | |
| 34 | + </section> | |
| 35 | + | |
| 36 | + <section class="diff"> | |
| 37 | + <h2>PRD changes</h2> | |
| 38 | + <PrdDiffPanel :pid="pid" :state="state" /> | |
| 39 | + </section> | |
| 40 | + </div> | |
| 41 | + | |
| 42 | + <UnitDrawer | |
| 43 | + v-if="drawerUnit" | |
| 44 | + :pid="pid" | |
| 45 | + :unit-id="drawerUnit" | |
| 46 | + @close="drawerUnit = null" | |
| 47 | + /> | |
| 48 | + | |
| 49 | + <FeedbackModal | |
| 50 | + v-if="showFeedback" | |
| 51 | + :pid="pid" | |
| 52 | + @close="showFeedback = false" | |
| 53 | + @submitted="onFeedbackSubmitted" | |
| 54 | + /> | |
| 55 | + </div> | |
| 56 | +</template> | |
| 57 | + | |
| 58 | +<script setup> | |
| 59 | +import { ref, onMounted, onBeforeUnmount } from 'vue'; | |
| 60 | +import { useRouter } from 'vue-router'; | |
| 61 | +import DashboardHeader from '@/components/devloop/DashboardHeader.vue'; | |
| 62 | +import LoopStatusBar from '@/components/devloop/LoopStatusBar.vue'; | |
| 63 | +import UnitsDAG from '@/components/devloop/UnitsDAG.vue'; | |
| 64 | +import LiveAgentStream from '@/components/devloop/LiveAgentStream.vue'; | |
| 65 | +import PrdDiffPanel from '@/components/devloop/PrdDiffPanel.vue'; | |
| 66 | +import FeedbackModal from '@/components/devloop/FeedbackModal.vue'; | |
| 67 | +import UnitDrawer from '@/components/devloop/UnitDrawer.vue'; | |
| 68 | +import RequirementListSection from '@/components/devloop/RequirementListSection.vue'; | |
| 69 | + | |
| 70 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 71 | +const router = useRouter(); | |
| 72 | + | |
| 73 | +const state = ref(null); | |
| 74 | +const error = ref(''); | |
| 75 | +const showFeedback = ref(false); | |
| 76 | +const drawerUnit = ref(null); | |
| 77 | +let pollTimer = null; | |
| 78 | + | |
| 79 | +async function fetchState() { | |
| 80 | + try { | |
| 81 | + const r = await fetch(`/api/devloop/projects/${props.pid}/state`); | |
| 82 | + if (!r.ok) { | |
| 83 | + error.value = r.status === 404 ? `项目不存在: ${props.pid}` : `读 state 失败: ${r.status}`; | |
| 84 | + state.value = null; | |
| 85 | + return; | |
| 86 | + } | |
| 87 | + error.value = ''; | |
| 88 | + state.value = await r.json(); | |
| 89 | + } catch (e) { | |
| 90 | + error.value = e.message; | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +function goOps(tab = 'bugs') { | |
| 95 | + router.push(`/devloop/${props.pid}/ops/${tab}`); | |
| 96 | +} | |
| 97 | + | |
| 98 | +function openFeedback() { showFeedback.value = true; } | |
| 99 | +function goNewRequirement() { router.push(`/devloop/${props.pid}/new-requirement`); } | |
| 100 | + | |
| 101 | +function onUnitClick(unitId) { drawerUnit.value = unitId; } | |
| 102 | + | |
| 103 | +async function onSignOff() { | |
| 104 | + if (!confirm('确认 Sign Off? 此后将无法再提交反馈。')) return; | |
| 105 | + const r = await fetch(`/api/devloop/projects/${props.pid}/sign-off`, { | |
| 106 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 107 | + body: JSON.stringify({ by: 'human' }), | |
| 108 | + }); | |
| 109 | + const j = await r.json(); | |
| 110 | + if (!r.ok) alert('Sign off failed: ' + (j.error || r.status)); | |
| 111 | + await fetchState(); | |
| 112 | +} | |
| 113 | + | |
| 114 | +async function onAbandon() { | |
| 115 | + const reason = prompt('Abandon 原因 (可空):', 'no longer needed'); | |
| 116 | + if (reason === null) return; | |
| 117 | + const r = await fetch(`/api/devloop/projects/${props.pid}/abandon`, { | |
| 118 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 119 | + body: JSON.stringify({ by: 'human', reason }), | |
| 120 | + }); | |
| 121 | + const j = await r.json(); | |
| 122 | + if (!r.ok) alert('Abandon failed: ' + (j.error || r.status)); | |
| 123 | + await fetchState(); | |
| 124 | +} | |
| 125 | + | |
| 126 | +function onFeedbackSubmitted() { showFeedback.value = false; fetchState(); } | |
| 127 | + | |
| 128 | +onMounted(async () => { | |
| 129 | + await fetchState(); | |
| 130 | + pollTimer = setInterval(fetchState, 2500); | |
| 131 | +}); | |
| 132 | + | |
| 133 | +onBeforeUnmount(() => { | |
| 134 | + if (pollTimer) clearInterval(pollTimer); | |
| 135 | +}); | |
| 136 | +</script> | |
| 137 | + | |
| 138 | +<style scoped> | |
| 139 | +.devloop-dashboard { max-width: 1280px; margin: 0 auto; padding: 16px 24px; } | |
| 140 | +.error-banner { background:#fee; border:1px solid #fcc; color:#c00; padding:8px 12px; border-radius:6px; margin:8px 0; } | |
| 141 | +.grid { display:grid; grid-template-columns: 1fr 1fr; gap:16px; margin-top:16px; } | |
| 142 | +.grid section { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:16px; min-height: 200px; } | |
| 143 | +.grid section.live, .grid section.diff { min-height: 280px; } | |
| 144 | +.grid h2 { font-size:14px; font-weight:600; margin:0 0 8px 0; color:#555; } | |
| 145 | +.loop-status, .dag, .reqs { grid-column: 1 / -1; } | |
| 146 | +@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } } | |
| 147 | +</style> | ... | ... |
web/src/views/DevLoopNewRequirement.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="new-req-page"> | |
| 3 | + <header class="hdr"> | |
| 4 | + <router-link :to="`/devloop/${pid}`" class="back">← Dashboard</router-link> | |
| 5 | + <h1>新增需求 · <code>{{ pid }}</code></h1> | |
| 6 | + </header> | |
| 7 | + | |
| 8 | + <div class="layout"> | |
| 9 | + <!-- LEFT: chat --> | |
| 10 | + <section class="chat-col"> | |
| 11 | + <div class="chat-hdr"> | |
| 12 | + <h2>对话草稿</h2> | |
| 13 | + <span class="hint-mini">写多条消息、上传文档、扫描代码 → 一次性提交给 PM Agent</span> | |
| 14 | + </div> | |
| 15 | + <div class="chat-log" ref="chatLogEl"> | |
| 16 | + <div v-if="messages.length === 0" class="empty-mini"> | |
| 17 | + 写下第一句需求描述,或左下角添加附件 / 代码目录后再继续。 | |
| 18 | + </div> | |
| 19 | + <div v-for="(m, i) in messages" :key="i" :class="['bubble', m.role]"> | |
| 20 | + <div class="bubble-meta">{{ m.role === 'user' ? '你' : 'Assistant' }}<span v-if="m.llm_stub" class="stub-tag">LLM 占位</span></div> | |
| 21 | + <div class="bubble-body">{{ m.content }}</div> | |
| 22 | + </div> | |
| 23 | + <div v-if="sending" class="bubble assistant pending">Assistant 正在思考…</div> | |
| 24 | + </div> | |
| 25 | + <div class="chat-input"> | |
| 26 | + <textarea v-model="draft" rows="3" placeholder="写一段需求 / 追加细节… Ctrl+Enter 发送" | |
| 27 | + @keydown.ctrl.enter="sendChat" @keydown.meta.enter="sendChat" /> | |
| 28 | + <div class="chat-actions"> | |
| 29 | + <button class="ghost" @click="sendChat" :disabled="!draft.trim() || sending">发送一句 (Ctrl+Enter)</button> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | + </section> | |
| 33 | + | |
| 34 | + <!-- RIGHT: attachments + scan + submit --> | |
| 35 | + <aside class="side-col"> | |
| 36 | + <div class="card"> | |
| 37 | + <h3>附件</h3> | |
| 38 | + <div class="upload-row"> | |
| 39 | + <input ref="fileInputEl" type="file" multiple accept=".md,.txt,.html,.htm" @change="onFilesPicked" /> | |
| 40 | + <button class="ghost" @click="showScanBrowser = true">扫描代码目录…</button> | |
| 41 | + </div> | |
| 42 | + <div v-if="attachments.length === 0" class="empty-mini">尚未添加任何附件 / 扫描结果。</div> | |
| 43 | + <ul v-else class="att-list"> | |
| 44 | + <li v-for="(a, i) in attachments" :key="i"> | |
| 45 | + <span class="att-icon">{{ a.kind === 'scan' ? '📂' : '📄' }}</span> | |
| 46 | + <span class="att-name" :title="a.name">{{ a.name }}</span> | |
| 47 | + <span class="att-size">{{ a.text.length }} 字符</span> | |
| 48 | + <button class="x-btn" @click="attachments.splice(i, 1)">×</button> | |
| 49 | + </li> | |
| 50 | + </ul> | |
| 51 | + </div> | |
| 52 | + | |
| 53 | + <div class="card submit-card"> | |
| 54 | + <h3>预览 + 提交</h3> | |
| 55 | + <div class="preview-stat"> | |
| 56 | + 消息 {{ userMsgCount }} 条 · 附件 {{ attachments.length }} 项 · 总长 {{ totalChars }} 字符 | |
| 57 | + </div> | |
| 58 | + <button class="primary big" :disabled="!canSubmit || submitting" @click="submitFinal"> | |
| 59 | + {{ submitting ? '提交中…' : '提交需求 → PM Agent' }} | |
| 60 | + </button> | |
| 61 | + <div v-if="submitError" class="err">{{ submitError }}</div> | |
| 62 | + <div v-if="submitOk" class="ok">{{ submitOk }}</div> | |
| 63 | + <details class="preview-details"> | |
| 64 | + <summary>查看将发送的 feedback 文本</summary> | |
| 65 | + <pre class="preview-text">{{ assembledText }}</pre> | |
| 66 | + </details> | |
| 67 | + </div> | |
| 68 | + </aside> | |
| 69 | + </div> | |
| 70 | + | |
| 71 | + <!-- 扫描目录浏览器(复用 fs/browse) --> | |
| 72 | + <div v-if="showScanBrowser" class="modal-mask" @click.self="showScanBrowser = false"> | |
| 73 | + <div class="modal browser"> | |
| 74 | + <h2>选择要扫描的目录</h2> | |
| 75 | + <p class="hint">递归读取该目录下的 <code>.md</code> / <code>.txt</code> / <code>.html</code> 文件,拼成一段文本作为附件。</p> | |
| 76 | + <div class="bcrumb"> | |
| 77 | + <button class="ghost" :disabled="!browser.parent" @click="browseTo(browser.parent)">↑ 上级</button> | |
| 78 | + <button class="ghost" @click="browseTo(browser.home)">🏠 主目录</button> | |
| 79 | + <input class="path-input" v-model="browser.pathInput" | |
| 80 | + @keydown.enter="browseTo(browser.pathInput)" | |
| 81 | + placeholder="输入绝对路径回车跳转" /> | |
| 82 | + </div> | |
| 83 | + <div class="cur-path">当前: <code>{{ browser.path || '—' }}</code></div> | |
| 84 | + <div v-if="browser.error" class="err">{{ browser.error }}</div> | |
| 85 | + <div class="entries"> | |
| 86 | + <div v-if="browser.loading" class="empty-mini">加载中…</div> | |
| 87 | + <div v-else-if="!browser.entries.length" class="empty-mini">(此目录下没有可见的子目录)</div> | |
| 88 | + <div v-else | |
| 89 | + v-for="e in browser.entries" | |
| 90 | + :key="e.abs_path" | |
| 91 | + class="entry" | |
| 92 | + @dblclick="browseTo(e.abs_path)"> | |
| 93 | + <span class="ico">📁</span> | |
| 94 | + <span>{{ e.name }}</span> | |
| 95 | + </div> | |
| 96 | + </div> | |
| 97 | + <div class="modal-actions"> | |
| 98 | + <button @click="showScanBrowser = false" :disabled="scanning">取消</button> | |
| 99 | + <button class="primary" :disabled="!browser.path || scanning" @click="doScan"> | |
| 100 | + {{ scanning ? '扫描中…' : '扫描当前目录' }} | |
| 101 | + </button> | |
| 102 | + </div> | |
| 103 | + </div> | |
| 104 | + </div> | |
| 105 | + </div> | |
| 106 | +</template> | |
| 107 | + | |
| 108 | +<script setup> | |
| 109 | +import { ref, computed, nextTick, onMounted } from 'vue'; | |
| 110 | +import { useRoute, useRouter } from 'vue-router'; | |
| 111 | + | |
| 112 | +const route = useRoute(); | |
| 113 | +const router = useRouter(); | |
| 114 | +const pid = route.params.pid; | |
| 115 | + | |
| 116 | +const messages = ref([]); // [{role, content, llm_stub?}] | |
| 117 | +const draft = ref(''); | |
| 118 | +const sending = ref(false); | |
| 119 | +const chatLogEl = ref(null); | |
| 120 | + | |
| 121 | +const fileInputEl = ref(null); | |
| 122 | +const attachments = ref([]); // [{kind:'file'|'scan', name, text}] | |
| 123 | + | |
| 124 | +const showScanBrowser = ref(false); | |
| 125 | +const scanning = ref(false); | |
| 126 | +const browser = ref({ path:'', parent:null, home:'', entries:[], pathInput:'', loading:false, error:'' }); | |
| 127 | + | |
| 128 | +const submitting = ref(false); | |
| 129 | +const submitError = ref(''); | |
| 130 | +const submitOk = ref(''); | |
| 131 | + | |
| 132 | +const userMsgCount = computed(() => messages.value.filter(m => m.role === 'user').length); | |
| 133 | +const totalChars = computed(() => | |
| 134 | + messages.value.reduce((s, m) => s + (m.content?.length || 0), 0) | |
| 135 | + + attachments.value.reduce((s, a) => s + (a.text?.length || 0), 0) | |
| 136 | +); | |
| 137 | +const assembledText = computed(() => { | |
| 138 | + const userTurns = messages.value.filter(m => m.role === 'user').map((m, i) => `[消息 ${i+1}] ${m.content}`).join('\n\n'); | |
| 139 | + const att = attachments.value.length | |
| 140 | + ? '\n\n--- 附件内容 ---\n\n' + attachments.value.map(a => `### ${a.name}\n\n${a.text}`).join('\n\n---\n\n') | |
| 141 | + : ''; | |
| 142 | + return userTurns + att; | |
| 143 | +}); | |
| 144 | +const canSubmit = computed(() => userMsgCount.value > 0 || attachments.value.length > 0); | |
| 145 | + | |
| 146 | +async function sendChat() { | |
| 147 | + if (!draft.value.trim() || sending.value) return; | |
| 148 | + const text = draft.value.trim(); | |
| 149 | + draft.value = ''; | |
| 150 | + messages.value.push({ role: 'user', content: text }); | |
| 151 | + await nextTick(); scrollChatToBottom(); | |
| 152 | + sending.value = true; | |
| 153 | + try { | |
| 154 | + const r = await fetch(`/api/devloop/projects/${pid}/draft-chat`, { | |
| 155 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 156 | + body: JSON.stringify({ messages: messages.value.map(m => ({ role: m.role, content: m.content })) }), | |
| 157 | + }); | |
| 158 | + const j = await r.json(); | |
| 159 | + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); | |
| 160 | + messages.value.push({ role: 'assistant', content: j.content || '', llm_stub: !!j.llm_stub }); | |
| 161 | + } catch (e) { | |
| 162 | + messages.value.push({ role: 'assistant', content: `(后端错误: ${e.message})`, llm_stub: true }); | |
| 163 | + } finally { | |
| 164 | + sending.value = false; | |
| 165 | + await nextTick(); scrollChatToBottom(); | |
| 166 | + } | |
| 167 | +} | |
| 168 | + | |
| 169 | +function scrollChatToBottom() { | |
| 170 | + if (chatLogEl.value) chatLogEl.value.scrollTop = chatLogEl.value.scrollHeight; | |
| 171 | +} | |
| 172 | + | |
| 173 | +async function onFilesPicked(ev) { | |
| 174 | + const files = Array.from(ev.target.files || []); | |
| 175 | + if (!files.length) return; | |
| 176 | + const form = new FormData(); | |
| 177 | + for (const f of files) form.append('files', f); | |
| 178 | + try { | |
| 179 | + const r = await fetch(`/api/devloop/projects/${pid}/intake/files`, { method: 'POST', body: form }); | |
| 180 | + const j = await r.json(); | |
| 181 | + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); | |
| 182 | + for (const f of (j.files || [])) { | |
| 183 | + if (f.skipped) { | |
| 184 | + alert(`跳过 ${f.name}: ${f.reason}`); | |
| 185 | + continue; | |
| 186 | + } | |
| 187 | + attachments.value.push({ kind: 'file', name: f.name, text: f.text || '' }); | |
| 188 | + } | |
| 189 | + } catch (e) { | |
| 190 | + alert('上传失败: ' + e.message); | |
| 191 | + } finally { | |
| 192 | + if (fileInputEl.value) fileInputEl.value.value = ''; | |
| 193 | + } | |
| 194 | +} | |
| 195 | + | |
| 196 | +async function browseTo(p) { | |
| 197 | + browser.value.loading = true; browser.value.error = ''; | |
| 198 | + try { | |
| 199 | + const url = p ? `/api/devloop/fs/browse?path=${encodeURIComponent(p)}` : '/api/devloop/fs/browse'; | |
| 200 | + const r = await fetch(url); | |
| 201 | + const j = await r.json(); | |
| 202 | + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); | |
| 203 | + browser.value.path = j.path; | |
| 204 | + browser.value.parent = j.parent; | |
| 205 | + browser.value.home = j.home; | |
| 206 | + browser.value.entries = j.entries || []; | |
| 207 | + browser.value.pathInput = j.path; | |
| 208 | + } catch (e) { | |
| 209 | + browser.value.error = e.message; | |
| 210 | + } finally { | |
| 211 | + browser.value.loading = false; | |
| 212 | + } | |
| 213 | +} | |
| 214 | + | |
| 215 | +async function doScan() { | |
| 216 | + if (!browser.value.path) return; | |
| 217 | + scanning.value = true; | |
| 218 | + try { | |
| 219 | + const r = await fetch(`/api/devloop/projects/${pid}/intake/scan-dir`, { | |
| 220 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 221 | + body: JSON.stringify({ path: browser.value.path, max_files: 30, max_bytes: 500000 }), | |
| 222 | + }); | |
| 223 | + const j = await r.json(); | |
| 224 | + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); | |
| 225 | + if (!j.files.length) { | |
| 226 | + alert('该目录下未找到 md/txt/html 文件。'); | |
| 227 | + return; | |
| 228 | + } | |
| 229 | + const text = j.files.map(f => `## ${f.rel}\n\n${f.text}`).join('\n\n---\n\n'); | |
| 230 | + const name = `${j.files.length} 个文件@${shortPath(browser.value.path)}` | |
| 231 | + + (j.truncated ? ' (已截断)' : ''); | |
| 232 | + attachments.value.push({ kind: 'scan', name, text }); | |
| 233 | + showScanBrowser.value = false; | |
| 234 | + } catch (e) { | |
| 235 | + alert('扫描失败: ' + e.message); | |
| 236 | + } finally { | |
| 237 | + scanning.value = false; | |
| 238 | + } | |
| 239 | +} | |
| 240 | + | |
| 241 | +function shortPath(p) { | |
| 242 | + if (!p) return ''; | |
| 243 | + if (browser.value.home && p.startsWith(browser.value.home)) return '~' + p.slice(browser.value.home.length); | |
| 244 | + return p.length > 36 ? '…' + p.slice(-33) : p; | |
| 245 | +} | |
| 246 | + | |
| 247 | +async function submitFinal() { | |
| 248 | + if (!canSubmit.value || submitting.value) return; | |
| 249 | + submitting.value = true; submitError.value = ''; submitOk.value = ''; | |
| 250 | + try { | |
| 251 | + const r = await fetch(`/api/devloop/projects/${pid}/feedback`, { | |
| 252 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 253 | + body: JSON.stringify({ text: assembledText.value, by: 'human' }), | |
| 254 | + }); | |
| 255 | + const j = await r.json(); | |
| 256 | + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); | |
| 257 | + submitOk.value = `已提交 → PRD bumped to rev ${j.prd_rev_after} · status=${j.status}`; | |
| 258 | + setTimeout(() => router.push(`/devloop/${pid}`), 1200); | |
| 259 | + } catch (e) { | |
| 260 | + submitError.value = e.message; | |
| 261 | + } finally { | |
| 262 | + submitting.value = false; | |
| 263 | + } | |
| 264 | +} | |
| 265 | + | |
| 266 | +onMounted(() => { /* nothing pre-loaded */ }); | |
| 267 | +</script> | |
| 268 | + | |
| 269 | +<style scoped> | |
| 270 | +.new-req-page { max-width: 1280px; margin: 0 auto; padding: 16px 24px; } | |
| 271 | +.hdr { display:flex; gap:14px; align-items:center; padding-bottom:8px; border-bottom:1px solid #eee; margin-bottom:16px; } | |
| 272 | +.back { color:#888; text-decoration:none; font-size:13px; } | |
| 273 | +.back:hover { color:#222; } | |
| 274 | +h1 { font-size:18px; margin:0; } | |
| 275 | +h1 code { font-family: ui-monospace, monospace; font-size:14px; background:#f4f4f6; padding:2px 8px; border-radius:4px; } | |
| 276 | + | |
| 277 | +.layout { display:grid; grid-template-columns: 1fr 380px; gap:16px; } | |
| 278 | +@media (max-width: 1000px) { .layout { grid-template-columns: 1fr; } } | |
| 279 | + | |
| 280 | +.chat-col, .side-col { display:flex; flex-direction:column; gap:12px; } | |
| 281 | +.chat-col { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:14px; min-height: 500px; } | |
| 282 | +.chat-hdr { display:flex; justify-content:space-between; align-items:baseline; } | |
| 283 | +.chat-hdr h2 { font-size:14px; font-weight:600; margin:0; color:#555; } | |
| 284 | +.hint-mini { font-size:11px; color:#888; } | |
| 285 | +.chat-log { flex:1; overflow-y:auto; max-height: 420px; padding:8px; background:#fafafa; border-radius:6px; min-height:200px; } | |
| 286 | +.empty-mini { color:#888; font-size:13px; text-align:center; padding:24px; } | |
| 287 | + | |
| 288 | +.bubble { margin-bottom:10px; padding:10px 12px; border-radius:8px; max-width:85%; word-break:break-word; white-space:pre-wrap; font-size:13px; line-height:1.5; } | |
| 289 | +.bubble.user { background:#dff; margin-left:auto; } | |
| 290 | +.bubble.assistant { background:#fff; border:1px solid #eee; } | |
| 291 | +.bubble.pending { color:#888; font-style:italic; } | |
| 292 | +.bubble-meta { font-size:10px; color:#888; margin-bottom:4px; font-weight:600; } | |
| 293 | +.stub-tag { background:#fa3; color:#fff; font-size:10px; padding:1px 6px; border-radius:6px; margin-left:6px; font-weight:400; } | |
| 294 | + | |
| 295 | +.chat-input { display:flex; flex-direction:column; gap:8px; } | |
| 296 | +.chat-input textarea { width:100%; padding:8px; border:1px solid #ccc; border-radius:5px; font-family:inherit; font-size:13px; box-sizing:border-box; resize:vertical; } | |
| 297 | +.chat-actions { display:flex; justify-content:flex-end; } | |
| 298 | + | |
| 299 | +.card { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:14px; } | |
| 300 | +.card h3 { font-size:13px; font-weight:600; margin:0 0 10px 0; color:#555; } | |
| 301 | +.upload-row { display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-bottom:10px; } | |
| 302 | +.upload-row input[type=file] { font-size:12px; } | |
| 303 | +.att-list { list-style:none; padding:0; margin:0; } | |
| 304 | +.att-list li { display:flex; align-items:center; gap:8px; padding:6px 0; border-bottom:1px solid #f3f3f3; font-size:12px; } | |
| 305 | +.att-icon { font-size:14px; } | |
| 306 | +.att-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } | |
| 307 | +.att-size { color:#888; font-size:11px; } | |
| 308 | +.x-btn { background:none; border:none; color:#c66; cursor:pointer; font-size:16px; padding:0 4px; } | |
| 309 | +.x-btn:hover { color:#c00; } | |
| 310 | + | |
| 311 | +.submit-card .preview-stat { font-size:12px; color:#666; margin-bottom:10px; } | |
| 312 | +button.primary { background:#0a6; color:#fff; border:1px solid #0a6; border-radius:5px; padding:6px 14px; cursor:pointer; font-size:13px; } | |
| 313 | +button.primary:hover:not(:disabled) { background:#085; } | |
| 314 | +button.primary:disabled { background:#bbb; border-color:#bbb; cursor:not-allowed; } | |
| 315 | +button.primary.big { width:100%; padding:10px; font-size:14px; font-weight:600; } | |
| 316 | +button.ghost { background:#fff; border:1px solid #ccc; padding:5px 12px; border-radius:5px; cursor:pointer; font-size:12px; color:#444; } | |
| 317 | +button.ghost:hover:not(:disabled) { background:#f4f4f6; } | |
| 318 | +button.ghost:disabled { opacity:0.5; cursor:not-allowed; } | |
| 319 | +.err { color:#c00; font-size:12px; margin-top:8px; padding:6px; background:#fee; border-radius:4px; } | |
| 320 | +.ok { color:#070; font-size:12px; margin-top:8px; padding:6px; background:#dfd; border-radius:4px; } | |
| 321 | +.preview-details { margin-top:10px; font-size:12px; } | |
| 322 | +.preview-details summary { cursor:pointer; color:#0a6; } | |
| 323 | +.preview-text { background:#fafafa; padding:10px; border-radius:5px; font-size:11px; line-height:1.4; max-height:200px; overflow-y:auto; white-space:pre-wrap; word-break:break-word; } | |
| 324 | + | |
| 325 | +/* Browser modal */ | |
| 326 | +.modal-mask { position:fixed; inset:0; background:rgba(0,0,0,0.4); display:flex; align-items:center; justify-content:center; z-index:1000; } | |
| 327 | +.modal { background:#fff; border-radius:8px; padding:20px 24px; min-width:480px; max-width:680px; box-shadow:0 8px 32px rgba(0,0,0,0.25); } | |
| 328 | +.modal.browser { min-width:560px; } | |
| 329 | +.modal h2 { font-size:16px; margin:0 0 8px 0; } | |
| 330 | +.modal .hint { color:#666; font-size:12px; margin:0 0 12px 0; } | |
| 331 | +.modal code { background:#f4f4f6; padding:1px 5px; border-radius:3px; font-size:11px; font-family: ui-monospace, monospace; } | |
| 332 | +.bcrumb { display:flex; gap:6px; align-items:center; margin-bottom:8px; } | |
| 333 | +.bcrumb .path-input { flex:1; padding:5px 10px; border:1px solid #ccc; border-radius:4px; font-size:12px; font-family: ui-monospace, monospace; } | |
| 334 | +.cur-path { font-size:12px; color:#666; margin:6px 0; } | |
| 335 | +.entries { border:1px solid #eee; border-radius:6px; padding:6px 0; max-height:280px; min-height:160px; overflow-y:auto; background:#fafafa; } | |
| 336 | +.entry { display:flex; gap:10px; padding:6px 14px; cursor:pointer; } | |
| 337 | +.entry:hover { background:#eef; } | |
| 338 | +.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:14px; } | |
| 339 | +.modal-actions button { padding:6px 16px; border:1px solid #ccc; background:#fff; border-radius:5px; cursor:pointer; font-size:13px; } | |
| 340 | +.modal-actions button:hover:not(:disabled) { background:#f4f4f6; } | |
| 341 | +.modal-actions button:disabled { opacity:0.5; cursor:not-allowed; } | |
| 342 | +</style> | ... | ... |
web/src/views/DevLoopOps.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="devloop-ops"> | |
| 3 | + <header class="hdr"> | |
| 4 | + <router-link :to="`/devloop/${pid}`" class="back">← Dashboard</router-link> | |
| 5 | + <h1>Ops · {{ pid }}</h1> | |
| 6 | + </header> | |
| 7 | + | |
| 8 | + <nav class="tabs"> | |
| 9 | + <router-link | |
| 10 | + v-for="t in tabs" | |
| 11 | + :key="t.id" | |
| 12 | + :to="`/devloop/${pid}/ops/${t.id}`" | |
| 13 | + :class="['tab', { active: t.id === tab }]" | |
| 14 | + >{{ t.label }}</router-link> | |
| 15 | + </nav> | |
| 16 | + | |
| 17 | + <component :is="activeView" :pid="pid" /> | |
| 18 | + </div> | |
| 19 | +</template> | |
| 20 | + | |
| 21 | +<script setup> | |
| 22 | +import { computed } from 'vue'; | |
| 23 | +import BuglistView from '@/components/devloop/ops/BuglistView.vue'; | |
| 24 | +import LoopLogView from '@/components/devloop/ops/LoopLogView.vue'; | |
| 25 | +import ChangelogView from '@/components/devloop/ops/ChangelogView.vue'; | |
| 26 | +import ProgressView from '@/components/devloop/ops/ProgressView.vue'; | |
| 27 | + | |
| 28 | +const props = defineProps({ | |
| 29 | + pid: { type: String, required: true }, | |
| 30 | + tab: { type: String, required: true }, | |
| 31 | +}); | |
| 32 | + | |
| 33 | +const tabs = [ | |
| 34 | + { id: 'bugs', label: 'Bugs' }, | |
| 35 | + { id: 'loops', label: 'Loops (Timeline)' }, | |
| 36 | + { id: 'changelog', label: 'Changelog' }, | |
| 37 | + { id: 'progress', label: 'Progress' }, | |
| 38 | +]; | |
| 39 | + | |
| 40 | +const viewMap = { | |
| 41 | + bugs: BuglistView, | |
| 42 | + loops: LoopLogView, | |
| 43 | + changelog: ChangelogView, | |
| 44 | + progress: ProgressView, | |
| 45 | +}; | |
| 46 | +const activeView = computed(() => viewMap[props.tab] || BuglistView); | |
| 47 | +</script> | |
| 48 | + | |
| 49 | +<style scoped> | |
| 50 | +.devloop-ops { max-width: 1280px; margin: 0 auto; padding: 16px 24px; } | |
| 51 | +.hdr { display:flex; align-items:center; gap:14px; padding-bottom:8px; border-bottom:1px solid #eee; } | |
| 52 | +.back { color:#888; text-decoration:none; font-size:13px; } | |
| 53 | +.back:hover { color:#222; } | |
| 54 | +h1 { font-size:18px; margin:0; font-family: ui-monospace, monospace; } | |
| 55 | +.tabs { display:flex; gap:0; margin:16px 0; border-bottom:1px solid #e3e3e8; } | |
| 56 | +.tab { | |
| 57 | + padding:8px 16px; text-decoration:none; color:#666; border-bottom:2px solid transparent; | |
| 58 | + font-size:13px; font-weight:500; | |
| 59 | +} | |
| 60 | +.tab:hover { color:#222; } | |
| 61 | +.tab.active { color:#0a6; border-bottom-color:#0a6; } | |
| 62 | +</style> | ... | ... |
web/src/views/DevLoopProjects.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="devloop-projects"> | |
| 3 | + <header class="hdr"> | |
| 4 | + <h1>DevLoop · 项目</h1> | |
| 5 | + <div class="hdr-actions"> | |
| 6 | + <label class="archived-toggle"> | |
| 7 | + <input type="checkbox" v-model="includeArchived" @change="fetchProjects" /> | |
| 8 | + 显示已归档 | |
| 9 | + </label> | |
| 10 | + <button class="primary" @click="openCreate">+ 新建项目</button> | |
| 11 | + </div> | |
| 12 | + </header> | |
| 13 | + | |
| 14 | + <div v-if="error" class="error-banner">{{ error }}</div> | |
| 15 | + | |
| 16 | + <div v-if="loading && !projects.length" class="empty">加载中…</div> | |
| 17 | + <div v-else-if="!projects.length" class="empty"> | |
| 18 | + 暂无项目。点击右上角 <b>新建项目</b> 开始一个 DevLoop 流程。 | |
| 19 | + </div> | |
| 20 | + | |
| 21 | + <table v-else class="proj-table"> | |
| 22 | + <thead> | |
| 23 | + <tr> | |
| 24 | + <th>名称</th> | |
| 25 | + <th>状态</th> | |
| 26 | + <th>Mode</th> | |
| 27 | + <th>代码目录</th> | |
| 28 | + <th>Company</th> | |
| 29 | + <th>创建时间</th> | |
| 30 | + <th>PID</th> | |
| 31 | + <th class="actions-col">操作</th> | |
| 32 | + </tr> | |
| 33 | + </thead> | |
| 34 | + <tbody> | |
| 35 | + <tr v-for="p in projects" :key="p.pid" :class="{ archived: p.archived }"> | |
| 36 | + <td class="name-cell"> | |
| 37 | + <router-link :to="`/devloop/${p.pid}`" class="name-link"> | |
| 38 | + {{ p.name || p.pid }} | |
| 39 | + </router-link> | |
| 40 | + <div v-if="p.idea_summary && p.idea_summary !== p.name" class="idea">{{ p.idea_summary }}</div> | |
| 41 | + </td> | |
| 42 | + <td><span :class="['status-pill', `s-${p.status}`]">{{ p.status }}</span></td> | |
| 43 | + <td><code>{{ p.mode }}</code></td> | |
| 44 | + <td class="path-cell"> | |
| 45 | + <code class="path" :title="p.root_path">{{ p.root_path }}</code> | |
| 46 | + </td> | |
| 47 | + <td><code>{{ p.company_id }}</code></td> | |
| 48 | + <td>{{ formatTime(p.created_at) }}</td> | |
| 49 | + <td><code class="pid">{{ p.pid }}</code></td> | |
| 50 | + <td class="actions-cell"> | |
| 51 | + <button @click="openRename(p)">改名</button> | |
| 52 | + <button v-if="!p.archived" class="warn" @click="archive(p)">归档</button> | |
| 53 | + <button v-else @click="restore(p)">恢复</button> | |
| 54 | + <button class="danger" @click="hardDelete(p)">永删</button> | |
| 55 | + </td> | |
| 56 | + </tr> | |
| 57 | + </tbody> | |
| 58 | + </table> | |
| 59 | + | |
| 60 | + <!-- 新建项目弹窗 --> | |
| 61 | + <div v-if="showCreate" class="modal-mask" @click.self="closeCreate"> | |
| 62 | + <div class="modal"> | |
| 63 | + <h2>新建 DevLoop 项目</h2> | |
| 64 | + <label> | |
| 65 | + 公司 | |
| 66 | + <select v-model="newProj.company_id"> | |
| 67 | + <option value="">— 选择公司 —</option> | |
| 68 | + <option v-for="c in companies" :key="c.id" :value="c.id"> | |
| 69 | + {{ c.name }} ({{ c.id }}) | |
| 70 | + </option> | |
| 71 | + </select> | |
| 72 | + </label> | |
| 73 | + <label> | |
| 74 | + 项目名 (可选) | |
| 75 | + <input v-model="newProj.name" placeholder="如:购物车结算重构" /> | |
| 76 | + </label> | |
| 77 | + <label> | |
| 78 | + 需求描述 * | |
| 79 | + <textarea v-model="newProj.requirement" rows="5" | |
| 80 | + placeholder="一句话或一段话描述这个项目要做什么。Agent 会按此生成 PRD/SDD。" /> | |
| 81 | + </label> | |
| 82 | + | |
| 83 | + <fieldset class="storage"> | |
| 84 | + <legend>代码目录</legend> | |
| 85 | + <label class="inline"> | |
| 86 | + <input type="radio" value="sandbox" v-model="newProj.mode" /> | |
| 87 | + <span><b>沙盒</b>(自动创建在 <code>data/companies/{cid}/projects/{pid}/</code>)</span> | |
| 88 | + </label> | |
| 89 | + <label class="inline"> | |
| 90 | + <input type="radio" value="linked-new" v-model="newProj.mode" /> | |
| 91 | + <span><b>新建到本机目录</b>(linked-new:必须空目录或不存在)</span> | |
| 92 | + </label> | |
| 93 | + <label class="inline"> | |
| 94 | + <input type="radio" value="linked-existing" v-model="newProj.mode" /> | |
| 95 | + <span><b>挂到已有项目</b>(linked-existing:在你已有的代码库上启动 DevLoop)</span> | |
| 96 | + </label> | |
| 97 | + <div v-if="isLinkedMode" class="linked-row"> | |
| 98 | + <input v-model="newProj.target_path" | |
| 99 | + :placeholder="newProj.mode === 'linked-new' | |
| 100 | + ? '/Users/yibo/projects/my-new-project(绝对路径,必须空目录或不存在)' | |
| 101 | + : '/Users/yibo/projects/my-existing-repo(绝对路径,必须已存在)'" /> | |
| 102 | + <button type="button" class="ghost" @click="openBrowser">浏览…</button> | |
| 103 | + </div> | |
| 104 | + <div v-if="newProj.mode === 'linked-new'" class="hint"> | |
| 105 | + 目标目录必须是<b>空</b>目录(或不存在,会自动创建)。系统目录如 <code>/etc</code>、<code>~/.ssh</code> 已加入黑名单。 | |
| 106 | + </div> | |
| 107 | + <div v-else-if="newProj.mode === 'linked-existing'" class="hint"> | |
| 108 | + DevLoop 会把元数据(<code>project.json</code> / <code>prd/</code> / <code>sdd/</code> / <code>units/</code> / <code>timeline.ndjson</code> 等)写到此目录根。 | |
| 109 | + 如已有 <code>.git</code>,复用既有 repo 不重新 init;不会覆盖你的 <code>.gitignore</code>。 | |
| 110 | + <b>要求 working tree 干净</b>:先 <code>git commit</code> 或 <code>git stash</code> 你的未提交改动,否则会被 Agent 的 <code>git add -A</code> 误扫进 DevLoop 的 commit。 | |
| 111 | + </div> | |
| 112 | + </fieldset> | |
| 113 | + | |
| 114 | + <label class="inline"> | |
| 115 | + <input type="checkbox" v-model="newProj.mock" /> | |
| 116 | + 使用 mock 模式(不调真实 Claude,用 fixture 走通流程) | |
| 117 | + </label> | |
| 118 | + <div v-if="createError" class="err">{{ createError }}</div> | |
| 119 | + <div class="modal-actions"> | |
| 120 | + <button @click="closeCreate" :disabled="creating">取消</button> | |
| 121 | + <button class="primary" :disabled="creating || !canCreate" @click="submitCreate"> | |
| 122 | + {{ creating ? '创建中…' : '创建' }} | |
| 123 | + </button> | |
| 124 | + </div> | |
| 125 | + </div> | |
| 126 | + </div> | |
| 127 | + | |
| 128 | + <!-- 改名弹窗 --> | |
| 129 | + <div v-if="renameTarget" class="modal-mask" @click.self="renameTarget = null"> | |
| 130 | + <div class="modal sm"> | |
| 131 | + <h2>改名 — {{ renameTarget.pid }}</h2> | |
| 132 | + <label> | |
| 133 | + 名称 | |
| 134 | + <input v-model="renameValue" /> | |
| 135 | + </label> | |
| 136 | + <div v-if="renameError" class="err">{{ renameError }}</div> | |
| 137 | + <div class="modal-actions"> | |
| 138 | + <button @click="renameTarget = null" :disabled="renaming">取消</button> | |
| 139 | + <button class="primary" :disabled="renaming || !renameValue" @click="submitRename"> | |
| 140 | + {{ renaming ? '保存中…' : '保存' }} | |
| 141 | + </button> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + | |
| 146 | + <!-- 目录浏览器 --> | |
| 147 | + <div v-if="showBrowser" class="modal-mask" @click.self="showBrowser = false" style="z-index:1100"> | |
| 148 | + <div class="modal browser"> | |
| 149 | + <h2>选择代码目录</h2> | |
| 150 | + <div class="bcrumb"> | |
| 151 | + <button class="ghost" :disabled="!browser.parent" @click="browseTo(browser.parent)">↑ 上级</button> | |
| 152 | + <button class="ghost" @click="browseTo(browser.home)">🏠 主目录</button> | |
| 153 | + <input class="path-input" v-model="browser.pathInput" | |
| 154 | + @keydown.enter="browseTo(browser.pathInput)" | |
| 155 | + placeholder="输入绝对路径回车跳转" /> | |
| 156 | + </div> | |
| 157 | + <div class="cur-path">当前: <code>{{ browser.path || '—' }}</code></div> | |
| 158 | + <div v-if="browser.error" class="err">{{ browser.error }}</div> | |
| 159 | + <div class="entries"> | |
| 160 | + <div v-if="browser.loading" class="empty-mini">加载中…</div> | |
| 161 | + <div v-else-if="!browser.entries.length" class="empty-mini">(此目录下没有可见的子目录)</div> | |
| 162 | + <div v-else | |
| 163 | + v-for="e in browser.entries" | |
| 164 | + :key="e.abs_path" | |
| 165 | + class="entry" | |
| 166 | + @dblclick="browseTo(e.abs_path)" | |
| 167 | + @click="browser.selectedChild = e.abs_path"> | |
| 168 | + <span class="ico">📁</span> | |
| 169 | + <span class="ename" :class="{ active: browser.selectedChild === e.abs_path }">{{ e.name }}</span> | |
| 170 | + <span class="empty-tag" v-if="e.is_empty">空</span> | |
| 171 | + </div> | |
| 172 | + </div> | |
| 173 | + <div class="hint"> | |
| 174 | + 单击选中 / 双击进入。<b>新建项目目录</b>建议在某个父目录下手填一个不存在的子目录名(系统会创建)。 | |
| 175 | + </div> | |
| 176 | + <div class="modal-actions"> | |
| 177 | + <button @click="showBrowser = false">取消</button> | |
| 178 | + <button class="ghost" :disabled="!browser.selectedChild" @click="useThisChild"> | |
| 179 | + 使用选中目录 | |
| 180 | + </button> | |
| 181 | + <button class="primary" :disabled="!browser.path" @click="useThisDir"> | |
| 182 | + 使用当前目录: <code>{{ shortPath(browser.path) }}</code> | |
| 183 | + </button> | |
| 184 | + </div> | |
| 185 | + </div> | |
| 186 | + </div> | |
| 187 | + </div> | |
| 188 | +</template> | |
| 189 | + | |
| 190 | +<script setup> | |
| 191 | +import { ref, computed, onMounted } from 'vue'; | |
| 192 | + | |
| 193 | +const projects = ref([]); | |
| 194 | +const companies = ref([]); | |
| 195 | +const error = ref(''); | |
| 196 | +const loading = ref(false); | |
| 197 | +const includeArchived = ref(false); | |
| 198 | + | |
| 199 | +const showCreate = ref(false); | |
| 200 | +const creating = ref(false); | |
| 201 | +const createError = ref(''); | |
| 202 | +const newProj = ref({ company_id: '', name: '', requirement: '', mock: true, mode: 'sandbox', target_path: '' }); | |
| 203 | +const isLinkedMode = computed(() => | |
| 204 | + newProj.value.mode === 'linked-new' || newProj.value.mode === 'linked-existing' | |
| 205 | +); | |
| 206 | +const canCreate = computed(() => { | |
| 207 | + if (!newProj.value.company_id || !newProj.value.requirement.trim()) return false; | |
| 208 | + if (isLinkedMode.value && !newProj.value.target_path.trim()) return false; | |
| 209 | + return true; | |
| 210 | +}); | |
| 211 | + | |
| 212 | +const showBrowser = ref(false); | |
| 213 | +const browser = ref({ | |
| 214 | + path: '', parent: null, home: '', entries: [], pathInput: '', | |
| 215 | + loading: false, error: '', selectedChild: '', | |
| 216 | +}); | |
| 217 | + | |
| 218 | +const renameTarget = ref(null); | |
| 219 | +const renameValue = ref(''); | |
| 220 | +const renaming = ref(false); | |
| 221 | +const renameError = ref(''); | |
| 222 | + | |
| 223 | +async function fetchProjects() { | |
| 224 | + loading.value = true; | |
| 225 | + try { | |
| 226 | + const url = `/api/devloop/projects${includeArchived.value ? '?include_archived=true' : ''}`; | |
| 227 | + const r = await fetch(url); | |
| 228 | + if (!r.ok) throw new Error(`HTTP ${r.status}`); | |
| 229 | + const j = await r.json(); | |
| 230 | + projects.value = j.projects || []; | |
| 231 | + error.value = ''; | |
| 232 | + } catch (e) { | |
| 233 | + error.value = '加载项目失败: ' + e.message; | |
| 234 | + } finally { | |
| 235 | + loading.value = false; | |
| 236 | + } | |
| 237 | +} | |
| 238 | + | |
| 239 | +async function fetchCompanies() { | |
| 240 | + try { | |
| 241 | + const r = await fetch('/api/company/list'); | |
| 242 | + if (!r.ok) return; | |
| 243 | + const j = await r.json(); | |
| 244 | + companies.value = Array.isArray(j) ? j : (j.companies || []); | |
| 245 | + } catch { /* 忽略,用户可手填 pid */ } | |
| 246 | +} | |
| 247 | + | |
| 248 | +function openCreate() { | |
| 249 | + newProj.value = { company_id: '', name: '', requirement: '', mock: true, mode: 'sandbox', target_path: '' }; | |
| 250 | + createError.value = ''; | |
| 251 | + showCreate.value = true; | |
| 252 | +} | |
| 253 | +function closeCreate() { | |
| 254 | + if (creating.value) return; | |
| 255 | + showCreate.value = false; | |
| 256 | +} | |
| 257 | + | |
| 258 | +async function submitCreate() { | |
| 259 | + creating.value = true; | |
| 260 | + createError.value = ''; | |
| 261 | + try { | |
| 262 | + const body = { | |
| 263 | + company_id: newProj.value.company_id, | |
| 264 | + name: newProj.value.name || undefined, | |
| 265 | + requirement: newProj.value.requirement.trim(), | |
| 266 | + mock: newProj.value.mock, | |
| 267 | + mode: newProj.value.mode, | |
| 268 | + }; | |
| 269 | + if (isLinkedMode.value) { | |
| 270 | + body.target_path = newProj.value.target_path.trim(); | |
| 271 | + } | |
| 272 | + const r = await fetch('/api/devloop/projects', { | |
| 273 | + method: 'POST', | |
| 274 | + headers: { 'Content-Type': 'application/json' }, | |
| 275 | + body: JSON.stringify(body), | |
| 276 | + }); | |
| 277 | + const j = await r.json(); | |
| 278 | + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); | |
| 279 | + showCreate.value = false; | |
| 280 | + await fetchProjects(); | |
| 281 | + } catch (e) { | |
| 282 | + createError.value = e.message; | |
| 283 | + } finally { | |
| 284 | + creating.value = false; | |
| 285 | + } | |
| 286 | +} | |
| 287 | + | |
| 288 | +async function openBrowser() { | |
| 289 | + showBrowser.value = true; | |
| 290 | + // 起点:用户已填的路径目录 / 已填值本身(若是目录) / $HOME | |
| 291 | + let start = newProj.value.target_path?.trim() || ''; | |
| 292 | + if (!start) start = ''; // 让后端默认 $HOME | |
| 293 | + browser.value.selectedChild = ''; | |
| 294 | + await browseTo(start); | |
| 295 | +} | |
| 296 | + | |
| 297 | +async function browseTo(p) { | |
| 298 | + browser.value.loading = true; | |
| 299 | + browser.value.error = ''; | |
| 300 | + try { | |
| 301 | + const url = p | |
| 302 | + ? `/api/devloop/fs/browse?path=${encodeURIComponent(p)}` | |
| 303 | + : '/api/devloop/fs/browse'; | |
| 304 | + const r = await fetch(url); | |
| 305 | + const j = await r.json(); | |
| 306 | + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); | |
| 307 | + browser.value.path = j.path; | |
| 308 | + browser.value.parent = j.parent; | |
| 309 | + browser.value.home = j.home; | |
| 310 | + browser.value.entries = j.entries || []; | |
| 311 | + browser.value.pathInput = j.path; | |
| 312 | + browser.value.selectedChild = ''; | |
| 313 | + } catch (e) { | |
| 314 | + browser.value.error = e.message; | |
| 315 | + } finally { | |
| 316 | + browser.value.loading = false; | |
| 317 | + } | |
| 318 | +} | |
| 319 | + | |
| 320 | +function useThisDir() { | |
| 321 | + if (!browser.value.path) return; | |
| 322 | + newProj.value.target_path = browser.value.path; | |
| 323 | + showBrowser.value = false; | |
| 324 | +} | |
| 325 | + | |
| 326 | +function useThisChild() { | |
| 327 | + if (!browser.value.selectedChild) return; | |
| 328 | + newProj.value.target_path = browser.value.selectedChild; | |
| 329 | + showBrowser.value = false; | |
| 330 | +} | |
| 331 | + | |
| 332 | +function shortPath(p) { | |
| 333 | + if (!p) return ''; | |
| 334 | + if (browser.value.home && p.startsWith(browser.value.home)) { | |
| 335 | + return '~' + p.slice(browser.value.home.length); | |
| 336 | + } | |
| 337 | + return p.length > 48 ? '…' + p.slice(-45) : p; | |
| 338 | +} | |
| 339 | + | |
| 340 | +function openRename(p) { | |
| 341 | + renameTarget.value = p; | |
| 342 | + renameValue.value = p.name || ''; | |
| 343 | + renameError.value = ''; | |
| 344 | +} | |
| 345 | + | |
| 346 | +async function submitRename() { | |
| 347 | + if (!renameTarget.value) return; | |
| 348 | + renaming.value = true; | |
| 349 | + renameError.value = ''; | |
| 350 | + try { | |
| 351 | + const r = await fetch(`/api/devloop/projects/${renameTarget.value.pid}`, { | |
| 352 | + method: 'PATCH', | |
| 353 | + headers: { 'Content-Type': 'application/json' }, | |
| 354 | + body: JSON.stringify({ name: renameValue.value }), | |
| 355 | + }); | |
| 356 | + const j = await r.json(); | |
| 357 | + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); | |
| 358 | + renameTarget.value = null; | |
| 359 | + await fetchProjects(); | |
| 360 | + } catch (e) { | |
| 361 | + renameError.value = e.message; | |
| 362 | + } finally { | |
| 363 | + renaming.value = false; | |
| 364 | + } | |
| 365 | +} | |
| 366 | + | |
| 367 | +async function archive(p) { | |
| 368 | + if (!confirm(`归档项目 "${p.name || p.pid}"?项目数据保留,可在"显示已归档"中恢复。`)) return; | |
| 369 | + const r = await fetch(`/api/devloop/projects/${p.pid}`, { method: 'DELETE' }); | |
| 370 | + if (!r.ok) { | |
| 371 | + const j = await r.json().catch(() => ({})); | |
| 372 | + return alert('归档失败: ' + (j.error || r.status)); | |
| 373 | + } | |
| 374 | + await fetchProjects(); | |
| 375 | +} | |
| 376 | + | |
| 377 | +async function restore(p) { | |
| 378 | + const r = await fetch(`/api/devloop/projects/${p.pid}/restore`, { method: 'POST' }); | |
| 379 | + if (!r.ok) { | |
| 380 | + const j = await r.json().catch(() => ({})); | |
| 381 | + return alert('恢复失败: ' + (j.error || r.status)); | |
| 382 | + } | |
| 383 | + await fetchProjects(); | |
| 384 | +} | |
| 385 | + | |
| 386 | +async function hardDelete(p) { | |
| 387 | + const msg = p.mode === 'sandbox' | |
| 388 | + ? `永久删除 "${p.name || p.pid}"?\n\n会递归删除目录:\n${p.root_path}\n\n此操作不可撤销!` | |
| 389 | + : `从 DevLoop 注册表中移除 "${p.name || p.pid}"?\n\n(linked 模式:你的目录 ${p.root_path} 会保留)`; | |
| 390 | + if (!confirm(msg)) return; | |
| 391 | + const r = await fetch(`/api/devloop/projects/${p.pid}?hard=true`, { method: 'DELETE' }); | |
| 392 | + if (!r.ok) { | |
| 393 | + const j = await r.json().catch(() => ({})); | |
| 394 | + return alert('删除失败: ' + (j.error || r.status)); | |
| 395 | + } | |
| 396 | + await fetchProjects(); | |
| 397 | +} | |
| 398 | + | |
| 399 | +function formatTime(iso) { | |
| 400 | + if (!iso) return '—'; | |
| 401 | + try { | |
| 402 | + const d = new Date(iso); | |
| 403 | + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; | |
| 404 | + } catch { return iso; } | |
| 405 | +} | |
| 406 | + | |
| 407 | +onMounted(() => { | |
| 408 | + fetchProjects(); | |
| 409 | + fetchCompanies(); | |
| 410 | +}); | |
| 411 | +</script> | |
| 412 | + | |
| 413 | +<style scoped> | |
| 414 | +.devloop-projects { max-width: 1280px; margin: 0 auto; padding: 16px 24px; } | |
| 415 | +.hdr { display:flex; align-items:center; justify-content:space-between; padding-bottom:12px; border-bottom:1px solid #eee; } | |
| 416 | +.hdr h1 { font-size:20px; margin:0; } | |
| 417 | +.hdr-actions { display:flex; align-items:center; gap:14px; } | |
| 418 | +.archived-toggle { font-size:13px; color:#666; display:flex; align-items:center; gap:6px; cursor:pointer; } | |
| 419 | +.error-banner { background:#fee; border:1px solid #fcc; color:#c00; padding:8px 12px; border-radius:6px; margin:12px 0; } | |
| 420 | + | |
| 421 | +.empty { text-align:center; color:#888; padding:48px 0; font-size:14px; } | |
| 422 | + | |
| 423 | +.proj-table { width:100%; border-collapse:collapse; margin-top:16px; background:#fff; } | |
| 424 | +.proj-table th, .proj-table td { padding:10px 12px; border-bottom:1px solid #eee; text-align:left; font-size:13px; vertical-align:middle; } | |
| 425 | +.proj-table th { background:#fafafa; color:#555; font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:0.04em; } | |
| 426 | +.proj-table tr.archived { opacity:0.55; background:#fafafa; } | |
| 427 | +.proj-table tr:hover:not(.archived) { background:#fafbff; } | |
| 428 | + | |
| 429 | +.name-cell { min-width:240px; } | |
| 430 | +.name-link { color:#0a6; text-decoration:none; font-weight:500; } | |
| 431 | +.name-link:hover { text-decoration:underline; } | |
| 432 | +.idea { color:#888; font-size:12px; margin-top:2px; max-width:340px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } | |
| 433 | + | |
| 434 | +.pid, code { font-family: ui-monospace, SFMono-Regular, monospace; font-size:12px; background:#f4f4f6; padding:1px 6px; border-radius:3px; } | |
| 435 | + | |
| 436 | +.status-pill { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; background:#eee; color:#444; } | |
| 437 | +.s-draft { background:#eef; color:#446; } | |
| 438 | +.s-running { background:#fff3cd; color:#7a5; } | |
| 439 | +.s-shipped { background:#d4f4dd; color:#0a6; } | |
| 440 | +.s-shipped-final { background:#0a6; color:#fff; } | |
| 441 | +.s-abandoned { background:#fbe; color:#922; } | |
| 442 | +.s-l2_blocked { background:#fdd; color:#a44; } | |
| 443 | + | |
| 444 | +.actions-col { width:240px; } | |
| 445 | +.actions-cell { display:flex; gap:6px; } | |
| 446 | +.actions-cell button { padding:4px 10px; font-size:12px; border:1px solid #ddd; background:#fff; border-radius:4px; cursor:pointer; color:#444; } | |
| 447 | +.actions-cell button:hover { background:#f4f4f6; border-color:#bbb; } | |
| 448 | +.actions-cell button.warn { color:#a60; border-color:#fc8; } | |
| 449 | +.actions-cell button.danger { color:#c00; border-color:#f99; } | |
| 450 | + | |
| 451 | +button.primary { background:#0a6; color:#fff; border:1px solid #0a6; padding:6px 14px; border-radius:5px; cursor:pointer; font-size:13px; } | |
| 452 | +button.primary:hover:not(:disabled) { background:#085; } | |
| 453 | +button.primary:disabled { background:#bbb; border-color:#bbb; color:#fff; opacity:0.7; cursor:not-allowed; } | |
| 454 | + | |
| 455 | +/* Modal */ | |
| 456 | +.modal-mask { position:fixed; inset:0; background:rgba(0,0,0,0.4); display:flex; align-items:center; justify-content:center; z-index:1000; } | |
| 457 | +.modal { background:#fff; border-radius:8px; padding:20px 24px; min-width:480px; max-width:600px; box-shadow:0 8px 32px rgba(0,0,0,0.25); } | |
| 458 | +.modal.sm { min-width:360px; } | |
| 459 | +.modal h2 { font-size:16px; margin:0 0 16px 0; } | |
| 460 | +.modal label { display:block; margin-bottom:12px; font-size:13px; color:#444; } | |
| 461 | +.modal label.inline { display:flex; align-items:center; gap:8px; } | |
| 462 | +.modal input[type="text"], .modal input:not([type]), .modal textarea, .modal select { display:block; width:100%; margin-top:4px; padding:6px 10px; border:1px solid #ccc; border-radius:4px; font-size:13px; box-sizing:border-box; } | |
| 463 | +.modal textarea { resize:vertical; font-family:inherit; } | |
| 464 | +.modal .err { color:#c00; font-size:12px; margin:8px 0; padding:6px 10px; background:#fee; border-radius:4px; } | |
| 465 | +.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:18px; } | |
| 466 | +.modal-actions button { padding:6px 16px; border:1px solid #ccc; background:#fff; border-radius:5px; cursor:pointer; font-size:13px; } | |
| 467 | +.modal-actions button:hover:not(:disabled) { background:#f4f4f6; } | |
| 468 | +.modal-actions button:disabled { opacity:0.5; cursor:not-allowed; } | |
| 469 | +.modal-actions button.ghost { color:#0a6; border-color:#9d6; } | |
| 470 | +.modal-actions button.ghost:hover:not(:disabled) { background:#f0fff5; } | |
| 471 | + | |
| 472 | +/* path cell */ | |
| 473 | +.path-cell { max-width:300px; } | |
| 474 | +.path { display:inline-block; max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:middle; font-size:11px; } | |
| 475 | + | |
| 476 | +/* storage fieldset */ | |
| 477 | +.modal fieldset.storage { border:1px solid #e5e5e8; border-radius:6px; padding:12px 14px; margin-bottom:14px; } | |
| 478 | +.modal fieldset.storage legend { padding:0 6px; color:#555; font-size:12px; font-weight:600; } | |
| 479 | +.modal fieldset.storage label.inline { margin-bottom:8px; } | |
| 480 | +.modal fieldset.storage code { background:#f4f4f6; padding:1px 5px; border-radius:3px; font-size:11px; } | |
| 481 | +.modal .linked-row { display:flex; gap:8px; margin-top:4px; } | |
| 482 | +.modal .linked-row input { flex:1; padding:6px 10px; border:1px solid #ccc; border-radius:4px; font-size:13px; font-family: ui-monospace, monospace; } | |
| 483 | +.modal .linked-row button.ghost { padding:6px 12px; border:1px solid #9d6; color:#0a6; background:#fff; border-radius:4px; cursor:pointer; font-size:13px; } | |
| 484 | +.modal .linked-row button.ghost:hover { background:#f0fff5; } | |
| 485 | +.modal .hint { color:#888; font-size:11px; margin-top:6px; line-height:1.5; } | |
| 486 | + | |
| 487 | +/* directory browser */ | |
| 488 | +.modal.browser { min-width:560px; max-width:720px; } | |
| 489 | +.modal.browser .bcrumb { display:flex; gap:6px; align-items:center; margin-bottom:8px; } | |
| 490 | +.modal.browser .bcrumb button.ghost { padding:4px 10px; border:1px solid #ccc; background:#fff; border-radius:4px; font-size:12px; cursor:pointer; } | |
| 491 | +.modal.browser .bcrumb button.ghost:hover:not(:disabled) { background:#f4f4f6; } | |
| 492 | +.modal.browser .bcrumb .path-input { flex:1; padding:5px 10px; border:1px solid #ccc; border-radius:4px; font-size:12px; font-family: ui-monospace, monospace; } | |
| 493 | +.modal.browser .cur-path { font-size:12px; color:#666; margin:6px 0; } | |
| 494 | +.modal.browser .entries { border:1px solid #eee; border-radius:6px; padding:6px 0; max-height:320px; min-height:180px; overflow-y:auto; background:#fafafa; } | |
| 495 | +.modal.browser .entry { display:flex; align-items:center; gap:10px; padding:6px 14px; cursor:pointer; user-select:none; } | |
| 496 | +.modal.browser .entry:hover { background:#eef; } | |
| 497 | +.modal.browser .entry .ico { font-size:13px; } | |
| 498 | +.modal.browser .entry .ename { font-size:13px; } | |
| 499 | +.modal.browser .entry .ename.active { color:#0a6; font-weight:600; } | |
| 500 | +.modal.browser .entry .empty-tag { font-size:10px; color:#0a6; background:#dfd; padding:1px 6px; border-radius:8px; margin-left:auto; } | |
| 501 | +.modal.browser .empty-mini { color:#888; font-size:12px; padding:24px; text-align:center; } | |
| 502 | +</style> | ... | ... |