diff --git a/CLAUDE.md b/CLAUDE.md index 10f3c2b..7cb20c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,11 @@ npm run setup # 开发模式(前后端分离,跑完整 ArenaSK + LemonBoss) npm run dev # 后端 server/index.js:3010 + 前端 vite:5173 -# 仅 Agent Chat 子系统(轻量,不依赖 Skill DB) -bash start.sh # server/agent-server.js:3010 + vite:5173 +# 等价快捷脚本(先 kill 占用端口再起) +bash start.sh # server/index.js:3010 + vite:5173 + +# (历史轻量入口,仅 Agent Chat / Company / OKR / Tasks / DevLoop 子集) +PORT=3010 node server/agent-server.js ``` > 端口默认 **3010**(`server/config.js`),不是 3000。`start.sh` 会先 kill 占用 3010 / 5173 的进程。 @@ -64,17 +67,17 @@ auto-dev "" # bin 入口 → server/src/cli/auto-dev.js | 入口 | 端口 | 启动方式 | 挂载路由 | |------|------|----------|----------| -| `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/` 静态文件 | -| `server/agent-server.js` | 3010 | `bash start.sh` | 仅 LemonBoss 子集:`/api/agent` `/api/company` `/api/goals` `/api/tasks` | +| `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/` 静态文件 | +| `server/agent-server.js` | 3010 | 仅手动 `node server/agent-server.js`(历史保留) | 子集:`/api/agent` `/api/company` `/api/goals` `/api/tasks` `/api/devloop` | -两者**不要同时启动**(端口冲突)。需要 Skill / Arena / Master Agent 时跑 `index.js`;只调通 Agent Chat / Company / OKR 时跑 `agent-server.js`。 +两者**不要同时启动**(端口冲突)。默认走 `index.js`(`start.sh` / `npm run dev` 都指向它);`agent-server.js` 只在你想跑无依赖最小子集时手动启动。 ### 目录速查 | 目录 | 说明 | |------|------| -| `server/index.js` | 完整 ArenaSK 服务(API + WS + 插件 + 前端托管) | -| `server/agent-server.js` | 独立 Agent Chat 服务(LemonBoss 子集) | +| `server/index.js` | 完整 ArenaSK 服务(API + WS + 插件 + 前端托管),`start.sh` / `npm run dev` 默认入口 | +| `server/agent-server.js` | 历史保留的轻量子集入口(Agent / Company / Goals / Tasks / DevLoop),仅手动启动 | | `server/routes/` | 15 个 API 路由 | | `server/services/` | 26 个业务服务(skill-db, benchmark-runner, company-service, heartbeat, pipeline-service, retrain-pipeline, …) | | `server/src/core/` | Agent 引擎核心(orchestrator, master-session, agent-manager, scheduler, session-pool, prompt-builder, runtime-resolver, …) | @@ -139,7 +142,7 @@ auto-dev "" # bin 入口 → server/src/cli/auto-dev.js ## 注意事项 - 生产环境只跑 `server/index.js`:它会同时托管 API + `web/dist/` 静态文件(`NODE_ENV=production`)。 -- `start.sh` **只**做:kill 端口 → 起 `agent-server.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。 +- `start.sh` **只**做:kill 占用 3010/5173 的进程 → 起 `server/index.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。 - `server/index.js` 与 `agent-server.js` 都内置了 **body-sanitizer 中间件**:在 `express.json()` 之前清掉 `\x00-\x1F` 控制字符,避免外部 Agent 通过 `curl` 传未转义换行/制表符导致的 `Bad control character` 报错。修改请求体解析链路前先看这两段。 - 模块别名 `@server` → `server/`(`module-alias` 注册),跨子包 require 时可用。 - Claude CLI 进程默认带 `--dangerously-skip-permissions`,写入受限路径前自己评估风险。 diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json new file mode 100644 index 0000000..dd26159 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json @@ -0,0 +1,59 @@ +{ + "_doc": "1-unit (todo-be only) contract_mismatch scenario. seq[0]=init, seq[1]=patch (rev=2 + l2_rounds=1).", + "sequence": [ + { + "_variant": "init", + "chunks": ["## SDD rev 1 + units.json (single unit: todo-be)\n"], + "result": "mock-ok devloop-architect[init]: SDD rev 1", + "files": [ + { + "path": "sdd/index.html", + "content": "SDD

SDD

todo-be

m
m
x
" + }, + { + "path": "sdd/mvcp/control.html", + "content": "Control rev1

C-1 POST /todos

201 on create (ambiguous on errors)

text
POST
v1
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "integration/spec.json", + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" + }, + { + "path": "units/todo-be/spec.json", + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"GET /todos returns array\",\"POST /todos empty returns 400\"]}" + }, + { + "path": "history/sdd-rev-001.html", + "content": "

snap1

rev1

" + } + ] + }, + { + "_variant": "patch", + "chunks": ["## Patch: SDD rev 2, units.json l2_rounds=1, todo-be status=pending\n"], + "result": "mock-ok devloop-architect[patch]: SDD rev 2", + "files": [ + { + "path": "sdd/mvcp/control.html", + "content": "Control rev 2

C-1 POST /todos (clarified)

201 on create; 400 on missing text

textrequired
POST
v2
" + }, + { + "path": "sdd/index.html", + "content": "SDD rev2

SDD

rev 2

m
m
x
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "history/sdd-rev-002.html", + "content": "

snap2

patch round 1

" + } + ] + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json new file mode 100644 index 0000000..9c990ea --- /dev/null +++ b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json @@ -0,0 +1,42 @@ +{ + "_doc": "L2 contract_mismatch: round 1 fails (auth-be 500 vs auth-fe expects 401), round 2 passes after Architect patches SDD + L1 reruns.", + "sequence": [ + { + "_variant": "round1-fail", + "chunks": [ + "## step 0 · 读 units.json + integration/spec.json\n", + "## step 1-4 · 起 be 进程,跑 case-add-list\n", + "case 失败: POST /todos 返 500, 期望 201/400 (contract 不一致)\n" + ], + "result": "mock-ok devloop-integrator[round1] FAILED: contract mismatch", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/result.json", + "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" + }, + { + "path": "integration/runs/run-mock-l2-1/process.log", + "content": "[todo-be] starting on :18001\n[todo-be] TypeError at server.js:8\n[todo-be] exited code 1\n" + }, + { + "path": "integration/runs/run-mock-l2-1/http-trace.ndjson", + "content": "{\"ts\":1747139000,\"unit\":\"todo-be\",\"method\":\"POST\",\"url\":\"http://127.0.0.1:18001/todos\",\"status\":500}\n" + } + ] + }, + { + "_variant": "round2-pass", + "chunks": [ + "## L2 round 2 (after SDD patched + L1 reran)\n", + "case-add-list 通过 ✓\n" + ], + "result": "mock-ok devloop-integrator[round2] PASS: integration green", + "files": [ + { + "path": "integration/runs/run-mock-l2-2/result.json", + "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" + } + ] + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json new file mode 100644 index 0000000..aa2749e --- /dev/null +++ b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json @@ -0,0 +1,16 @@ +{ + "_doc": "Triager归因为 contract_mismatch (confidence 0.85) → LoopController will dispatch Architect(patch).", + "chunks": [ + "## step 0 · 收集证据 (result.json + process.log + sdd + contracts)\n", + "## step 1 · category_classifier → contract_mismatch\n", + "## step 2 · 起草 proposed_sdd_patch\n", + "## step 3 · 写 triage.json + evidence 自检\n" + ], + "result": "mock-ok devloop-triager: contract_mismatch confidence=0.85 affected=[todo-be]", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/triage.json", + "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" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json b/fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json new file mode 100644 index 0000000..707e473 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json @@ -0,0 +1,31 @@ +{ + "_doc": "Happy-path 1-unit (todo-be) architect for P4 acceptance.", + "chunks": ["## SDD rev 1 (todo-be only)\n"], + "result": "mock-ok devloop-architect[happy]: 1 unit", + "files": [ + { + "path": "sdd/index.html", + "content": "SDD

SDD

todo-be only

m
m
v1
" + }, + { + "path": "sdd/mvcp/control.html", + "content": "Control

C-1 POST /todos

201 on create, 400 on missing text

text
POST
api
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "integration/spec.json", + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" + }, + { + "path": "units/todo-be/spec.json", + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}" + }, + { + "path": "history/sdd-rev-001.html", + "content": "

snap

rev1

" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json new file mode 100644 index 0000000..ea2ef6f --- /dev/null +++ b/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json @@ -0,0 +1,31 @@ +{ + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", + "chunks": ["## SDD rev 1 (single unit todo-be)\n"], + "result": "mock-ok devloop-architect[init]: SDD rev 1", + "files": [ + { + "path": "sdd/index.html", + "content": "SDD

SDD

todo-be

m
m
x
" + }, + { + "path": "sdd/mvcp/control.html", + "content": "Control

C-1 POST /todos

201 on create, 400 on missing text (explicit)

text
POST
api
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "integration/spec.json", + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" + }, + { + "path": "units/todo-be/spec.json", + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}" + }, + { + "path": "history/sdd-rev-001.html", + "content": "

snap

rev1

" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json new file mode 100644 index 0000000..15078b5 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json @@ -0,0 +1,31 @@ +{ + "_doc": "impl_bug: integration fails with assertion (status 500 but SDD said something specific). Round 2 passes after just resetting affected units.", + "sequence": [ + { + "_variant": "round1-fail", + "chunks": ["## case fail: todo-be assertion mismatch\n"], + "result": "mock-ok devloop-integrator[round1]: impl_bug failure", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/result.json", + "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" + }, + { + "path": "integration/runs/run-mock-l2-1/process.log", + "content": "[todo-be] ReferenceError at line 8\n" + } + ] + }, + { + "_variant": "round2-pass", + "chunks": ["## pass after coder reset\n"], + "result": "mock-ok devloop-integrator[round2]: pass", + "files": [ + { + "path": "integration/runs/run-mock-l2-2/result.json", + "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" + } + ] + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json new file mode 100644 index 0000000..646a68c --- /dev/null +++ b/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json @@ -0,0 +1,13 @@ +{ + "_doc": "Triager: impl_bug — SDD 写得清楚,code 没遵守。不改 SDD,只 reset todo-be。", + "chunks": [ + "## category_classifier → impl_bug (SDD 明确,code 实现错)\n" + ], + "result": "mock-ok devloop-triager: impl_bug confidence=0.78 affected=[todo-be]", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/triage.json", + "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" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json new file mode 100644 index 0000000..ea2ef6f --- /dev/null +++ b/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json @@ -0,0 +1,31 @@ +{ + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", + "chunks": ["## SDD rev 1 (single unit todo-be)\n"], + "result": "mock-ok devloop-architect[init]: SDD rev 1", + "files": [ + { + "path": "sdd/index.html", + "content": "SDD

SDD

todo-be

m
m
x
" + }, + { + "path": "sdd/mvcp/control.html", + "content": "Control

C-1 POST /todos

201 on create, 400 on missing text (explicit)

text
POST
api
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "integration/spec.json", + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" + }, + { + "path": "units/todo-be/spec.json", + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}" + }, + { + "path": "history/sdd-rev-001.html", + "content": "

snap

rev1

" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json new file mode 100644 index 0000000..97b9d25 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json @@ -0,0 +1,31 @@ +{ + "_doc": "infra_flake: round 1 crashed (EADDRINUSE), round 2 passes after retry (no reset, no budget burn).", + "sequence": [ + { + "_variant": "round1-crash", + "chunks": ["## EADDRINUSE on :18001\n"], + "result": "mock-ok devloop-integrator[round1] CRASH: EADDRINUSE", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/result.json", + "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" + }, + { + "path": "integration/runs/run-mock-l2-1/process.log", + "content": "[todo-be] Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\n" + } + ] + }, + { + "_variant": "round2-pass", + "chunks": ["## retry: new port, pass\n"], + "result": "mock-ok devloop-integrator[round2]: pass", + "files": [ + { + "path": "integration/runs/run-mock-l2-2/result.json", + "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" + } + ] + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json new file mode 100644 index 0000000..3923410 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json @@ -0,0 +1,11 @@ +{ + "_doc": "Triager: infra_flake — env issue, just rerun. affected=[], no SDD change.", + "chunks": ["## stderr 含 EADDRINUSE → infra_flake (直接重跑)\n"], + "result": "mock-ok devloop-triager: infra_flake confidence=0.95", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/triage.json", + "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" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json new file mode 100644 index 0000000..ea2ef6f --- /dev/null +++ b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json @@ -0,0 +1,31 @@ +{ + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", + "chunks": ["## SDD rev 1 (single unit todo-be)\n"], + "result": "mock-ok devloop-architect[init]: SDD rev 1", + "files": [ + { + "path": "sdd/index.html", + "content": "SDD

SDD

todo-be

m
m
x
" + }, + { + "path": "sdd/mvcp/control.html", + "content": "Control

C-1 POST /todos

201 on create, 400 on missing text (explicit)

text
POST
api
" + }, + { + "path": "units.json", + "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" + }, + { + "path": "integration/spec.json", + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" + }, + { + "path": "units/todo-be/spec.json", + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}" + }, + { + "path": "history/sdd-rev-001.html", + "content": "

snap

rev1

" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json new file mode 100644 index 0000000..4aba5a9 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json @@ -0,0 +1,11 @@ +{ + "_doc": "spec_ambiguous: integration fails but root cause is SDD ambiguity. Triager confidence < 0.5 → human intervention.", + "chunks": ["## case fail (spec_ambiguous)\n"], + "result": "mock-ok devloop-integrator: failure due to ambiguous SDD", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/result.json", + "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" + } + ] +} diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json new file mode 100644 index 0000000..07c7ef2 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json @@ -0,0 +1,11 @@ +{ + "_doc": "Triager: spec_ambiguous, confidence 0.4 → human_intervention_needed.", + "chunks": ["## SDD 未明确 token 续期 / POST 状态码 → spec_ambiguous\n"], + "result": "mock-ok devloop-triager: spec_ambiguous confidence=0.4 (升 L3)", + "files": [ + { + "path": "integration/runs/run-mock-l2-1/triage.json", + "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" + } + ] +} diff --git a/fixtures/devloop-mock/devloop-integrator.json b/fixtures/devloop-mock/devloop-integrator.json new file mode 100644 index 0000000..fb9c7fa --- /dev/null +++ b/fixtures/devloop-mock/devloop-integrator.json @@ -0,0 +1,19 @@ +{ + "_doc": "Baseline happy-path Integrator: returns ok=true on every call. Used by tests/runs that don't override via mockFixtureDir.", + "chunks": [ + "## step 0 · 读 units.json + integration/spec.json\n", + "## step 1-7 · 起 be 进程,跑全部 cases\n", + "全部 case 通过 ✓\n" + ], + "result": "mock-ok devloop-integrator[baseline] PASS: integration green", + "files": [ + { + "path": "integration/runs/run-mock-baseline/result.json", + "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" + }, + { + "path": "integration/runs/run-mock-baseline/process.log", + "content": "[todo-be] starting on :18001\n[todo-be] /api/health → 200\n[todo-be] case-* pass\n[todo-be] SIGTERM clean exit\n" + } + ] +} diff --git a/fixtures/devloop-mock/devloop-pm.json b/fixtures/devloop-mock/devloop-pm.json index 723cd16..cc84063 100644 --- a/fixtures/devloop-mock/devloop-pm.json +++ b/fixtures/devloop-mock/devloop-pm.json @@ -1,6 +1,9 @@ { - "_doc": "Mock fixture for PM Agent — drives P1 acceptance (todo-demo project). Produces the full PRD multi-page site + pm agent-state + history snapshot.", - "chunks": [ + "_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 简化)。", + "sequence": [ + { + "_variant": "init", + "chunks": [ "## 第 1 步: 解析 idea\n", "用户要求做一个 TODO 列表 API + Vue 前端。\n", "\n## 第 2 步: 初始化 prd/ 站点\n", @@ -69,4 +72,28 @@ "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}" } ] + }, + { + "_variant": "incremental", + "chunks": [ + "## Mode: incremental\n", + "收到人类反馈,追加 prd/feature/feature-tree.html 一个 section,bump prd_rev → 2\n" + ], + "result": "mock-ok devloop-pm[incremental]: PRD rev 2 + 1 new feature section", + "files": [ + { + "path": "prd/index.html", + "content": "PRD · todo-demo (rev 2)

项目首页

todo-demo · 一个 TODO 列表 API + 前端

章节路径
背景product/background.html
flow: list -> add -> done
导航卡片
更多

站点入口

新增功能 (feedback)

追加: 标记 done / undone 切换

新功能
toggle done
v2
rev2: 人类追加 toggle done
" + }, + { + "path": "prd/feature/feature-tree.html", + "content": "功能树 rev 2

功能树

todo-be · todo-fe · todo-db

模块说明
todo-beAPI
tree
v1

NEW: toggle done

用户反馈:列表项需要支持标记完成/未完成的切换。新增 PATCH /todos/:id 接口与对应前端按钮。

PATCH/todos/:id
toggle
delta
" + }, + { + "path": "history/prd-rev-002.html", + "content": "

Snapshot rev 2

Archive of PRD rev 2 (after incremental feedback).

" + } + ] + } + ] } diff --git a/packages/acp-core/protocol/ClaudeCliProtocol.js b/packages/acp-core/protocol/ClaudeCliProtocol.js index 3d85913..b4a2502 100644 --- a/packages/acp-core/protocol/ClaudeCliProtocol.js +++ b/packages/acp-core/protocol/ClaudeCliProtocol.js @@ -49,6 +49,9 @@ class ClaudeCliProtocol extends EventEmitter { this._mockMode = MOCK_MODE || options.mock === true; this._mockFixtureDir = options.mockFixtureDir || path.resolve(__dirname, '../../../fixtures/devloop-mock'); + // P3 场景测试: 如 scenario 目录无 {agent}.json,回落到 baseline 目录 + this._mockFallbackDir = options.mockFallbackDir + || path.resolve(__dirname, '../../../fixtures/devloop-mock'); } /** @@ -73,6 +76,19 @@ class ClaudeCliProtocol extends EventEmitter { '--dangerously-skip-permissions', ]; + // DevLoop 真模式: 注入 agent 专属 system prompt (来自 system/templates/{agent}/prompt.md) + // 不用 --bare — 我们仍想要 Read/Write/Edit/Bash 自动可用 + 工具权限提示等正常默认。 + // 用 --append-system-prompt 而不是 --system-prompt: 保留 Claude 默认提示 + // (含工具使用约定),再追加 agent 的 ROLE / SANDBOX / WORKFLOW 段。 + if (this.options.systemPrompt && typeof this.options.systemPrompt === 'string') { + args.push('--append-system-prompt', this.options.systemPrompt); + } + + // 单次会话预算上限 (USD); 防 Agent 跑飞烧钱 + if (typeof this.options.maxBudgetUsd === 'number' && this.options.maxBudgetUsd > 0) { + args.push('--max-budget-usd', String(this.options.maxBudgetUsd)); + } + // 支持禁用特定原生工具 (用于编排型 Agent,强制纯文本输出) if (this.options.disallowedTools && this.options.disallowedTools.length > 0) { args.push('--disallowedTools', ...this.options.disallowedTools); @@ -536,21 +552,25 @@ class ClaudeCliProtocol extends EventEmitter { async _sendMockPrompt(text) { const agentName = this.agentInfo?.name || this.options.agentName || 'default'; const fixturePath = path.join(this._mockFixtureDir, `${agentName}.json`); - const defaultPath = path.join(this._mockFixtureDir, 'default.json'); + const fallbackPath = this._mockFallbackDir !== this._mockFixtureDir + ? path.join(this._mockFallbackDir, `${agentName}.json`) + : null; + const defaultPath = path.join(this._mockFallbackDir, 'default.json'); let raw; - if (fs.existsSync(fixturePath)) { + const tryPaths = [fixturePath]; + if (fallbackPath) tryPaths.push(fallbackPath); + tryPaths.push(defaultPath); + + for (const p of tryPaths) { + if (!fs.existsSync(p)) continue; try { - raw = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + raw = JSON.parse(fs.readFileSync(p, 'utf8')); + break; } catch (e) { - console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${fixturePath}): ${e.message}`); + console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${p}): ${e.message}`); } } - if (!raw && fs.existsSync(defaultPath)) { - try { - raw = JSON.parse(fs.readFileSync(defaultPath, 'utf8')); - } catch { /* ignore */ } - } if (!raw) { // echo fallback: 把 prompt 头 80 字符回显 raw = { diff --git a/scripts/phase3-verify.js b/scripts/phase3-verify.js new file mode 100644 index 0000000..7807dd3 --- /dev/null +++ b/scripts/phase3-verify.js @@ -0,0 +1,213 @@ +#!/usr/bin/env node +/** + * scripts/phase3-verify.js + * + * P3 L1 + L2 联动验收 (mock 模式 · 4 类分支): + * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 · sdd.updated SSE · changelog sdd_rev 条 + * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 · 仅 reset Unit + * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE + * ④ infra_flake → shipped · l2_rounds=0 · rerun 无 reset + * ⑤ Observability → bugs.ndjson · timeline.ndjson · stream.log SSE replay + * + * 用法: node scripts/phase3-verify.js + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); + +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); +require('module').globalPaths.push(serverNodeModules); +const express = (() => { + try { return require('express'); } + catch { + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); + } +})(); + +const pathResolver = require('../server/services/devloop/path-resolver'); +const sseBroker = require('../server/services/devloop/sse-broker'); +const bugTracker = require('../server/services/devloop/bug-tracker'); +const lc = require('../server/src/core/devloop/loop-controller'); + +const SCENARIO_BASE = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios'); + +const results = []; +function step(name, fn) { + return (async () => { + try { + const r = await fn(); + results.push({ name, ok: true, detail: r?.detail || '' }); + return r; + } catch (e) { + results.push({ name, ok: false, reason: e.message }); + throw e; + } + })(); +} + +function makeFakeRes(events) { + return { + setHeader() {}, flushHeaders() {}, end() {}, + on() {}, + write(line) { + const m = line.match(/^event: (.+)$/m); + if (m) events.push(m[1]); + }, + }; +} + +async function runScenario(scenario) { + pathResolver._resetForTesting(); + sseBroker._resetForTesting(); + const cid = 'c-phase3-' + scenario + '-' + Date.now().toString(36); + const dir = path.join(SCENARIO_BASE, scenario); + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'phase3 ' + scenario }); + await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir }); + + const events = []; + sseBroker.subscribe(proj.pid, makeFakeRes(events), {}); + + const result = await lc.runProject(proj.pid, { mockFixtureDir: dir }); + sseBroker.close(proj.pid); + return { pid: proj.pid, cid, result, events }; +} + +function cleanup(cid) { + try { + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); + } catch { /* ignore */ } +} + +async function main() { + const cidsToClean = []; + + // ① contract_mismatch + let cm; + try { + await step('① contract_mismatch → shipped (sdd_rev=2, l2_rounds=1)', async () => { + cm = await runScenario('contract_mismatch'); + cidsToClean.push(cm.cid); + if (cm.result.final_state !== 'shipped') throw new Error('not shipped: ' + cm.result.final_state); + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(cm.pid), 'utf8')); + if (u.l2_rounds !== 1) throw new Error('l2_rounds=' + u.l2_rounds); + if (u.sdd_rev !== '2') throw new Error('sdd_rev=' + u.sdd_rev); + return { detail: `pid=${cm.pid} sdd_rev=2 l2_rounds=1` }; + }); + + await step(' sdd.updated SSE event', () => { + if (!cm.events.includes('sdd.updated')) throw new Error('no sdd.updated in: ' + cm.events.join(',')); + return { detail: 'sdd.updated emitted' }; + }); + + await step(' changelog has sdd_rev=2 entry', () => { + const cl = pathResolver.resolveChangelog(cm.pid); + if (!fs.existsSync(cl)) throw new Error('changelog missing'); + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + if (!lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)) throw new Error('no sdd_rev=2 in changelog'); + return { detail: `${lines.length} changelog entries` }; + }); + + await step(' bug opened → fixed', () => { + const bugs = bugTracker.list(cm.pid, {}); + if (bugs.length !== 1) throw new Error('want 1 bug, got ' + bugs.length); + if (bugs[0].status !== 'fixed') throw new Error('bug.status=' + bugs[0].status); + return { detail: `${bugs[0].id} (${bugs[0].status})` }; + }); + } catch { /* errors recorded by step */ } + + // ② impl_bug + let ib; + try { + await step('② impl_bug → shipped · SDD unchanged · l2_rounds=0', async () => { + ib = await runScenario('impl_bug'); + cidsToClean.push(ib.cid); + if (ib.result.final_state !== 'shipped') throw new Error('state=' + ib.result.final_state); + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(ib.pid), 'utf8')); + if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds + ' (impl_bug should not burn)'); + if (u.sdd_rev !== '1') throw new Error('sdd_rev=' + u.sdd_rev + ' (should remain 1)'); + return { detail: `sdd_rev=1 l2_rounds=0` }; + }); + } catch {} + + // ③ spec_ambiguous + let sa; + try { + await step('③ spec_ambiguous → l2_blocked + human_intervention_needed SSE', async () => { + sa = await runScenario('spec_ambiguous'); + cidsToClean.push(sa.cid); + if (sa.result.final_state !== 'l2_blocked') throw new Error('state=' + sa.result.final_state); + if (!sa.events.includes('human_intervention_needed')) throw new Error('no human_intervention_needed SSE'); + return { detail: 'l2_blocked · SSE fired' }; + }); + } catch {} + + // ④ infra_flake + let inf; + try { + await step('④ infra_flake → shipped · l2_rounds=0 · rerun_integrator', async () => { + inf = await runScenario('infra_flake'); + cidsToClean.push(inf.cid); + if (inf.result.final_state !== 'shipped') throw new Error('state=' + inf.result.final_state); + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(inf.pid), 'utf8')); + if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds); + const actions = inf.result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); + if (!actions.some(a => /rerun_integrator/.test(a))) throw new Error('no rerun_integrator action'); + return { detail: 'shipped without budget burn' }; + }); + } catch {} + + // ⑤ Observability cross-check + try { + await step('⑤ Observability — timeline / bugs / changelog / stream.log', () => { + // 用 contract_mismatch 项目验 + if (!cm) throw new Error('cm scenario did not run'); + const tl = pathResolver.resolveTimelineLog(cm.pid); + const bg = pathResolver.resolveBugsLog(cm.pid); + const cl = pathResolver.resolveChangelog(cm.pid); + const sl = pathResolver.resolveStreamLog(cm.pid); + for (const [name, p] of [['timeline', tl], ['bugs', bg], ['changelog', cl], ['stream.log', sl]]) { + if (!fs.existsSync(p)) throw new Error(`${name} missing`); + } + const tlKinds = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean) + .map(l => { try { return JSON.parse(l).kind; } catch { return ''; }}); + for (const must of ['unit.state', 'integration.result', 'triage.result', 'sdd.updated']) { + // 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. + } + // 至少: project.created + agent.call (PM + Architect + L1 coder/tester + integrator + triager + architect-patch) + project.shipped + const expectedTimelineKinds = ['project.created', 'agent.call', 'project.shipped']; + const missing = expectedTimelineKinds.filter(k => !tlKinds.includes(k)); + if (missing.length) throw new Error('timeline missing kinds: ' + missing.join(',')); + return { detail: `timeline=${tlKinds.length}ev · bugs+changelog+stream.log present` }; + }); + + await step('⑥ SSE replay via Last-Event-ID', async () => { + if (!cm) throw new Error('cm scenario did not run'); + const replayed = []; + sseBroker.subscribe(cm.pid, makeFakeRes(replayed), { replayFromOffset: 3 }); + sseBroker.close(cm.pid); + // 至少应回放出 sdd.updated / project.shipped 之类 + if (replayed.length < 2) throw new Error('replay too few events: ' + replayed.length); + return { detail: `replayed ${replayed.length} events from id=3` }; + }); + } catch {} + + for (const c of cidsToClean) cleanup(c); + + let pass = 0, fail = 0; + console.log('\n┌─ DevLoop Phase 3 — L1+L2 联动 Acceptance ────────'); + for (const r of results) { + const tag = r.ok ? '✓' : '✗'; + console.log(`│ ${tag} ${r.name.padEnd(62)} ${r.ok ? r.detail : '— ' + r.reason}`); + r.ok ? pass++ : fail++; + } + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); + process.exit(fail ? 1 : 0); +} + +main().catch(e => { + console.error('\n[phase3-verify] fatal:', e); + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); + process.exit(1); +}); diff --git a/scripts/phase4-verify.js b/scripts/phase4-verify.js new file mode 100644 index 0000000..8ea12e6 --- /dev/null +++ b/scripts/phase4-verify.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +/** + * scripts/phase4-verify.js + * + * P4 L3 Human Feedback 验收 (mock 模式 · 端到端 HTTP): + * ① POST /projects → status=running, PRD rev 1 + * ② POST /run → ships, status=shipped + * ③ POST /feedback → PRD rev bumped to 2, status shipped→running, changelog prd_rev=2 + * ④ POST /run (re-run) → status=shipped again + * ⑤ POST /sign-off → status=shipped-final + * ⑥ POST /feedback (after sign-off) → 400 InvalidProjectStatusTransition + * ⑦ POST /sign-off (after sign-off) → 400 + * ⑧ 第二个项目: POST /abandon → status=abandoned + * ⑨ 第二个项目 POST /abandon 再次 → 400 + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); + +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); +require('module').globalPaths.push(serverNodeModules); +const express = (() => { + try { return require('express'); } + catch { + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); + } +})(); + +const pathResolver = require('../server/services/devloop/path-resolver'); +const sseBroker = require('../server/services/devloop/sse-broker'); +const devloopRouter = require('../server/routes/devloop'); + +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path'); + +const results = []; +function step(name, fn) { + return (async () => { + try { + const r = await fn(); + results.push({ name, ok: true, detail: r?.detail || '' }); + return r; + } catch (e) { + results.push({ name, ok: false, reason: e.message }); + throw e; + } + })(); +} + +async function startApp() { + const app = express(); + app.use(express.json()); + app.use('/api/devloop', devloopRouter); + return new Promise((resolve) => { + const srv = app.listen(0, '127.0.0.1', () => { + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); + }); + }); +} + +async function main() { + const { srv, base } = await startApp(); + const cid = 'c-phase4-' + Date.now().toString(36); + let pid1, pid2; + + try { + await step('① POST /projects → status=running', async () => { + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'TODO demo', mockFixtureDir: HAPPY_DIR }), + }); + if (r.status !== 200) throw new Error('status ' + r.status); + const j = await r.json(); + pid1 = j.pid; + const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json(); + if (s.status !== 'running') throw new Error('want running, got ' + s.status); + return { detail: `pid=${pid1} status=running prd_rev=${s.prd_rev}` }; + }); + + await step('② POST /run → status=shipped', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }), + }); + const j = await r.json(); + if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state); + const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json(); + if (s.status !== 'shipped') throw new Error('status=' + s.status); + return { detail: `final_state=shipped l2_rounds=${j.l2_rounds}` }; + }); + + await step('③ POST /feedback → PRD rev 1→2, status shipped→running', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'add toggle done', by: 'yibo', mockFixtureDir: HAPPY_DIR }), + }); + if (r.status !== 200) throw new Error('status ' + r.status); + const j = await r.json(); + if (j.prd_rev_after !== 2) throw new Error('prd_rev_after=' + j.prd_rev_after); + if (j.status !== 'running') throw new Error('status=' + j.status); + // changelog + const cl = pathResolver.resolveChangelog(pid1); + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + if (!lines.some(l => l.kind === 'prd_rev' && l.rev === 2)) throw new Error('no prd_rev=2 in changelog'); + return { detail: `rev 1→2 · status=running · changelog updated` }; + }); + + await step('④ POST /run (re-run) → status=shipped', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }), + }); + const j = await r.json(); + if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state); + return { detail: 'shipped again' }; + }); + + await step('⑤ POST /sign-off → status=shipped-final', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ by: 'yibo' }), + }); + if (r.status !== 200) throw new Error('status ' + r.status); + const j = await r.json(); + if (j.status !== 'shipped-final') throw new Error('status=' + j.status); + return { detail: 'status=shipped-final' }; + }); + + await step('⑥ POST /feedback (after sign-off) → 400', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'late change' }), + }); + if (r.status !== 400) throw new Error('want 400, got ' + r.status); + const j = await r.json(); + if (j.from !== 'shipped-final') throw new Error('from=' + j.from); + return { detail: '400 InvalidProjectStatusTransition (from=shipped-final)' }; + }); + + await step('⑦ POST /sign-off (after sign-off) → 400', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + if (r.status !== 400) throw new Error('want 400, got ' + r.status); + return { detail: '400 (terminal state)' }; + }); + + await step('⑧ 第二项目: POST /abandon → status=abandoned', async () => { + const cr = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'abandon test', mockFixtureDir: HAPPY_DIR }), + }); + pid2 = (await cr.json()).pid; + const ab = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ by: 'yibo', reason: 'mock test' }), + }); + if (ab.status !== 200) throw new Error('abandon status ' + ab.status); + const j = await ab.json(); + if (j.status !== 'abandoned') throw new Error('status=' + j.status); + return { detail: `pid=${pid2} status=abandoned` }; + }); + + await step('⑨ POST /abandon (after abandon) → 400', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + if (r.status !== 400) throw new Error('want 400, got ' + r.status); + return { detail: '400 (terminal state)' }; + }); + + } finally { + srv.close(); + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } + catch { /* ignore */ } + } + + let pass = 0, fail = 0; + console.log('\n┌─ DevLoop Phase 4 — L3 Human Feedback Acceptance ──'); + for (const r of results) { + const tag = r.ok ? '✓' : '✗'; + console.log(`│ ${tag} ${r.name.padEnd(58)} ${r.ok ? r.detail : '— ' + r.reason}`); + r.ok ? pass++ : fail++; + } + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); + process.exit(fail ? 1 : 0); +} + +main().catch(e => { + console.error('\n[phase4-verify] fatal:', e); + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); + process.exit(1); +}); diff --git a/scripts/phase5-verify.js b/scripts/phase5-verify.js new file mode 100644 index 0000000..7e8b815 --- /dev/null +++ b/scripts/phase5-verify.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +/** + * scripts/phase5-verify.js + * + * P5 Vue Dashboard 验收 (后端 + 静态产物): + * ① Vue 构建产物 (DevLoopDashboard + DevLoopOps + 8 子组件) 存在 + * ② GET /:pid/state 返带 status + units 字段 + * ③ GET /:pid/timeline 返事件列表 + * ④ GET /:pid/changelog 返条目列表 + * ⑤ GET /:pid/units/:unitId 返 unit + runs + coder_log_tail + * ⑥ GET /:pid/bugs 返 bug 列表 + * ⑦ SSE /:pid/stream 一秒内收到 ≥1 心跳/事件 + * + * 注: 这是后端 + 产物层面的验证;真正点击鼠标 UI 需要 vite dev 跑起来手测。 + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); + +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); +require('module').globalPaths.push(serverNodeModules); +const express = (() => { + try { return require('express'); } + catch { + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); + } +})(); + +const pathResolver = require('../server/services/devloop/path-resolver'); +const lc = require('../server/src/core/devloop/loop-controller'); +const devloopRouter = require('../server/routes/devloop'); + +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path'); +const WEB_DIST = path.resolve(__dirname, '../web/dist'); + +const results = []; +function step(name, fn) { + return (async () => { + try { + const r = await fn(); + results.push({ name, ok: true, detail: r?.detail || '' }); + return r; + } catch (e) { + results.push({ name, ok: false, reason: e.message }); + throw e; + } + })(); +} + +async function startApp() { + const app = express(); + app.use(express.json()); + app.use('/api/devloop', devloopRouter); + return new Promise((resolve) => { + const srv = app.listen(0, '127.0.0.1', () => { + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); + }); + }); +} + +async function main() { + await step('① Vue build 产物存在 (Dashboard + Ops + components)', () => { + const required = [ + 'index.html', 'assets', + ]; + for (const f of required) { + if (!fs.existsSync(path.join(WEB_DIST, f))) throw new Error(`web/dist/${f} missing — run npm run web:build`); + } + const assets = fs.readdirSync(path.join(WEB_DIST, 'assets')); + if (!assets.some(n => /DevLoopDashboard.*\.js$/.test(n))) throw new Error('DevLoopDashboard chunk not built'); + if (!assets.some(n => /DevLoopOps.*\.js$/.test(n))) throw new Error('DevLoopOps chunk not built'); + return { detail: `DevLoopDashboard + DevLoopOps chunks present in web/dist/assets` }; + }); + + const { srv, base } = await startApp(); + const cid = 'c-phase5-' + Date.now().toString(36); + let pid; + + try { + await step('② 准备项目: POST /projects → state', async () => { + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'TODO', mockFixtureDir: HAPPY_DIR }), + }); + pid = (await r.json()).pid; + const s = await (await fetch(`${base}/api/devloop/projects/${pid}/state`)).json(); + if (!s.status) throw new Error('state.status missing'); + if (!Array.isArray(s.units)) throw new Error('state.units missing'); + return { detail: `pid=${pid} status=${s.status} units=${s.units.length}` }; + }); + + await step('③ GET /:pid/timeline returns events', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline?limit=50`); + const j = await r.json(); + if (!Array.isArray(j.events)) throw new Error('events not array'); + if (j.events.length === 0) throw new Error('events empty'); + return { detail: `${j.events.length} events` }; + }); + + await step('④ GET /:pid/changelog returns entries', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`); + const j = await r.json(); + if (!Array.isArray(j.entries)) throw new Error('entries not array'); + return { detail: `${j.entries.length} entries` }; + }); + + await step('⑤ GET /:pid/units/todo-be returns unit + runs', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`); + if (r.status !== 200) throw new Error(`status ${r.status}`); + const j = await r.json(); + if (!j.unit) throw new Error('unit missing'); + if (!Array.isArray(j.runs)) throw new Error('runs not array'); + return { detail: `unit.status=${j.unit?.status} · ${j.runs.length} runs` }; + }); + + await step('⑥ GET /:pid/bugs returns bug list', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/bugs`); + const j = await r.json(); + if (!Array.isArray(j.bugs)) throw new Error('bugs not array'); + return { detail: `${j.bugs.length} bugs` }; + }); + + await step('⑦ SSE /:pid/stream delivers ≥1 event in 1s after emit', async () => { + // 用 http.get 拿原生 stream + const url = new URL(`${base}/api/devloop/projects/${pid}/stream`); + const events = await new Promise((resolve) => { + const out = []; + const req = http.get({ + hostname: url.hostname, port: url.port, path: url.pathname, + headers: { 'Accept': 'text/event-stream' }, + }, (res) => { + let buf = ''; + res.on('data', (chunk) => { + buf += chunk.toString(); + const matches = buf.matchAll(/^event: (.+)$/gm); + for (const m of matches) out.push(m[1]); + buf = buf.slice(buf.lastIndexOf('\n\n') + 2); + }); + res.on('error', () => resolve(out)); + }); + // Trigger an event so we have something to receive + setTimeout(async () => { + try { + await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'feedback for sse test', mockFixtureDir: HAPPY_DIR }), + }); + } catch { /* ignore */ } + }, 100); + setTimeout(() => { try { req.destroy(); } catch {} resolve(out); }, 1500); + }); + if (events.length === 0) throw new Error('no SSE events received'); + return { detail: `${events.length} events: ${events.slice(0, 4).join(', ')}` }; + }); + + } finally { + srv.close(); + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } + catch { /* ignore */ } + } + + let pass = 0, fail = 0; + console.log('\n┌─ DevLoop Phase 5 — Vue Dashboard Acceptance ──────'); + for (const r of results) { + const tag = r.ok ? '✓' : '✗'; + console.log(`│ ${tag} ${r.name.padEnd(60)} ${r.ok ? r.detail : '— ' + r.reason}`); + r.ok ? pass++ : fail++; + } + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); + process.exit(fail ? 1 : 0); +} + +main().catch(e => { + console.error('\n[phase5-verify] fatal:', e); + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); + process.exit(1); +}); diff --git a/scripts/real-pm-smoke.js b/scripts/real-pm-smoke.js new file mode 100644 index 0000000..c28fcbe --- /dev/null +++ b/scripts/real-pm-smoke.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * scripts/real-pm-smoke.js + * + * 最小验证: 真 claude CLI 跑一次 PM Agent,看 ClaudeCliProtocol + + * --append-system-prompt + 工作树 mtime diff 这条 real 链路是否 + * 端到端能用。预算 $0.50 cap。 + * + * 期望: + * ① claude 进程 spawn 成功 + * ② Agent 输出非空 (result_text 有内容) + * ③ files_written 至少 ≥ 1 (Agent 调用 Write 工具写了文件) + * ④ git 有新 commit + * + * NOTE: 真 LLM 输出不可控;PM 可能不完全照 prompt.md 跑全套 11 章节。 + * 只要它写了任意 prd/* 文件就算 wire-up 通。 + */ + +delete process.env.LEMONBOSS_CLAUDE_MODE; // 显式关闭 mock + +// 加载根 .env (含 CLAUDE_CODE_OAUTH_TOKEN); dotenv 装在 server/node_modules +const path = require('path'); +const dotenv = require(require.resolve('dotenv', { paths: [path.resolve(__dirname, '../server')] })); +dotenv.config({ path: path.join(__dirname, '../.env') }); + +const fs = require('fs'); + +const lc = require('../server/src/core/devloop/loop-controller'); +const pr = require('../server/services/devloop/path-resolver'); + +// --max-budget-usd 仅在 --print 模式可用,与我们的 stream-json 长连接互斥 +// → 用进程超时 (5min) + 一次性单 agent 调用兜底 +const HARD_TIMEOUT_MS = 5 * 60 * 1000; +const cleanup = []; + +async function main() { + console.log('\n┌─ Real Claude CLI · PM Smoke ────────────────────'); + console.log(`│ Hard timeout: 5min (no maxBudgetUsd — only --print mode)`); + console.log(`│ Model: claude default`); + console.log('└──────────────────────────────────────────────────\n'); + + const cid = 'c-real-pm-' + Date.now().toString(36); + cleanup.push(cid); + const proj = await lc.createProject({ + company_id: cid, + mode: 'sandbox', + idea: 'real PM smoke: minimal TODO API + Vue UI', + }); + console.log(`pid = ${proj.pid}`); + console.log(`root = ${pr.resolveProjectRoot(proj.pid)}\n`); + + const userPrompt = [ + 'Mode: init', + 'Requirement: A minimal TODO list — POST /todos (add) + GET /todos (list).', + '', + '约束 (please obey for this smoke test):', + ' • cwd 已经是项目根,直接相对路径写文件', + ' • 至少写 prd/index.html 一个最小 HTML stub (含

...

...

)', + ' • 不要尝试调 hash_contract / bootstrap_prd_site 等 LemonBoss 内部工具 — 它们在本 session 不可用', + ' • 不要执行 npm install / git / curl 等 Bash 命令', + ' • 完成后直接 stop, 不需要总结', + '', + '回 "DONE" 一词即可结束。', + ].join('\n'); + + console.log('▸ calling devloop-pm via real claude CLI...'); + console.log(` (system prompt: system/templates/devloop-pm/prompt.md, ~3KB)\n`); + + const startMs = Date.now(); + let chunkCount = 0; + const r = await Promise.race([ + lc.callAgent(proj.pid, 'devloop-pm', userPrompt, { + mock: false, + onChunk: () => { chunkCount++; }, + }), + new Promise((_, rej) => + setTimeout(() => rej(new Error('hard timeout 5min reached')), HARD_TIMEOUT_MS), + ), + ]); + const elapsedSec = Math.round((Date.now() - startMs) / 1000); + + console.log('\n▸ result:'); + console.log(` elapsed: ${elapsedSec}s`); + console.log(` stream chunks: ${chunkCount}`); + console.log(` result text: ${r.result_text.slice(0, 120).replace(/\n/g, ' ')}${r.result_text.length > 120 ? '…' : ''}`); + console.log(` files_written: ${r.files_written.length}`); + for (const f of r.files_written.slice(0, 10)) console.log(` · ${f}`); + if (r.files_written.length > 10) console.log(` · (+${r.files_written.length - 10} more)`); + console.log(` commit: ${r.commit?.sha?.slice(0, 7) || '(none)'}`); + + console.log('\n┌─ Wire-up status ────────────────────────────────'); + const checks = [ + ['process spawn', true], + ['stream chunks', chunkCount > 0], + ['result text', r.result_text.length > 0], + ['files written', r.files_written.length > 0], + ['git commit', !!r.commit?.sha], + ]; + let pass = 0; + for (const [k, ok] of checks) { + console.log(`│ ${ok ? '✓' : '✗'} ${k}`); + if (ok) pass++; + } + console.log(`└─ ${pass}/${checks.length} checks passed\n`); + + console.log('Artifacts retained for inspection:'); + console.log(` ${pr.resolveProjectRoot(proj.pid)}`); + console.log(` cleanup: rm -rf data/companies/${cid}\n`); + + process.exit(pass === checks.length ? 0 : 1); +} + +main().catch(e => { + console.error('\n[real-pm-smoke] FATAL:', e?.message || e); + console.error(e?.stack); + process.exit(1); +}); diff --git a/scripts/self-bootstrap.js b/scripts/self-bootstrap.js new file mode 100644 index 0000000..880911c --- /dev/null +++ b/scripts/self-bootstrap.js @@ -0,0 +1,254 @@ +#!/usr/bin/env node +/** + * scripts/self-bootstrap.js + * + * "自举乐趣" — 让 DevLoop 系统去开发 LemonBoss DevLoop 自己的下一个 feature。 + * + * 流程模拟一个人在 5173 端口前端发起的完整工作流: + * 1. POST /projects — 新需求:给 DevLoop 自己加 /restart 端点 + * 2. GET /state — 看 PRD/SDD 出来了 (status=running) + * 3. POST /run — 自动 L1 → L2 → ship (status=shipped) + * 4. GET /timeline / bugs / changelog / units — 检查所有产物 + * 5. POST /feedback — 反馈追加:支持 force=true 参数 (触发 PM incremental) + * 6. POST /run — 再 ship 一次 (PRD rev 1→2 后) + * 7. POST /sign-off — 锁定 shipped-final + * 8. POST /feedback — 验证锁定后 400 + * + * 当前 Agent 全部 mock 模式,产出是 fixture-driven 的演示数据。整个流程跑通即 + * 证明三层 Loop 协议 + 持久化 + 状态机 + UI 端点完整 wire-up。真切换到 real + * Claude CLI 时只需 LEMONBOSS_CLAUDE_MODE=real (或不设),Mock 路径关闭即可。 + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); + +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); +require('module').globalPaths.push(serverNodeModules); +const express = (() => { + try { return require('express'); } + catch { + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); + } +})(); + +const pathResolver = require('../server/services/devloop/path-resolver'); +const devloopRouter = require('../server/routes/devloop'); + +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path'); +const BOOTSTRAP_IDEA = `Add POST /api/devloop/projects/:pid/restart endpoint: + - resets a 'shipped-final' or 'abandoned' project back to 'draft' + - re-runs runDocsPhase from scratch with the original requirement + - returns { ok, pid, status, idea } + - rejects non-terminal states with 400`; + +const PIPELINE_LOG = []; +function logStep(name, info = '') { + const ts = new Date().toTimeString().slice(0, 8); + console.log(`[${ts}] ▸ ${name}${info ? ' ' + info : ''}`); + PIPELINE_LOG.push({ ts, name, info }); +} + +function logArtifact(name, value) { + console.log(` · ${name.padEnd(20)} ${value}`); +} + +async function startApp() { + const app = express(); + app.use(express.json()); + app.use('/api/devloop', devloopRouter); + return new Promise(r => { + const s = app.listen(0, '127.0.0.1', () => { + r({ srv: s, base: `http://127.0.0.1:${s.address().port}` }); + }); + }); +} + +async function J(url, opts = {}) { + const r = await fetch(url, opts); + let j; try { j = await r.json(); } catch { j = null; } + return { status: r.status, body: j }; +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════════════╗'); + console.log('║ DevLoop · 自举测试 (Self-Bootstrap) ║'); + console.log('║ 让系统去开发自己的下一个 feature ║'); + console.log('╚══════════════════════════════════════════════════════════════════╝\n'); + + console.log('Meta-Requirement:'); + for (const line of BOOTSTRAP_IDEA.split('\n')) console.log(' ' + line); + console.log(''); + + const { srv, base } = await startApp(); + const cid = 'c-self-' + Date.now().toString(36); + let pid; + + try { + // ─── Step 1: 提交新需求 ─── + logStep('Step 1 / POST /projects (new requirement)'); + { + const r = await J(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + company_id: cid, + name: 'devloop-self-restart', + requirement: BOOTSTRAP_IDEA, + mockFixtureDir: HAPPY_DIR, + }), + }); + if (r.status !== 200) throw new Error('POST /projects: ' + r.status + ' / ' + JSON.stringify(r.body)); + pid = r.body.pid; + logArtifact('pid', pid); + logArtifact('pm files', r.body.pm.files_written); + logArtifact('architect files', r.body.architect.files_written); + logArtifact('PM commit', r.body.pm.commit?.slice(0, 7)); + logArtifact('Arch commit', r.body.architect.commit?.slice(0, 7)); + } + + // ─── Step 2: 读 state ─── + logStep('Step 2 / GET /state'); + { + const r = await J(`${base}/api/devloop/projects/${pid}/state`); + logArtifact('status', r.body.status); + logArtifact('prd_rev', r.body.prd_rev); + logArtifact('sdd_rev', r.body.sdd_rev); + logArtifact('units', r.body.units?.map(u => `${u.id}:${u.status}`).join(', ')); + logArtifact('idea', r.body.project?.idea_summary?.slice(0, 60)); + } + + // ─── Step 3: 跑 L1 → L2 → ship ─── + logStep('Step 3 / POST /run (runProject — drives L1 + L2 + ship)'); + { + const r = await J(`${base}/api/devloop/projects/${pid}/run`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }), + }); + if (r.status !== 200) throw new Error('POST /run: ' + r.status); + logArtifact('final_state', r.body.final_state); + logArtifact('l2_rounds', r.body.l2_rounds); + logArtifact('run_id', r.body.run_id); + logArtifact('L1+L2 phases', r.body.history?.length); + } + + // ─── Step 4: 4 个 Ops 子端点全部检查 ─── + logStep('Step 4 / GET /bugs · /timeline · /changelog · /units/todo-be'); + { + const bugs = await J(`${base}/api/devloop/projects/${pid}/bugs`); + const tl = await J(`${base}/api/devloop/projects/${pid}/timeline?limit=20`); + const cl = await J(`${base}/api/devloop/projects/${pid}/changelog`); + const unit = await J(`${base}/api/devloop/projects/${pid}/units/todo-be`); + logArtifact('bugs total', `${bugs.body.total} (statuses: ${bugs.body.bugs.map(b => b.status).join(', ')})`); + logArtifact('timeline events', tl.body.total); + logArtifact('changelog entries', cl.body.total); + logArtifact('todo-be runs', unit.body.runs?.length); + logArtifact('todo-be status', unit.body.unit?.status); + } + + // ─── Step 5: human feedback ─── + logStep('Step 5 / POST /feedback (iterate the spec)'); + { + const r = await J(`${base}/api/devloop/projects/${pid}/feedback`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: 'Also add ?from= query param so /restart can rewind to a specific PRD rev. Default current rev.', + by: 'yibo', + mockFixtureDir: HAPPY_DIR, + }), + }); + if (r.status !== 200) throw new Error('POST /feedback: ' + r.status); + logArtifact('prd_rev', `${r.body.prd_rev_before} → ${r.body.prd_rev_after}`); + logArtifact('status', r.body.status); + logArtifact('files', r.body.files_written?.length); + } + + // ─── Step 6: 重新跑一遍 ─── + logStep('Step 6 / POST /run (re-ship after feedback)'); + { + const r = await J(`${base}/api/devloop/projects/${pid}/run`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }), + }); + logArtifact('final_state', r.body.final_state); + } + + // ─── Step 7: sign off ─── + logStep('Step 7 / POST /sign-off (human approves)'); + { + const r = await J(`${base}/api/devloop/projects/${pid}/sign-off`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ by: 'yibo' }), + }); + logArtifact('status', r.body.status); + } + + // ─── Step 8: lock-out check ─── + logStep('Step 8 / POST /feedback after sign-off (should reject)'); + { + const r = await J(`${base}/api/devloop/projects/${pid}/feedback`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'too late' }), + }); + logArtifact('status code', r.status); + logArtifact('error', r.body.error); + logArtifact('from', r.body.from); + } + + // ─── 总结 ─── + console.log('\n╭─ Self-Bootstrap Summary ─────────────────────────────────────────╮'); + const root = pathResolver.resolveProjectRoot(pid); + console.log(`│ project root: ${root}`); + const inventory = [ + 'project.json', + 'prd/index.html', + 'prd/feature/feature-tree.html', + 'sdd/index.html', + 'sdd/mvcp/control.html', + 'units.json', + 'integration/spec.json', + 'integration/runs/run-mock-baseline/result.json', + 'timeline.ndjson', + 'changelog.ndjson', + 'bugs.ndjson', + 'stream.log', + 'history/prd-rev-001.html', + 'history/prd-rev-002.html', + 'history/sdd-rev-001.html', + '.git', + ]; + for (const f of inventory) { + const p = path.join(root, f); + const exists = fs.existsSync(p); + console.log(`│ ${exists ? '✓' : '✗'} ${f}`); + } + + // git log + console.log('│'); + console.log(`│ git commits (auto-generated by LoopController.commitAfterAgent):`); + const { execSync } = require('child_process'); + try { + const log = execSync(`git -C "${root}" log --oneline -20`, { encoding: 'utf8' }).trim().split('\n'); + for (const line of log) console.log(`│ ${line}`); + } catch (e) { console.log(`│ (git log failed: ${e.message})`); } + + console.log('╰──────────────────────────────────────────────────────────────────╯\n'); + + console.log('Result: DevLoop 成功地为自己"开发"了一个新 feature, 通过完整 L1+L2+L3 pipeline'); + console.log('完成 → ship → human iterate (PRD rev 2) → ship → sign-off → 拒绝后续修改。\n'); + console.log('生产模式接 real Claude CLI: 删 LEMONBOSS_CLAUDE_MODE=mock 即可,流程不动。\n'); + + } finally { + srv.close(); + // 保留产物供查阅,不清理 + if (pid) { + console.log(`(artifacts retained at: data/companies/${cid}/projects/${pid}/)`); + console.log(`若要清理: rm -rf data/companies/${cid}`); + } + } +} + +main().catch(e => { + console.error('\n[self-bootstrap] FATAL:', e); + process.exit(1); +}); diff --git a/server/agent-server.js b/server/agent-server.js index e2c5e40..5502f43 100644 --- a/server/agent-server.js +++ b/server/agent-server.js @@ -67,6 +67,7 @@ app.use('/api/company', require('./routes/company')); app.use('/api/goals', require('./routes/goals')); app.use('/api/tasks', require('./routes/tasks')); app.use('/api/devloop', require('./routes/devloop')); +app.use('/public', require('express').static(require('path').join(__dirname, 'public'))); // Health app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' })); diff --git a/server/index.js b/server/index.js index e23c94d..3064fe9 100644 --- a/server/index.js +++ b/server/index.js @@ -94,6 +94,7 @@ app.use('/api/goals', require('./routes/goals')); app.use('/api/company', require('./routes/company')); app.use('/api/tasks', require('./routes/tasks')); app.use('/api/devloop', require('./routes/devloop')); +app.use('/public', require('express').static(require('path').join(__dirname, 'public'))); // ─── 系统端点 ─── app.get('/api/health', (req, res) => res.json({ diff --git a/server/public/devloop-status.html b/server/public/devloop-status.html new file mode 100644 index 0000000..b1cd1bb --- /dev/null +++ b/server/public/devloop-status.html @@ -0,0 +1,186 @@ + + + + +DevLoop · Project Status + + + +
+

DevLoop · Project Status

+
+ + +
+
+ +
+ + + + + + diff --git a/server/routes/devloop.js b/server/routes/devloop.js index 6da93a5..8318cbe 100644 --- a/server/routes/devloop.js +++ b/server/routes/devloop.js @@ -20,6 +20,18 @@ const router = express.Router(); const loopController = require('../src/core/devloop/loop-controller'); const pathResolver = require('../services/devloop/path-resolver'); +const sseBroker = require('../services/devloop/sse-broker'); +const bugTracker = require('../services/devloop/bug-tracker'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const multer = require('multer'); + +// 单次 intake 上传:内存解析后我们自己写盘到项目目录。10MB / 文件,10 文件上限。 +const intakeUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024, files: 10 }, +}); const h = (fn) => async (req, res) => { try { await fn(req, res); } @@ -31,25 +43,49 @@ const h = (fn) => async (req, res) => { // POST /api/devloop/projects router.post('/projects', h(async (req, res) => { - const { company_id, requirement, mode, target_path, mock, name } = req.body || {}; + const { company_id, requirement, mode, target_path, mock, name, mockFixtureDir } = req.body || {}; if (!company_id) { return res.status(400).json({ error: 'missing field: company_id' }); } if (!requirement || typeof requirement !== 'string') { return res.status(400).json({ error: 'missing field: requirement' }); } + if ((mode === 'linked-new' || mode === 'linked-existing') && !target_path) { + return res.status(400).json({ error: `missing field: target_path (required for ${mode})` }); + } + if (mode && !['sandbox', 'linked-new', 'linked-existing'].includes(mode)) { + return res.status(400).json({ error: `invalid mode: ${mode} (must be sandbox | linked-new | linked-existing)` }); + } - // 1. 创建项目 (sandbox 默认) - const proj = await loopController.createProject({ - company_id, - mode: mode || 'sandbox', - target_path, - idea: name ? `${name}: ${requirement}` : requirement, - }); + // 1. 创建项目 (sandbox 默认 / linked-new 用 target_path) + let proj; + try { + proj = await loopController.createProject({ + company_id, + mode: mode || 'sandbox', + target_path, + idea: name ? `${name}: ${requirement}` : requirement, + }); + } catch (e) { + // 用户输入级错误返 400;其他保留 500 + if (e.code === 'SENSITIVE_PATH' + || e.code === 'DIRTY_WORKTREE' + || /target_path|parent dir|not empty|must be absolute|does not exist|not a directory|already contains|uncommitted changes/i.test(e.message)) { + return res.status(400).json({ error: e.message }); + } + throw e; + } + + // 2. 把 name 作为一等字段写入 project.json (供列表 / 改名共用) + if (name) { + try { pathResolver.patchProjectMeta(proj.pid, { name }); } + catch (e) { console.warn('[DevLoop] failed to set name:', e.message); } + } - // 2. PM → Architect (mock 模式) + // 3. PM → Architect (mock 模式) — mockFixtureDir 可选,P3/P4 scenario 测试用 const phase = await loopController.runDocsPhase(proj.pid, requirement, { mock: mock !== false, + mockFixtureDir, }); res.json({ @@ -70,44 +106,306 @@ router.post('/projects', h(async (req, res) => { }); })); -// GET /api/devloop/projects -router.get('/projects', h(async (_req, res) => { - const projects = pathResolver.listProjects(); +// GET /api/devloop/projects?include_archived=true +router.get('/projects', h(async (req, res) => { + const includeArchived = String(req.query.include_archived || '') === 'true'; + const projects = pathResolver.listProjects({ includeArchived }); res.json({ projects }); })); -// POST /api/devloop/projects/:pid/run?only= -// P2: only= 跑单 unit;不带 only → 501 (P3 才补全 runProject) +// GET /api/devloop/fs/browse?path=/abs/path +// 列出指定绝对路径下的子目录(供新建项目时的"指定本机目录"选择用)。 +// - 不传 path → 用 $HOME +// - 拒绝 sensitive 路径 +// - 仅返回目录,不返回文件 +router.get('/fs/browse', h(async (req, res) => { + let p = req.query.path ? String(req.query.path) : os.homedir(); + if (!path.isAbsolute(p)) { + return res.status(400).json({ error: 'path must be absolute' }); + } + p = path.resolve(p); + if (pathResolver.isSensitivePath(p)) { + return res.status(403).json({ error: `sensitive_path: ${p} is blacklisted` }); + } + if (!fs.existsSync(p)) { + return res.status(404).json({ error: `not found: ${p}` }); + } + const stat = fs.statSync(p); + if (!stat.isDirectory()) { + return res.status(400).json({ error: `not a directory: ${p}` }); + } + let entries = []; + try { + entries = fs.readdirSync(p, { withFileTypes: true }) + .filter(d => d.isDirectory() && !d.name.startsWith('.')) + .map(d => { + const abs = path.join(p, d.name); + let is_empty = false; + try { is_empty = fs.readdirSync(abs).length === 0; } catch { /* permission denied 当作非空 */ } + return { name: d.name, abs_path: abs, is_empty }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + } catch (e) { + return res.status(403).json({ error: `cannot read: ${e.message}` }); + } + const parent = path.dirname(p); + res.json({ + path: p, + parent: parent === p ? null : parent, // 根目录 parent === p + home: os.homedir(), + entries, + }); +})); + +// PATCH /api/devloop/projects/:pid +// body: { name?, idea_summary? } 仅允许这两个字段(status 走 sign-off / abandon / feedback) +router.patch('/projects/:pid', h(async (req, res) => { + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + const allowed = ['name', 'idea_summary']; + const patch = {}; + for (const k of allowed) { + if (req.body && Object.prototype.hasOwnProperty.call(req.body, k)) { + const v = req.body[k]; + if (typeof v !== 'string') return res.status(400).json({ error: `${k} must be string` }); + patch[k] = v.slice(0, 500); + } + } + if (Object.keys(patch).length === 0) { + return res.status(400).json({ error: 'no editable fields in body (allowed: name, idea_summary)' }); + } + const meta = pathResolver.patchProjectMeta(pid, patch); + res.json({ ok: true, pid, project: meta }); +})); + +// DELETE /api/devloop/projects/:pid?hard=true +// 默认软删 (archived=true);hard=true 时递归删目录 (sandbox) 或解注册 (linked) +router.delete('/projects/:pid', h(async (req, res) => { + const { pid } = req.params; + const hard = String(req.query.hard || '') === 'true'; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + if (hard) { + const r = pathResolver.hardDeleteProject(pid); + return res.json({ ok: true, pid, hard: true, ...r }); + } + const meta = pathResolver.patchProjectMeta(pid, { + archived: true, + archived_at: new Date().toISOString(), + }); + res.json({ ok: true, pid, hard: false, archived: true, project: meta }); +})); + +// POST /api/devloop/projects/:pid/restore — 把 archived=true 的项目恢复 +router.post('/projects/:pid/restore', h(async (req, res) => { + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + const meta = pathResolver.patchProjectMeta(pid, { archived: false, archived_at: null }); + res.json({ ok: true, pid, archived: false, project: meta }); +})); + +// POST /api/devloop/projects/:pid/run[?only=] [body: { mockFixtureDir? }] +// - 不带 only: 跑完整 runProject (P3) +// - only=: 单 unit drill-down (P2 兼容) router.post('/projects/:pid/run', h(async (req, res) => { const { pid } = req.params; const { only } = req.query; - if (!only) { - return res.status(501).json({ error: 'P2 only supports ?only=; full project run lands in P3' }); - } + const { mockFixtureDir } = req.body || {}; try { - const result = await loopController.runUnit(pid, only, { mock: true }); - res.json({ - ok: result.ok, - pid, unit_id: only, - attempts: result.attempts, + if (only) { + const result = await loopController.runUnit(pid, only, { mock: true, mockFixtureDir }); + return res.json({ + ok: result.ok, pid, unit_id: only, + attempts: result.attempts, final_state: result.final_state, + history: result.history.map(h => ({ + agent: h.agent, attempt: h.attempt, + ok: h.ok, files: h.files?.length || 0, + commit: h.commit || null, + })), + }); + } + // 完整 runProject + const result = await loopController.runProject(pid, { mock: true, mockFixtureDir }); + return res.json({ + ok: result.ok, pid, final_state: result.final_state, - history: result.history.map(h => ({ - agent: h.agent, attempt: h.attempt, - ok: h.ok, files: h.files?.length || 0, - commit: h.commit || null, - })), + l2_rounds: result.l2_rounds, + run_id: result.run_id || null, + reason: result.reason || null, + history: result.history, }); } catch (e) { if (/Unknown project/.test(e.message)) { return res.status(404).json({ error: 'unknown project: ' + pid }); } if (/not found in project/.test(e.message)) { - return res.status(404).json({ error: 'unknown unit: ' + only }); + return res.status(404).json({ error: 'unknown unit: ' + (only || '?') }); } throw e; } })); +// GET /api/devloop/projects/:pid/stream — SSE +// client may send Last-Event-ID header → broker replays from there +router.get('/projects/:pid/stream', (req, res) => { + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders?.(); + + const lastEventId = req.headers['last-event-id'] || req.query.lastEventId || null; + sseBroker.subscribe(pid, res, { + replayFromOffset: lastEventId != null ? lastEventId : undefined, + }); +}); + +// P4: POST /api/devloop/projects/:pid/sign-off +// body: { by?, note? } +// 400 if status !== 'shipped' +router.post('/projects/:pid/sign-off', h(async (req, res) => { + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + try { + const meta = await loopController.signOff(pid, req.body || {}); + res.json({ ok: true, pid, status: meta.status }); + } catch (e) { + if (e.name === 'InvalidProjectStatusTransition') { + return res.status(400).json({ error: e.message, from: e.from, to: e.to }); + } + throw e; + } +})); + +// P4: POST /api/devloop/projects/:pid/abandon +// body: { by?, reason?, note? } +router.post('/projects/:pid/abandon', h(async (req, res) => { + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + try { + const meta = await loopController.abandon(pid, req.body || {}); + res.json({ ok: true, pid, status: meta.status }); + } catch (e) { + if (e.name === 'InvalidProjectStatusTransition') { + return res.status(400).json({ error: e.message, from: e.from, to: e.to }); + } + throw e; + } +})); + +// P4: POST /api/devloop/projects/:pid/feedback +// body: { text (required), by?, mockFixtureDir? } +// 400 if status ∈ {shipped-final, abandoned} +router.post('/projects/:pid/feedback', h(async (req, res) => { + const { pid } = req.params; + const { text, by, mockFixtureDir } = req.body || {}; + if (!text) return res.status(400).json({ error: 'missing field: text' }); + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + try { + const r = await loopController.submitFeedback(pid, text, { by, mockFixtureDir }); + res.json(r); + } catch (e) { + if (e.name === 'InvalidProjectStatusTransition') { + return res.status(400).json({ error: e.message, from: e.from, to: e.to }); + } + throw e; + } +})); + +// GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be +router.get('/projects/:pid/bugs', h(async (req, res) => { + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + const filter = {}; + if (req.query.status) filter.status = String(req.query.status).split(','); + if (req.query.unit_id) filter.unit_id = req.query.unit_id; + if (req.query.severity) filter.severity = String(req.query.severity).split(','); + const bugs = bugTracker.list(pid, filter); + res.json({ bugs, total: bugs.length }); +})); + +// P5: GET /api/devloop/projects/:pid/timeline?limit=200 +// 读 timeline.ndjson 最近 N 条 (按时间倒序),给 LoopLogView 用 +router.get('/projects/:pid/timeline', h(async (req, res) => { + const fs = require('fs'); + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + const limit = Math.max(1, Math.min(2000, parseInt(req.query.limit, 10) || 200)); + const p = pathResolver.resolveTimelineLog(pid); + if (!fs.existsSync(p)) return res.json({ events: [], total: 0 }); + const lines = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean); + const events = lines.slice(-limit).reverse().map(l => { + try { return JSON.parse(l); } catch { return null; } + }).filter(Boolean); + res.json({ events, total: events.length }); +})); + +// P5: GET /api/devloop/projects/:pid/changelog +// 读 changelog.ndjson 全部 (按时间倒序) +router.get('/projects/:pid/changelog', h(async (req, res) => { + const fs = require('fs'); + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + const p = pathResolver.resolveChangelog(pid); + if (!fs.existsSync(p)) return res.json({ entries: [], total: 0 }); + const entries = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean) + .map(l => { try { return JSON.parse(l); } catch { return null; }}).filter(Boolean) + .reverse(); + res.json({ entries, total: entries.length }); +})); + +// P5: GET /api/devloop/projects/:pid/units/:unitId +// 返回 unit 元数据 + 最近 N 次 runs (Tester result.json) +router.get('/projects/:pid/units/:unitId', h(async (req, res) => { + const fs = require('fs'); const path = require('path'); + const { pid, unitId } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + const unitDir = pathResolver.resolveUnitDir(pid, unitId); + if (!fs.existsSync(unitDir)) return res.status(404).json({ error: 'unknown unit: ' + unitId }); + // 从 units.json 找 unit 元数据 + let unitMeta = null; + try { + const uj = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); + unitMeta = (uj.units || []).find(u => u.id === unitId) || null; + } catch { /* ignore */ } + // runs + const runsDir = path.join(unitDir, 'runs'); + let runs = []; + if (fs.existsSync(runsDir)) { + const ids = fs.readdirSync(runsDir).filter(n => { + try { return fs.statSync(path.join(runsDir, n)).isDirectory(); } catch { return false; } + }).sort().reverse().slice(0, 3); + for (const id of ids) { + const rp = path.join(runsDir, id, 'result.json'); + let r = null; + if (fs.existsSync(rp)) { + try { r = JSON.parse(fs.readFileSync(rp, 'utf8')); } catch {} + } + runs.push({ run_id: id, result: r }); + } + } + // coder.log 最后 1KB 节选 + let coderLog = null; + const clp = path.join(unitDir, 'coder.log'); + if (fs.existsSync(clp)) { + const raw = fs.readFileSync(clp, 'utf8'); + coderLog = raw.length > 1024 ? raw.slice(-1024) : raw; + } + res.json({ unit: unitMeta, runs, coder_log_tail: coderLog }); +})); + // GET /api/devloop/projects/:pid/state router.get('/projects/:pid/state', h(async (req, res) => { try { @@ -121,4 +419,178 @@ router.get('/projects/:pid/state', h(async (req, res) => { } })); +// ───────────────────────────────────────────────────────────────────────────── +// P6 · Requirements 列表 + 新增需求 intake (file upload / dir scan / chat draft) +// ───────────────────────────────────────────────────────────────────────────── + +// GET /api/devloop/projects/:pid/requirements +// 返回需求列表:rev=1 为初始需求(取自 project.json.idea_summary), +// rev>=2 来自 changelog.ndjson 中 kind=prd_rev 的条目。 +router.get('/projects/:pid/requirements', h(async (req, res) => { + const { pid } = req.params; + let projectMeta; + try { + projectMeta = JSON.parse(fs.readFileSync(pathResolver.resolveProjectJson(pid), 'utf8')); + } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + + const items = []; + // rev 1: 初始需求 + items.push({ + rev: 1, + ts: projectMeta.created_at, + by: 'init', + trigger: 'project_create', + text: projectMeta.idea_summary || '', + }); + + // rev>=2: from changelog + const clp = pathResolver.resolveChangelog(pid); + if (fs.existsSync(clp)) { + const lines = fs.readFileSync(clp, 'utf8').trim().split('\n').filter(Boolean); + for (const l of lines) { + let e; try { e = JSON.parse(l); } catch { continue; } + if (e.kind !== 'prd_rev') continue; + items.push({ + rev: e.rev, + ts: e.ts, + by: e.by || 'human', + trigger: e.trigger || 'human_feedback', + text: e.diff?.feedback_excerpt || e.summary || '', + }); + } + } + + // 派生 status: 最新一条 = current(用项目状态),其余 = older + items.sort((a, b) => a.rev - b.rev); + const latestRev = items.length ? items[items.length - 1].rev : 0; + const projectStatus = projectMeta.status || 'draft'; + for (const it of items) { + it.status = it.rev === latestRev ? projectStatus : 'older'; + it.is_current = it.rev === latestRev; + } + + res.json({ + pid, + project_status: projectStatus, + current_rev: latestRev, + items: items.reverse(), // 倒序:最新在前 + }); +})); + +// POST /api/devloop/projects/:pid/intake/files +// multipart/form-data: files=...; (.md / .txt / .html 才被采纳,其它跳过) +// 保存到 projects/{pid}/intake/{ts}/,返回每个文件的提取文本。 +router.post('/projects/:pid/intake/files', + intakeUpload.array('files', 10), + h(async (req, res) => { + const { pid } = req.params; + let root; + try { root = pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const intakeDir = path.join(root, 'intake', ts); + fs.mkdirSync(intakeDir, { recursive: true }); + + const files = req.files || []; + const results = []; + for (const f of files) { + const ext = path.extname(f.originalname).toLowerCase(); + if (!['.md', '.txt', '.html', '.htm'].includes(ext)) { + results.push({ name: f.originalname, skipped: true, reason: `unsupported ext: ${ext}` }); + continue; + } + const safeName = f.originalname.replace(/[/\\]/g, '_').slice(0, 200); + const abs = path.join(intakeDir, safeName); + fs.writeFileSync(abs, f.buffer); + let text = f.buffer.toString('utf8'); + if (ext === '.html' || ext === '.htm') { + // 极简 strip:去脚本/样式块 + 拆 tag + text = text + .replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + results.push({ + name: f.originalname, + size: f.size, + saved_to: path.relative(root, abs), + text, + }); + } + res.json({ ok: true, intake_dir: path.relative(root, intakeDir), files: results }); + }) +); + +// POST /api/devloop/projects/:pid/intake/scan-dir +// body: { path: '/abs/dir', max_files?: 50, max_bytes?: 1MB } +// 递归找 md/txt/html,读文本。受 sensitive 黑名单拦截。 +router.post('/projects/:pid/intake/scan-dir', h(async (req, res) => { + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + + const target = req.body?.path && String(req.body.path); + const maxFiles = Math.min(200, parseInt(req.body?.max_files, 10) || 50); + const maxBytes = Math.min(5 * 1024 * 1024, parseInt(req.body?.max_bytes, 10) || 1024 * 1024); + if (!target || !path.isAbsolute(target)) { + return res.status(400).json({ error: 'path required (absolute)' }); + } + if (pathResolver.isSensitivePath(target)) { + return res.status(403).json({ error: `sensitive_path: ${target}` }); + } + if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) { + return res.status(404).json({ error: `not a directory: ${target}` }); + } + + const found = []; + let totalBytes = 0; + let truncated = false; + function walk(dir, depth) { + if (depth > 6 || found.length >= maxFiles || truncated) return; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + if (e.name.startsWith('.') || e.name === 'node_modules') continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) { walk(full, depth + 1); continue; } + if (!e.isFile()) continue; + const ext = path.extname(e.name).toLowerCase(); + if (!['.md', '.txt', '.html', '.htm'].includes(ext)) continue; + const stat = fs.statSync(full); + if (totalBytes + stat.size > maxBytes) { truncated = true; return; } + let text; + try { text = fs.readFileSync(full, 'utf8'); } catch { continue; } + if (ext === '.html' || ext === '.htm') { + text = text.replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + } + totalBytes += stat.size; + found.push({ path: full, rel: path.relative(target, full), size: stat.size, text }); + if (found.length >= maxFiles) { truncated = true; return; } + } + } + walk(target, 0); + res.json({ ok: true, scanned_root: target, total_bytes: totalBytes, truncated, files: found }); +})); + +// POST /api/devloop/projects/:pid/draft-chat +// body: { messages: [{role:'user'|'assistant', content:string}, ...] } +// 返回 { role:'assistant', content:string } +// 占位实现:原样回显最后一条 user 消息 + 提示。LLM 接入后此函数体内换实现即可。 +router.post('/projects/:pid/draft-chat', h(async (req, res) => { + const { pid } = req.params; + try { pathResolver.resolveProjectRoot(pid); } + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } + const messages = Array.isArray(req.body?.messages) ? req.body.messages : []; + const lastUser = [...messages].reverse().find(m => m.role === 'user'); + const echoText = lastUser?.content?.toString().trim() || '(空消息)'; + const reply = `已收到你的描述(共 ${echoText.length} 字符):\n\n> ${echoText.slice(0, 200)}${echoText.length > 200 ? '…' : ''}\n\n(LLM 多轮调试暂未接入。请继续追加细节、上传文档、或扫描代码目录,然后点"提交需求"一次性发给 PM Agent。)`; + res.json({ role: 'assistant', content: reply, llm_stub: true }); +})); + module.exports = router; diff --git a/server/services/devloop/git-vault.js b/server/services/devloop/git-vault.js index c7e6f4c..0edee1d 100644 --- a/server/services/devloop/git-vault.js +++ b/server/services/devloop/git-vault.js @@ -142,12 +142,11 @@ async function commit(pid, spec) { } if (!spec.summary) throw new Error('commit: summary required'); - // 1. 加 files(默认 -A,但受 .gitignore 控制) - const addArgs = ['add', '--']; + // 1. 加 files(显式列表则限定路径;否则 -A 全量。-A 受 .gitignore 控制) if (Array.isArray(spec.files) && spec.files.length) { - addArgs.push(...spec.files); + const rAdd = await _run(root, ['add', '--', ...spec.files]); + if (!rAdd.ok) throw new Error(`git add files failed: ${rAdd.stderr}`); } else { - // -A:把所有变更(包括删除)加进 index const rA = await _run(root, ['add', '-A']); if (!rA.ok) throw new Error(`git add -A failed: ${rA.stderr}`); } diff --git a/server/services/devloop/path-resolver.js b/server/services/devloop/path-resolver.js index f76d365..6bbec10 100644 --- a/server/services/devloop/path-resolver.js +++ b/server/services/devloop/path-resolver.js @@ -259,9 +259,55 @@ function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { }; saveLinkedRegistry(reg); } else if (mode === 'linked-existing') { - const e = new Error('linked-existing mode is v2; not supported in v1'); - e.code = 'NOT_IMPLEMENTED'; - throw e; + if (!target_path) throw new Error('target_path required for linked-existing'); + if (!path.isAbsolute(target_path)) { + throw new Error('target_path must be absolute'); + } + if (isSensitivePath(target_path)) { + const e = new Error(`sensitive_path: ${target_path} is in blacklist`); + e.code = 'SENSITIVE_PATH'; + throw e; + } + if (!fs.existsSync(target_path)) { + throw new Error(`target_path does not exist: ${target_path} (use linked-new for empty/new dir)`); + } + const stat = fs.statSync(target_path); + if (!stat.isDirectory()) { + throw new Error(`target_path is not a directory: ${target_path}`); + } + // 已有 DevLoop 项目(目录里就有 project.json)→ 拒绝重复挂载 + if (fs.existsSync(path.join(target_path, 'project.json'))) { + throw new Error(`target_path already contains a DevLoop project.json: ${target_path}`); + } + // 已有 .git → 必须 working tree 干净(避免后续 Agent 的 git add -A 把用户脏改动扫进 DevLoop commit) + if (fs.existsSync(path.join(target_path, '.git'))) { + const { execFileSync } = require('child_process'); + let porcelain; + try { + porcelain = execFileSync('git', ['status', '--porcelain'], { cwd: target_path, encoding: 'utf8' }); + } catch (e) { + throw new Error(`failed to check git status at ${target_path}: ${e.message}`); + } + if (porcelain.trim().length > 0) { + const lines = porcelain.trim().split('\n').slice(0, 8).join('\n'); + const err = new Error( + `target_path has uncommitted changes; please commit or stash first.\n` + + `Reason: DevLoop agents auto-commit with git add -A and would sweep your dirty files into agent commits.\n` + + `--- git status --porcelain (truncated) ---\n${lines}` + ); + err.code = 'DIRTY_WORKTREE'; + throw err; + } + } + root_path = path.resolve(target_path); + const reg = loadLinkedRegistry(); + reg[pid] = { + root_path, + mode: 'linked-existing', + company_id, + registered_at: new Date().toISOString(), + }; + saveLinkedRegistry(reg); } else { throw new Error(`unknown mode: ${mode}`); } @@ -282,9 +328,76 @@ function unregisterLinkedProject(pid) { if (cache) cache.delete(pid); } -function listProjects() { +/** + * 列出全部项目。读 project.json 注入 name/created_at/status/archived/idea_summary。 + * 默认过滤已 archived 的项目;includeArchived=true 时全返。 + */ +function listProjects({ includeArchived = false } = {}) { + ensureInit(); + const out = []; + for (const [pid, entry] of cache.entries()) { + const meta = _readMetaSafe(entry.root_path); + if (!includeArchived && meta && meta.archived) continue; + out.push({ + pid, + ...entry, + name: meta?.name || meta?.idea_summary || pid, + idea_summary: meta?.idea_summary || '', + status: meta?.status || 'draft', + created_at: meta?.created_at || null, + archived: !!meta?.archived, + archived_at: meta?.archived_at || null, + }); + } + // 倒序:最近创建的在前 + out.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); + return out; +} + +function _readMetaSafe(rootPath) { + try { + const p = path.join(rootPath, 'project.json'); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')); + } catch { return null; } +} + +/** + * 修改 project.json 的字段(name / idea_summary / archived 等)。 + * 调用方负责传合法字段集;不做 FSM 校验(status 用 loop-controller._setProjectStatus)。 + */ +function patchProjectMeta(pid, patch = {}) { + ensureInit(); + const root = resolveProjectRoot(pid); + const metaPath = path.join(root, 'project.json'); + if (!fs.existsSync(metaPath)) throw new Error(`project.json missing for ${pid}`); + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + Object.assign(meta, patch); + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2)); + return meta; +} + +/** + * 硬删项目:sandbox 模式下递归删 root_path;linked 模式只解除注册(不删用户自有目录)。 + * 返回 { mode, removed_disk: boolean } + */ +function hardDeleteProject(pid) { ensureInit(); - return Array.from(cache.entries()).map(([pid, entry]) => ({ pid, ...entry })); + const entry = cache.get(pid); + if (!entry) throw new Error(`Unknown project: ${pid}`); + let removed_disk = false; + if (entry.mode === 'sandbox') { + if (fs.existsSync(entry.root_path)) { + fs.rmSync(entry.root_path, { recursive: true, force: true }); + removed_disk = true; + } + } else if (entry.mode === 'linked-new' || entry.mode === 'linked-existing') { + // 只解注册,不动用户自己挑的目录 + const reg = loadLinkedRegistry(); + if (reg[pid]) { delete reg[pid]; saveLinkedRegistry(reg); } + } + cache.delete(pid); + return { mode: entry.mode, removed_disk }; } /** 测试用:重置缓存,强制下次重新扫描 */ @@ -316,6 +429,8 @@ module.exports = { allocateProjectRoot, unregisterLinkedProject, listProjects, + patchProjectMeta, + hardDeleteProject, isSensitivePath, _resetForTesting, // 常量 diff --git a/server/src/core/devloop/loop-controller.js b/server/src/core/devloop/loop-controller.js index c4092d9..62321eb 100644 --- a/server/src/core/devloop/loop-controller.js +++ b/server/src/core/devloop/loop-controller.js @@ -24,8 +24,17 @@ const pathResolver = require('../../../services/devloop/path-resolver'); const gitVault = require('../../../services/devloop/git-vault'); const timelineLogger = require('../../../services/devloop/timeline-logger'); const unitRegistry = require('../../../services/devloop/unit-registry'); +const bugTracker = require('../../../services/devloop/bug-tracker'); +const changelogWriter = require('../../../services/devloop/changelog-writer'); +const sseBroker = require('../../../services/devloop/sse-broker'); +const docStore = require('../../../services/devloop/doc-store'); const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol'); +// 注入依赖:让 unit-registry / bug-tracker 内的 SSE/Timeline 调用都能落地 +unitRegistry.attachSseBroker(sseBroker); +bugTracker.attachSseBroker(sseBroker); +bugTracker.attachTimelineLogger(timelineLogger); + const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates'); /** @@ -42,13 +51,15 @@ async function createProject({ company_id, mode = 'sandbox', target_path, idea = company_id, mode, target_path, }); - // project.json + // project.json (含 P4 status FSM 字段) const meta = { pid, company_id, mode, created_at: new Date().toISOString(), idea_summary: (idea || '').slice(0, 200), + status: 'draft', // FSM: draft → running → shipped → shipped-final | abandoned + status_updated_at: new Date().toISOString(), }; fs.writeFileSync( pathResolver.resolveProjectJson(pid), @@ -74,10 +85,12 @@ async function createProject({ company_id, mode = 'sandbox', target_path, idea = } // 把 project.json + timeline.ndjson 一并提交进第二个 commit (工作树留干净) + // 显式 files 列表:避免 linked-existing 模式把用户已有未提交文件拖进我们的 chore commit const firstCommit = await gitVault.commit(pid, { kind: 'chore', summary: `chore: project metadata + first timeline entry for ${pid}`, author: { name: 'devloop-system', email: 'devloop@local' }, + files: ['project.json', 'timeline.ndjson'], }); return { pid, root_path, mode, first_commit: firstCommit }; @@ -147,19 +160,32 @@ async function commitAfterAgent(pid, agentName, summary = '') { async function callAgent(pid, agentName, userPrompt, opts = {}) { const projectRoot = pathResolver.resolveProjectRoot(pid); + const useMock = opts.mock !== false; + // 1. 系统提示 (可选 — 没文件就空串) const promptPath = path.join(TEMPLATES_DIR, agentName, 'prompt.md'); const systemPrompt = fs.existsSync(promptPath) ? fs.readFileSync(promptPath, 'utf8') : ''; - const fullPrompt = systemPrompt - ? `${systemPrompt}\n\n## User Task\n${userPrompt}` + // mock 模式: 把 system + user 拼一起喂 fixture (fixture 不在意); + // real 模式: systemPrompt 走 --append-system-prompt, userPrompt 走 stdin。 + const fullPrompt = useMock + ? (systemPrompt ? `${systemPrompt}\n\n## User Task\n${userPrompt}` : userPrompt) : userPrompt; - // 2. 启动 protocol + // 2. real 模式: 先快照工作树 mtime,Agent 跑完后做 diff + const preSnapshot = useMock ? null : _snapshotMtimes(projectRoot); + + // 3. 启动 protocol const protocol = new ClaudeCliProtocol( { name: agentName }, - { mock: opts.mock !== false }, // 默认 mock=true (P1 阶段) + { + mock: useMock, + mockFixtureDir: opts.mockFixtureDir, + // real 模式专属: + systemPrompt: useMock ? undefined : systemPrompt, + maxBudgetUsd: opts.maxBudgetUsd, // 调用方可设上限 + }, ); let resultParams = null; @@ -183,36 +209,44 @@ async function callAgent(pid, agentName, userPrompt, opts = {}) { protocol.disconnect(); } - // 3. 落盘 fixture.files (path 相对 projectRoot) + // 4. 落盘 — mock 用 fixture.files, real 用 fs diff const filesWritten = []; - for (const f of filesFromMock) { - if (!f?.path || typeof f.content !== 'string') continue; - if (path.isAbsolute(f.path) || f.path.includes('..')) { - throw new Error(`unsafe mock file path: ${f.path}`); + if (useMock) { + for (const f of filesFromMock) { + if (!f?.path || typeof f.content !== 'string') continue; + if (path.isAbsolute(f.path) || f.path.includes('..')) { + throw new Error(`unsafe mock file path: ${f.path}`); + } + const abs = path.join(projectRoot, f.path); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + let content = f.content; + if (f.path === 'units.json' || f.path.endsWith('/units.json')) { + try { + const u = JSON.parse(content); + u.project_id = pid; + content = JSON.stringify(u, null, 2); + } catch { /* leave as-is, downstream will fail validation */ } + } + fs.writeFileSync(abs, content); + filesWritten.push(f.path); } - const abs = path.join(projectRoot, f.path); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - let content = f.content; - // 把 fixture 里的占位符替换成实际值 ({{pid}} / "_placeholder_") - if (f.path === 'units.json' || f.path.endsWith('/units.json')) { - try { - const u = JSON.parse(content); - u.project_id = pid; - content = JSON.stringify(u, null, 2); - } catch { /* leave as-is, downstream will fail validation */ } + } else { + // real 模式: 扫工作树找新增/修改文件 (相对项目根) + const postSnapshot = _snapshotMtimes(projectRoot); + for (const [rel, mtime] of postSnapshot) { + const before = preSnapshot.get(rel); + if (before === undefined || before < mtime) filesWritten.push(rel); } - fs.writeFileSync(abs, content); - filesWritten.push(f.path); } - // 4. timeline + commit + // 5. timeline + commit try { timelineLogger.append(pid, { source: agentName, kind: 'agent.call', - ref: resultParams?.session_id || 'mock', - summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (mock)`, - payload: { files_written: filesWritten.length, mock: !!resultParams?.mock }, + ref: resultParams?.session_id || (useMock ? 'mock' : 'real'), + summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (${useMock ? 'mock' : 'real'})`, + payload: { files_written: filesWritten.length, mock: !!resultParams?.mock, real: !useMock }, }); } catch { /* swallow */ } @@ -246,17 +280,26 @@ async function runDocsPhase(pid, requirement, opts = {}) { throw new Error('requirement (string) required'); } + // Docs 是项目第一步 — reset mock 计数,确保 PM/Architect 从 seq 0 开始 + if (opts.resetMockSequence !== false) { + ClaudeCliProtocol.resetMockState(); + } + const onChunk = opts.onChunk ? (agent) => (text) => opts.onChunk(agent, text) : null; + const passthrough = { + mock: opts.mock !== false, + mockFixtureDir: opts.mockFixtureDir, + }; const pm = await callAgent(pid, 'devloop-pm', `Requirement:\n${requirement}\n\nmode: init`, - { onChunk: onChunk ? onChunk('devloop-pm') : undefined }); + { ...passthrough, onChunk: onChunk ? onChunk('devloop-pm') : undefined }); const architect = await callAgent(pid, 'devloop-architect', `Read prd/ from project root and produce SDD + units.json + integration/spec.json.\nRequirement context:\n${requirement}`, - { onChunk: onChunk ? onChunk('devloop-architect') : undefined }); + { ...passthrough, onChunk: onChunk ? onChunk('devloop-architect') : undefined }); // timeline 总结 try { @@ -272,6 +315,9 @@ async function runDocsPhase(pid, requirement, opts = {}) { }); } catch { /* swallow */ } + // P4: 项目进入 running 态(完成 docs 后,准备进 L1/L2) + _setProjectStatus(pid, 'running', { reason: 'docs_complete' }); + return { ok: true, pm, architect }; } @@ -290,7 +336,10 @@ function getProjectState(pid) { const projectJson = pathResolver.resolveProjectJson(pid); if (fs.existsSync(projectJson)) { - try { out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); } catch {} + try { + out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); + out.status = out.project.status || 'unknown'; + } catch {} } const unitsJson = pathResolver.resolveUnitsJson(pid); @@ -464,11 +513,487 @@ function _findLatestTesterResult(pid, unitId) { } } +// ────────────────────────────────────────────────────────────── +// P3 · L2 Integration Loop · runProject +// ────────────────────────────────────────────────────────────── + +/** + * Topological wave grouping (Kahn). Returns string[][] — units per wave. + */ +function _groupUnitsByWave(units) { + const groups = []; + const remaining = new Set(units.map(u => u.id)); + const deps = new Map(units.map(u => [u.id, (u.depends_on || []).slice()])); + while (remaining.size > 0) { + const wave = []; + for (const id of remaining) { + if (deps.get(id).every(d => !remaining.has(d))) wave.push(id); + } + if (wave.length === 0) { + throw new Error('cycle in units.json (no unit has all dependencies cleared)'); + } + for (const id of wave) remaining.delete(id); + groups.push(wave); + } + return groups; +} + +/** find latest run dir under integration/runs/ */ +function _findLatestIntegrationRunDir(pid) { + const dir = path.join(pathResolver.resolveProjectRoot(pid), 'integration', 'runs'); + if (!fs.existsSync(dir)) return null; + const ids = fs.readdirSync(dir).filter(n => fs.statSync(path.join(dir, n)).isDirectory()); + if (!ids.length) return null; + ids.sort(); + return ids[ids.length - 1]; +} + +function _readJsonSafe(p) { + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } +} + +/** + * runProject — P3 主循环 (mock 模式)。 + * + * 流程: + * while not done and not blocked: + * 1. 按 DAG wave 跑 L1 (per-unit runUnit) + * 2. 若 l2GateOpen → 跑 Integrator + * 3. result.ok=true → 项目完成 + * 4. result.ok=false → Triager → dispatch: + * - contract_mismatch + conf≥0.5 → Architect(patch) + reset affected (架构师自己改 units.json) + * → l2_rounds++; 超 l2_budget → l2_exhausted + * - impl_bug + conf≥0.5 → reset affected (unit-registry.reset) → 不烧预算 + * - infra_flake → 直接重跑 (不 reset, 不烧预算) + * - spec_ambiguous OR conf<0.5 → l2_blocked + SSE human_intervention_needed + * + * @param {string} pid + * @param {object} opts + * - mockFixtureDir?: string (P3 scenario 测试用) + * - maxRetries?: number (safety net 防 mock 死循环;默认 10) + * @returns { ok, pid, final_state: 'shipped'|'l2_blocked'|'l2_exhausted', l2_rounds, history } + */ +async function runProject(pid, opts = {}) { + // 注意: 不 resetMockState — runProject 是 runDocsPhase 的延续, + // 计数继续往下走才能让 Architect 第二次调用进入 patch 分支 (seq[1])。 + // 调用方若想全新状态,在调 runDocsPhase 前自己 reset。 + const projectRoot = pathResolver.resolveProjectRoot(pid); + const history = []; + const callOpts = { + mock: opts.mock !== false, + mockFixtureDir: opts.mockFixtureDir, + resetMockSequence: false, // 一次 runProject 只 reset 一次 + }; + const safety = opts.maxRetries || 10; + let iterations = 0; + + // SSE start + sseBroker.emit(pid, 'project.run_started', { pid, ts: Date.now() }); + + while (iterations < safety) { + iterations++; + + // ── (1) L1 wave 调度: 找所有 pending units 按 DAG 分波 ──────── + const allUnits = unitRegistry.list(pid); + const pendingUnits = allUnits.filter(u => u.status === 'pending'); + if (pendingUnits.length > 0) { + const waves = _groupUnitsByWave(pendingUnits); + for (let w = 0; w < waves.length; w++) { + const wave = waves[w]; + sseBroker.emit(pid, 'l1.wave_start', { wave_index: w, units: wave }); + // 串行跑(mock 同步快,不需并行) + for (const uid of wave) { + const r = await runUnit(pid, uid, { ...callOpts }); + history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts }); + if (!r.ok) { + sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts }); + _safeSetStatus(pid, 'l2_blocked', { reason: `unit ${uid} blocked` }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` }; + } + } + } + } + + // ── (2) L2 gate ──────────────────────────────────────────── + if (!unitRegistry.l2GateOpen(pid)) { + sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' }); + _safeSetStatus(pid, 'l2_blocked', { reason: 'l2GateClosed' }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' }; + } + + // ── (3) 跑 Integrator ────────────────────────────────────── + sseBroker.emit(pid, 'integration.run_start', { l2_round: _l2Rounds(pid) }); + const integ = await callAgent(pid, 'devloop-integrator', + `Mode: ${_l2Rounds(pid) === 0 ? 'run' : 'retry'}\nRun integration tests per integration/spec.json. Spawn be units on free ports, execute cases, write result.json.`, + callOpts); + const runId = _findLatestIntegrationRunDir(pid); + const intResult = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'result.json')) : null; + history.push({ phase: 'L2.integrate', l2_round: _l2Rounds(pid), run_id: runId, ok: intResult?.ok, crashed: intResult?.crashed }); + sseBroker.emit(pid, 'integration.result', { run_id: runId, ok: !!intResult?.ok, crashed: !!intResult?.crashed }); + + // ── (4) 成功 → 项目完成 ──────────────────────────────────── + if (intResult?.ok === true) { + // 若有未关 bug → 转 fixed + const openBugs = bugTracker.list(pid, { status: ['open', 'fixing'] }); + for (const b of openBugs) { + try { bugTracker.transition(pid, b.id, { to: 'fixed', by: 'devloop-integrator', note: 'integration passed' }); } + catch { /* skip invalid transitions */ } + } + sseBroker.emit(pid, 'project.shipped', { l2_rounds: _l2Rounds(pid), run_id: runId }); + try { + timelineLogger.append(pid, { + source: 'system', kind: 'project.shipped', ref: runId, + summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`, + }); + } catch {} + _setProjectStatus(pid, 'shipped', { run_id: runId }); + return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId }; + } + + // ── (5) 失败 → 开 bug + 调 Triager ────────────────────────── + const bug = bugTracker.open(pid, { + source: 'integration_test', + run_id: runId, + title: `integration ${runId} failed${intResult?.crashed ? ' (crashed)' : ''}`, + severity: intResult?.crashed ? 'high' : 'medium', + category: 'unknown', + detail: (intResult?.failures?.[0]?.stderr_excerpt || intResult?.crash_reason || 'unknown').slice(0, 200), + affected_units: intResult?.failures?.[0]?.involves || [], + by: 'devloop-integrator', + }); + bugTracker.transition(pid, bug.id, { to: 'fixing', by: 'devloop-triager', note: 'triage in progress' }); + + await callAgent(pid, 'devloop-triager', + `Mode: triage\nrun_id: ${runId}\nAnalyze integration/runs/${runId}/result.json + process.log; write triage.json.`, + callOpts); + const triage = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'triage.json')) : null; + history.push({ phase: 'L2.triage', l2_round: _l2Rounds(pid), run_id: runId, category: triage?.category, confidence: triage?.confidence }); + sseBroker.emit(pid, 'triage.result', { + run_id: runId, + category: triage?.category, + confidence: triage?.confidence, + affected_units: triage?.affected_units || [], + }); + + if (!triage) { + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId }); + _safeSetStatus(pid, 'l2_blocked', { reason: 'triage_missing' }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' }; + } + + // ── (6) 分支调度 ──────────────────────────────────────────── + const lowConfidence = (triage.confidence ?? 0) < 0.5; + const category = triage.category; + + if (lowConfidence || category === 'spec_ambiguous') { + sseBroker.emit(pid, 'human_intervention_needed', { + reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous', + run_id: runId, category, confidence: triage.confidence, + }); + _safeSetStatus(pid, 'l2_blocked', { reason: lowConfidence ? 'low_confidence' : 'spec_ambiguous' }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` }; + } + + if (category === 'infra_flake') { + // 不 reset 不烧预算,直接下一轮重跑 Integrator + history.push({ phase: 'L2.dispatch', action: 'rerun_integrator (infra_flake)' }); + continue; + } + + if (category === 'impl_bug') { + // reset affected units (不烧 l2_budget) + const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u)); + if (affected.length === 0) { + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId }); + _safeSetStatus(pid, 'l2_blocked', { reason: 'impl_bug_no_affected' }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history }; + } + unitRegistry.reset(pid, affected); + history.push({ phase: 'L2.dispatch', action: 'reset_units (impl_bug)', units: affected }); + sseBroker.emit(pid, 'units.reset', { units: affected, cause: 'impl_bug' }); + continue; + } + + if (category === 'contract_mismatch') { + // 调 Architect patch (fixture 自己写 units.json,会把 affected_units 标 pending + l2_rounds++) + // 预检 l2_budget + const currentRounds = _l2Rounds(pid); + const l2Budget = _l2Budget(pid); + if (currentRounds + 1 > l2Budget) { + sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget }); + _safeSetStatus(pid, 'l2_exhausted', { reason: 'budget_exceeded' }); + return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history }; + } + const oldSddRev = _readSddRev(pid); + await callAgent(pid, 'devloop-architect', + `Mode: patch\nrun_id: ${runId}\nApply triage.proposed_sdd_patch; bump units.json contract_hash for affected units; increment l2_rounds.`, + callOpts); + const newSddRev = _readSddRev(pid); + if (newSddRev > oldSddRev) { + try { + changelogWriter.append(pid, { + kind: 'sdd_rev', + rev: newSddRev, + by: 'devloop-architect', + trigger: 'l2_triage', + summary: `SDD patched from rev ${oldSddRev} to ${newSddRev} per triage ${category}`, + diff: { changed_sections: [triage.proposed_sdd_patch?.target_section || '(unknown)'] }, + }); + } catch { /* swallow */ } + sseBroker.emit(pid, 'sdd.updated', { rev: newSddRev, summary: `patch applied (round ${_l2Rounds(pid)})` }); + } + history.push({ phase: 'L2.dispatch', action: 'sdd_patch + reset', sdd_rev: newSddRev }); + continue; + } + + // Unknown category + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category }); + _safeSetStatus(pid, 'l2_blocked', { reason: 'unknown_category' }); + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` }; + } + + // safety net 超过 + sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations }); + _safeSetStatus(pid, 'l2_exhausted', { reason: 'safety_net' }); + return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history }; +} + +/** Status set that swallows invalid-transition errors (already in target state etc.) */ +function _safeSetStatus(pid, to, extra) { + try { return _setProjectStatus(pid, to, extra); } + catch (e) { + if (e.name === 'InvalidProjectStatusTransition') return null; + throw e; + } +} + +function _l2Rounds(pid) { + const u = docStore.readUnits(pid); + return u?.l2_rounds || 0; +} +function _l2Budget(pid) { + const u = docStore.readUnits(pid); + return u?.l2_budget || 3; +} +function _readSddRev(pid) { + const root = pathResolver.resolveProjectRoot(pid); + const idx = path.join(root, 'sdd', 'index.html'); + if (!fs.existsSync(idx)) return 0; + const m = fs.readFileSync(idx, 'utf8').match(/]*data-doc="sdd"[^>]*data-rev="(\d+)"/); + if (m) return parseInt(m[1], 10); + // fallback: try mvcp/control.html (where patch goes) + const ctrl = path.join(root, 'sdd', 'mvcp', 'control.html'); + if (fs.existsSync(ctrl)) { + const m2 = fs.readFileSync(ctrl, 'utf8').match(/]*data-doc="sdd"[^>]*data-rev="(\d+)"/); + if (m2) return parseInt(m2[1], 10); + } + return 1; +} + +// ────────────────────────────────────────────────────────────── +// P4 · L3 Human Feedback · status FSM + signOff / abandon / feedback +// ────────────────────────────────────────────────────────────── + +const STATUS_TRANSITIONS = { + draft: ['running', 'abandoned'], + running: ['shipped', 'l2_blocked', 'l2_exhausted', 'abandoned'], + l2_blocked: ['running', 'abandoned'], + l2_exhausted: ['running', 'abandoned'], + shipped: ['running', 'shipped-final', 'abandoned'], + 'shipped-final': [], // 终态 + abandoned: [], // 终态 +}; + +class InvalidProjectStatusTransition extends Error { + constructor(from, to) { + super(`Invalid project status transition: ${from} → ${to}`); + this.name = 'InvalidProjectStatusTransition'; + this.from = from; + this.to = to; + } +} + +function _readProjectMeta(pid) { + const p = pathResolver.resolveProjectJson(pid); + if (!fs.existsSync(p)) throw new Error(`project.json missing for ${pid}`); + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function _writeProjectMeta(pid, meta) { + fs.writeFileSync(pathResolver.resolveProjectJson(pid), JSON.stringify(meta, null, 2)); +} + +/** + * 改 project.json.status,做 FSM 校验 + 发 SSE + 写 timeline。 + * 非法转换抛 InvalidProjectStatusTransition (供路由层映射 400)。 + */ +function _setProjectStatus(pid, to, extra = {}) { + const meta = _readProjectMeta(pid); + const from = meta.status || 'draft'; + if (from === to) return meta; // idempotent + const allowed = STATUS_TRANSITIONS[from] || []; + if (!allowed.includes(to)) { + throw new InvalidProjectStatusTransition(from, to); + } + meta.status = to; + meta.status_updated_at = new Date().toISOString(); + if (extra.note) meta.status_note = extra.note; + _writeProjectMeta(pid, meta); + + try { + sseBroker.emit(pid, 'project.status', { from, to, ...extra }); + timelineLogger.append(pid, { + source: extra.by || 'system', + kind: 'project.status', + ref: pid, + summary: `Project status ${from} → ${to}${extra.reason ? ` (${extra.reason})` : ''}`, + payload: { from, to, ...extra }, + }); + } catch { /* swallow */ } + return meta; +} + +/** + * Sign Off — 把已 shipped 的项目锁为 shipped-final。 + * 之后再发 feedback 会被拒绝。 + */ +async function signOff(pid, opts = {}) { + const meta = _readProjectMeta(pid); + if (meta.status !== 'shipped') { + throw new InvalidProjectStatusTransition(meta.status || 'unknown', 'shipped-final'); + } + _setProjectStatus(pid, 'shipped-final', { by: opts.by || 'human', note: opts.note || 'signed off' }); + // sign-off commit (留 git 痕) + try { + await gitVault.commit(pid, { + kind: 'chore', + summary: `chore: sign-off project ${pid}`, + author: { name: opts.by || 'human', email: 'human@devloop.local' }, + }); + } catch { /* swallow if nothing to commit */ } + return _readProjectMeta(pid); +} + +/** + * Abandon — 任意状态 → abandoned。发 SSE close 帧关闭所有订阅。 + */ +async function abandon(pid, opts = {}) { + const meta = _readProjectMeta(pid); + if (meta.status === 'shipped-final' || meta.status === 'abandoned') { + throw new InvalidProjectStatusTransition(meta.status, 'abandoned'); + } + _setProjectStatus(pid, 'abandoned', { by: opts.by || 'human', reason: opts.reason || 'manual abandon', note: opts.note }); + try { + await gitVault.commit(pid, { + kind: 'chore', + summary: `chore: abandon project ${pid}${opts.reason ? ' · ' + opts.reason : ''}`, + author: { name: opts.by || 'human', email: 'human@devloop.local' }, + }); + } catch { /* swallow */ } + // 关闭所有 SSE 订阅(close 帧) + try { sseBroker.close(pid); } catch { /* ignore */ } + return _readProjectMeta(pid); +} + +/** + * Submit human feedback — 触发 PM incremental 模式。 + * 仅当 status ∈ {shipped, running, l2_blocked, l2_exhausted} 时允许。 + * shipped-final / abandoned → 拒绝。 + */ +async function submitFeedback(pid, text, opts = {}) { + if (!text || typeof text !== 'string') { + throw new Error('feedback text required'); + } + const meta = _readProjectMeta(pid); + if (meta.status === 'shipped-final' || meta.status === 'abandoned') { + throw new InvalidProjectStatusTransition(meta.status, 'feedback'); + } + + const prdRevBefore = _readPrdRev(pid); + const pm = await callAgent(pid, 'devloop-pm', + `Mode: incremental\nFeedback from human (${opts.by || 'user'}):\n${text}\n\nAppend a new section under prd/feature/feature-tree.html (or appropriate chapter), bump prd_rev.`, + { mock: opts.mock !== false, mockFixtureDir: opts.mockFixtureDir }, + ); + const prdRevAfter = _readPrdRev(pid); + + // 若 PRD rev bumped → 写 changelog + if (prdRevAfter > prdRevBefore) { + try { + changelogWriter.append(pid, { + kind: 'prd_rev', + rev: prdRevAfter, + by: opts.by || 'human', + trigger: 'human_feedback', + summary: `PRD bumped to rev ${prdRevAfter} via human feedback`, + diff: { feedback_excerpt: text.slice(0, 120) }, + }); + sseBroker.emit(pid, 'prd.updated', { rev: prdRevAfter, summary: 'human feedback applied' }); + } catch { /* swallow */ } + } + + // 若当前是 shipped, 改回 running (人类反馈触发新一轮) + if (meta.status === 'shipped') { + _setProjectStatus(pid, 'running', { reason: 'human_feedback' }); + } + + return { + ok: true, + pid, + files_written: pm.files_written, + prd_rev_before: prdRevBefore, + prd_rev_after: prdRevAfter, + status: _readProjectMeta(pid).status, + }; +} + +/** + * 扫项目根递归收集 (relPath → mtimeMs) Map。 + * 忽略 .git / node_modules / agent-state/snapshots (高频写) 子树。 + * real 模式 callAgent 用这个做 pre/post 快照,自动识别 Agent 写出来的新文件。 + */ +function _snapshotMtimes(root) { + const map = new Map(); + const IGNORE_DIRS = new Set(['.git', 'node_modules', '.cache', '.tmp']); + function walk(dir, prefix) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + if (IGNORE_DIRS.has(e.name)) continue; + const rel = prefix ? `${prefix}/${e.name}` : e.name; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + walk(full, rel); + } else if (e.isFile()) { + try { + map.set(rel, fs.statSync(full).mtimeMs); + } catch { /* ignore */ } + } + } + } + walk(root, ''); + return map; +} + +function _readPrdRev(pid) { + const root = pathResolver.resolveProjectRoot(pid); + const idx = path.join(root, 'prd', 'index.html'); + if (!fs.existsSync(idx)) return 0; + const m = fs.readFileSync(idx, 'utf8').match(/]*data-doc="prd"[^>]*data-rev="(\d+)"/); + return m ? parseInt(m[1], 10) : 1; +} + module.exports = { createProject, commitAfterAgent, callAgent, runDocsPhase, runUnit, + runProject, getProjectState, + // P4 + signOff, + abandon, + submitFeedback, + InvalidProjectStatusTransition, }; diff --git a/server/tests/devloop/p4-l3-feedback.test.js b/server/tests/devloop/p4-l3-feedback.test.js new file mode 100644 index 0000000..a5b1b5a --- /dev/null +++ b/server/tests/devloop/p4-l3-feedback.test.js @@ -0,0 +1,241 @@ +/** + * P4 · L3 Human Feedback tests + * + * 覆盖: + * - project.status FSM: draft → running → shipped → shipped-final + * - submitFeedback bumps prd_rev + shipped → running + * - signOff requires status=shipped + * - abandon any active state → abandoned (emits close) + * - InvalidProjectStatusTransition error mapping + * - PM incremental fixture variant runs on 2nd call + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-p4-')); +process.env.HOME = TEST_HOME; + +const pathResolver = require('../../services/devloop/path-resolver'); +const sseBroker = require('../../services/devloop/sse-broker'); +const lc = require('../../src/core/devloop/loop-controller'); + +const HAPPY_DIR = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios/happy_path'); + +async function fresh(idea = 'p4 test') { + pathResolver._resetForTesting(); + sseBroker._resetForTesting(); + const cid = 'c-p4-' + Math.random().toString(36).slice(2, 7); + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea }); + return { pid: proj.pid, cid }; +} + +function cleanup(cid) { + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } + catch { /* ignore */ } +} + +describe('P4 · L3 status FSM', () => { + describe('happy-path: draft → running → shipped → shipped-final', () => { + let pid, cid; + beforeAll(async () => { ({ pid, cid } = await fresh()); }); + afterAll(() => cleanup(cid)); + + it('starts as draft', () => { + expect(lc.getProjectState(pid).status).toBe('draft'); + }); + + it('runDocsPhase → running', async () => { + await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR }); + expect(lc.getProjectState(pid).status).toBe('running'); + }); + + it('runProject → shipped', async () => { + const r = await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR }); + expect(r.final_state).toBe('shipped'); + expect(lc.getProjectState(pid).status).toBe('shipped'); + }); + + it('feedback bumps prd_rev 1 → 2 and flips status shipped → running', async () => { + const r = await lc.submitFeedback(pid, 'add toggle done feature', { mockFixtureDir: HAPPY_DIR }); + expect(r.prd_rev_after).toBe(2); + expect(r.prd_rev_before).toBe(1); + expect(r.status).toBe('running'); + }); + + it('changelog has prd_rev=2 entry', () => { + const cl = pathResolver.resolveChangelog(pid); + expect(fs.existsSync(cl)).toBe(true); + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + expect(lines.some(l => l.kind === 'prd_rev' && l.rev === 2)).toBe(true); + }); + + it('runProject again → shipped', async () => { + await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR }); + expect(lc.getProjectState(pid).status).toBe('shipped'); + }); + + it('signOff → shipped-final', async () => { + const meta = await lc.signOff(pid, { by: 'yibo' }); + expect(meta.status).toBe('shipped-final'); + }); + + it('feedback after sign-off is rejected', async () => { + await expect(lc.submitFeedback(pid, 'late change', { mockFixtureDir: HAPPY_DIR })) + .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); + }); + + it('signOff after sign-off is rejected', async () => { + await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); + }); + }); + + describe('abandon path', () => { + let pid, cid; + beforeAll(async () => { ({ pid, cid } = await fresh('abandon test')); }); + afterAll(() => cleanup(cid)); + + it('abandon from draft state succeeds', async () => { + const m = await lc.abandon(pid, { by: 'yibo', reason: 'no longer needed' }); + expect(m.status).toBe('abandoned'); + }); + + it('abandon after abandon is rejected', async () => { + await expect(lc.abandon(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); + }); + + it('feedback on abandoned project is rejected', async () => { + await expect(lc.submitFeedback(pid, 'x', { mockFixtureDir: HAPPY_DIR })) + .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); + }); + }); + + describe('signOff requires shipped (not running)', () => { + let pid, cid; + beforeAll(async () => { + ({ pid, cid } = await fresh('not yet shipped')); + await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR }); + // 不跑 runProject,status 还是 running + }); + afterAll(() => cleanup(cid)); + + it('signOff from running fails', async () => { + await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition', from: 'running', to: 'shipped-final' }); + }); + }); + + describe('feedback empty text rejected', () => { + let pid, cid; + beforeAll(async () => { ({ pid, cid } = await fresh('empty feedback')); }); + afterAll(() => cleanup(cid)); + + it('throws on empty/missing text', async () => { + await expect(lc.submitFeedback(pid, '')).rejects.toThrow(/text/); + await expect(lc.submitFeedback(pid, null)).rejects.toThrow(/text/); + }); + }); +}); + +describe('P4 · routes', () => { + let app, base; + const cids = []; + + beforeAll(async () => { + const express = require('express'); + const devloopRouter = require('../../routes/devloop'); + pathResolver._resetForTesting(); + sseBroker._resetForTesting(); + const e = express(); + e.use(express.json()); + e.use('/api/devloop', devloopRouter); + await new Promise(r => { + app = e.listen(0, '127.0.0.1', () => { + base = `http://127.0.0.1:${app.address().port}`; + r(); + }); + }); + }); + + afterAll(() => { + app.close(); + for (const c of cids) cleanup(c); + }); + + it('POST /sign-off requires shipped status (400)', async () => { + const cid = 'c-route-signoff-' + Math.random().toString(36).slice(2, 7); + cids.push(cid); + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), + }); + const j = await r.json(); + // immediately after creation/run (full flow), check signoff + const r2 = await fetch(`${base}/api/devloop/projects/${j.pid}/sign-off`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + // After POST /projects, status is 'running' (not yet shipped via runProject) + // → signOff should 400 + expect(r2.status).toBe(400); + }); + + it('POST /feedback bumps PRD rev', async () => { + const cid = 'c-route-feedback-' + Math.random().toString(36).slice(2, 7); + cids.push(cid); + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), + }); + const pid = (await r.json()).pid; + const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'add toggle', mockFixtureDir: HAPPY_DIR }), + }); + expect(fb.status).toBe(200); + const j = await fb.json(); + expect(j.prd_rev_after).toBeGreaterThan(j.prd_rev_before); + }); + + it('POST /feedback missing text → 400', async () => { + const cid = 'c-route-fb-empty-' + Math.random().toString(36).slice(2, 7); + cids.push(cid); + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), + }); + const pid = (await r.json()).pid; + const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + expect(fb.status).toBe(400); + }); + + it('POST /abandon → status=abandoned', async () => { + const cid = 'c-route-abandon-' + Math.random().toString(36).slice(2, 7); + cids.push(cid); + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), + }); + const pid = (await r.json()).pid; + const ab = await fetch(`${base}/api/devloop/projects/${pid}/abandon`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ by: 'yibo', reason: 'test' }), + }); + expect(ab.status).toBe(200); + const j = await ab.json(); + expect(j.status).toBe('abandoned'); + }); + + it('POST /sign-off / abandon / feedback on unknown pid → 404', async () => { + for (const ep of ['sign-off', 'abandon', 'feedback']) { + const r = await fetch(`${base}/api/devloop/projects/p-nope/${ep}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ep === 'feedback' ? { text: 'x' } : {}), + }); + expect(r.status).toBe(404); + } + }); +}); diff --git a/server/tests/devloop/path-resolver.test.js b/server/tests/devloop/path-resolver.test.js index b5e74db..51ad2fa 100644 --- a/server/tests/devloop/path-resolver.test.js +++ b/server/tests/devloop/path-resolver.test.js @@ -109,13 +109,54 @@ describe('pathResolver', () => { }); }); - describe('allocateProjectRoot · linked-existing rejected in v1', () => { - it('throws NOT_IMPLEMENTED', () => { + describe('allocateProjectRoot · linked-existing', () => { + it('rejects nonexistent target_path', () => { + const fake = path.join(os.tmpdir(), 'devloop-le-nope-' + Math.random().toString(36).slice(2,8)); expect(() => pathResolver.allocateProjectRoot({ - company_id: 'c-test', mode: 'linked-existing', target_path: '/tmp', + company_id: 'c-test', mode: 'linked-existing', target_path: fake, }) - ).toThrow(/v2/); + ).toThrow(/does not exist/); + }); + + it('rejects target_path that already contains a DevLoop project.json', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dup-')); + fs.writeFileSync(path.join(dir, 'project.json'), '{}'); + expect(() => + pathResolver.allocateProjectRoot({ + company_id: 'c-test', mode: 'linked-existing', target_path: dir, + }) + ).toThrow(/already contains/); + }); + + it('accepts existing non-empty plain dir (no .git)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-plain-')); + fs.writeFileSync(path.join(dir, 'README.md'), '# user'); + const r = pathResolver.allocateProjectRoot({ + company_id: 'c-test', mode: 'linked-existing', target_path: dir, + }); + expect(r.pid).toMatch(/^p-/); + expect(r.root_path).toBe(dir); + expect(r.mode).toBe('linked-existing'); + pathResolver.unregisterLinkedProject(r.pid); + }); + + it('rejects existing git repo with dirty worktree', () => { + const { execFileSync } = require('child_process'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dirty-')); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }); + execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: dir }); + fs.writeFileSync(path.join(dir, 'a.txt'), 'one'); + execFileSync('git', ['add', '-A'], { cwd: dir }); + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); + // dirty: untracked + fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked'); + expect(() => + pathResolver.allocateProjectRoot({ + company_id: 'c-test', mode: 'linked-existing', target_path: dir, + }) + ).toThrow(/uncommitted changes/); }); }); diff --git a/server/tests/devloop/routes-crud.test.js b/server/tests/devloop/routes-crud.test.js new file mode 100644 index 0000000..5afd137 --- /dev/null +++ b/server/tests/devloop/routes-crud.test.js @@ -0,0 +1,191 @@ +/** + * routes/devloop.js · 项目 CRUD 路由测试 + * + * 覆盖 (新增的) PATCH / DELETE (soft + hard) / restore + listProjects 过滤。 + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const express = require('express'); + +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-crud-')); +process.env.HOME = TEST_HOME; + +const pathResolver = require('../../services/devloop/path-resolver'); +const devloopRouter = require('../../routes/devloop'); + +function startApp() { + const app = express(); + app.use(express.json()); + app.use('/api/devloop', devloopRouter); + return new Promise((resolve) => { + const srv = app.listen(0, '127.0.0.1', () => { + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); + }); + }); +} + +async function createProject(base, cid, requirement = 'crud test') { + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement }), + }); + const j = await r.json(); + return j.pid; +} + +describe('routes/devloop · CRUD', () => { + let app, base; + const cids = []; + + beforeAll(async () => { + pathResolver._resetForTesting(); + ({ srv: app, base } = await startApp()); + }); + + afterAll(() => { + app.close(); + for (const c of cids) { + try { + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); + } catch { /* ignore */ } + } + }); + + describe('PATCH /projects/:pid', () => { + let pid, cid; + beforeAll(async () => { + cid = 'c-patch-' + Math.random().toString(36).slice(2, 7); + cids.push(cid); + pid = await createProject(base, cid); + }); + + it('updates name + idea_summary', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'renamed project', idea_summary: 'new summary' }), + }); + expect(r.status).toBe(200); + const j = await r.json(); + expect(j.project.name).toBe('renamed project'); + expect(j.project.idea_summary).toBe('new summary'); + }); + + it('rejects empty body (no allowed fields)', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(r.status).toBe(400); + }); + + it('rejects non-string fields', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 123 }), + }); + expect(r.status).toBe(400); + }); + + it('ignores fields not in allowlist (status / archived not changeable here)', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'shipped-final', archived: true }), + }); + expect(r.status).toBe(400); // 没有任何 allowed 字段 → 400 + }); + + it('404 on unknown pid', async () => { + const r = await fetch(`${base}/api/devloop/projects/p-nope`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'x' }), + }); + expect(r.status).toBe(404); + }); + }); + + describe('DELETE soft (archive) + GET list', () => { + let pidA, pidB, cid; + beforeAll(async () => { + cid = 'c-del-' + Math.random().toString(36).slice(2, 7); + cids.push(cid); + pidA = await createProject(base, cid, 'kept'); + pidB = await createProject(base, cid, 'will be archived'); + }); + + it('soft delete sets archived=true (default no hard query)', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pidB}`, { method: 'DELETE' }); + expect(r.status).toBe(200); + const j = await r.json(); + expect(j.hard).toBe(false); + expect(j.archived).toBe(true); + expect(j.project.archived).toBe(true); + }); + + it('default listProjects excludes archived', async () => { + const r = await fetch(`${base}/api/devloop/projects`); + const j = await r.json(); + const ids = j.projects.map(p => p.pid); + expect(ids).toContain(pidA); + expect(ids).not.toContain(pidB); + }); + + it('?include_archived=true returns archived too', async () => { + const r = await fetch(`${base}/api/devloop/projects?include_archived=true`); + const j = await r.json(); + const ids = j.projects.map(p => p.pid); + expect(ids).toContain(pidA); + expect(ids).toContain(pidB); + const arch = j.projects.find(p => p.pid === pidB); + expect(arch.archived).toBe(true); + }); + + it('POST /restore un-archives', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pidB}/restore`, { method: 'POST' }); + expect(r.status).toBe(200); + const j = await r.json(); + expect(j.archived).toBe(false); + // 现在默认 list 应能见到 + const list = await (await fetch(`${base}/api/devloop/projects`)).json(); + expect(list.projects.map(p => p.pid)).toContain(pidB); + }); + }); + + describe('DELETE hard', () => { + it('?hard=true rm -rf 项目目录 (sandbox)', async () => { + const cid = 'c-hard-' + Math.random().toString(36).slice(2, 7); + cids.push(cid); + const pid = await createProject(base, cid, 'hard delete me'); + const rootBefore = pathResolver.resolveProjectRoot(pid); + expect(fs.existsSync(rootBefore)).toBe(true); + + const r = await fetch(`${base}/api/devloop/projects/${pid}?hard=true`, { method: 'DELETE' }); + expect(r.status).toBe(200); + const j = await r.json(); + expect(j.hard).toBe(true); + expect(j.removed_disk).toBe(true); + expect(fs.existsSync(rootBefore)).toBe(false); + + // 后续读 state 应 404 (pid 已从 cache 移除) + const s = await fetch(`${base}/api/devloop/projects/${pid}/state`); + expect(s.status).toBe(404); + }); + + it('hard delete unknown pid → 404', async () => { + const r = await fetch(`${base}/api/devloop/projects/p-nope?hard=true`, { method: 'DELETE' }); + expect(r.status).toBe(404); + }); + }); + + describe('GET /projects?include_archived=false (default)', () => { + it('returns projects array (could be empty)', async () => { + const r = await fetch(`${base}/api/devloop/projects`); + expect(r.status).toBe(200); + const j = await r.json(); + expect(Array.isArray(j.projects)).toBe(true); + }); + }); +}); diff --git a/server/tests/devloop/routes.test.js b/server/tests/devloop/routes.test.js index 25cc2fc..61c6e13 100644 --- a/server/tests/devloop/routes.test.js +++ b/server/tests/devloop/routes.test.js @@ -106,9 +106,13 @@ describe('routes/devloop', () => { expect(s.units).toHaveLength(3); }); - it('POST /api/devloop/projects/:pid/run requires ?only (501 without)', async () => { - const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { method: 'POST' }); - expect(r.status).toBe(501); + it('POST /api/devloop/projects/:pid/run without ?only on unknown pid returns 404 (full-project mode)', async () => { + const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(r.status).toBe(404); }); it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => { @@ -133,6 +137,32 @@ describe('routes/devloop', () => { expect(j2.history).toHaveLength(4); }); + it('POST /run (full project, no ?only) drives runProject', async () => { + const cid = 'c-route-p3-' + Math.random().toString(36).slice(2, 7); + created.push(cid); + const r1 = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'TODO demo' }), + }); + const pid = (await r1.json()).pid; + // ship via full project run (baseline fixtures = happy path) + const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(r2.status).toBe(200); + const j = await r2.json(); + expect(['shipped', 'l2_blocked', 'l2_exhausted']).toContain(j.final_state); + expect(typeof j.l2_rounds).toBe('number'); + }); + + it('GET /api/devloop/projects/:pid/bugs returns 404 on unknown pid', async () => { + const r = await fetch(`${base}/api/devloop/projects/p-nope/bugs`); + expect(r.status).toBe(404); + }); + it('POST /run?only on unknown unit returns 404', async () => { const cid = 'c-route-p2u-' + Math.random().toString(36).slice(2, 7); created.push(cid); @@ -159,4 +189,52 @@ describe('routes/devloop', () => { const j = await r.json(); expect(Array.isArray(j.projects)).toBe(true); }); + + // P5 — UI-supporting endpoints + describe('P5 UI endpoints', () => { + let pid; + beforeAll(async () => { + const cid = 'c-p5-routes-' + Math.random().toString(36).slice(2, 7); + created.push(cid); + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'p5 test' }), + }); + pid = (await r.json()).pid; + }); + + it('GET /timeline returns events array', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline`); + expect(r.status).toBe(200); + const j = await r.json(); + expect(Array.isArray(j.events)).toBe(true); + expect(j.events.length).toBeGreaterThan(0); + }); + + it('GET /changelog returns entries array', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`); + expect(r.status).toBe(200); + const j = await r.json(); + expect(Array.isArray(j.entries)).toBe(true); + }); + + it('GET /units/:unitId returns unit + runs + coder_log_tail', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`); + expect(r.status).toBe(200); + const j = await r.json(); + expect(j).toHaveProperty('unit'); + expect(j).toHaveProperty('runs'); + expect(Array.isArray(j.runs)).toBe(true); + }); + + it('GET /units/:unitId 404 on unknown unit', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/no-such-unit`); + expect(r.status).toBe(404); + }); + + it('GET /timeline 404 on unknown pid', async () => { + const r = await fetch(`${base}/api/devloop/projects/p-nope/timeline`); + expect(r.status).toBe(404); + }); + }); }); diff --git a/server/tests/devloop/run-project.test.js b/server/tests/devloop/run-project.test.js new file mode 100644 index 0000000..22e21f0 --- /dev/null +++ b/server/tests/devloop/run-project.test.js @@ -0,0 +1,166 @@ +/** + * loop-controller.runProject (P3 L2 联动) 单测 + * + * 跑 4 个 L2 分支: + * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 + * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 + * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE + * ④ infra_flake → shipped · sdd_rev=1 · l2_rounds=0 + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rp-')); +process.env.HOME = TEST_HOME; + +const pathResolver = require('../../services/devloop/path-resolver'); +const sseBroker = require('../../services/devloop/sse-broker'); +const bugTracker = require('../../services/devloop/bug-tracker'); +const lc = require('../../src/core/devloop/loop-controller'); + +const SCENARIO_BASE = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios'); + +async function setupScenario(name) { + pathResolver._resetForTesting(); + sseBroker._resetForTesting(); + const cid = 'c-p3-' + name + '-' + Math.random().toString(36).slice(2, 7); + const dir = path.join(SCENARIO_BASE, name); + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p3 ' + name }); + await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir }); + return { pid: proj.pid, cid, dir }; +} + +function cleanup(cid) { + try { + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); + } catch { /* ignore */ } +} + +describe('runProject · L2 branches', () => { + describe('① contract_mismatch', () => { + let pid, cid, dir, result; + const sseEvents = []; + + beforeAll(async () => { + ({ pid, cid, dir } = await setupScenario('contract_mismatch')); + const fakeRes = { + setHeader() {}, flushHeaders() {}, end() {}, + on() {}, // sse-broker subscribes res.on('close', ...) + write(line) { + const m = line.match(/^event: (.+)$/m); + if (m) sseEvents.push(m[1]); + }, + }; + sseBroker.subscribe(pid, fakeRes, {}); + result = await lc.runProject(pid, { mockFixtureDir: dir }); + }); + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); + + it('ships after 1 L2 round', () => { + expect(result.ok).toBe(true); + expect(result.final_state).toBe('shipped'); + }); + + it('units.json l2_rounds=1 and sdd_rev bumped to 2', () => { + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); + expect(u.l2_rounds).toBe(1); + expect(u.sdd_rev).toBe('2'); + }); + + it('emits sdd.updated event', () => { + expect(sseEvents).toContain('sdd.updated'); + }); + + it('changelog has sdd_rev entry', () => { + const cl = pathResolver.resolveChangelog(pid); + expect(fs.existsSync(cl)).toBe(true); + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + expect(lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)).toBe(true); + }); + + it('bug opened then transitioned to fixed', () => { + const bugs = bugTracker.list(pid, {}); + expect(bugs).toHaveLength(1); + expect(bugs[0].status).toBe('fixed'); + }); + }); + + describe('② impl_bug', () => { + let pid, cid, dir, result; + + beforeAll(async () => { + ({ pid, cid, dir } = await setupScenario('impl_bug')); + result = await lc.runProject(pid, { mockFixtureDir: dir }); + }); + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); + + it('ships without SDD change', () => { + expect(result.ok).toBe(true); + expect(result.final_state).toBe('shipped'); + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); + expect(u.l2_rounds).toBe(0); + expect(u.sdd_rev).toBe('1'); + }); + + it('history shows impl_bug reset_units (no sdd_patch)', () => { + const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); + expect(actions.some(a => /impl_bug/.test(a))).toBe(true); + expect(actions.some(a => /sdd_patch/.test(a))).toBe(false); + }); + }); + + describe('③ spec_ambiguous', () => { + let pid, cid, dir, result; + const sseEvents = []; + + beforeAll(async () => { + ({ pid, cid, dir } = await setupScenario('spec_ambiguous')); + const fakeRes = { + setHeader() {}, flushHeaders() {}, end() {}, + on() {}, // sse-broker subscribes res.on('close', ...) + write(line) { + const m = line.match(/^event: (.+)$/m); + if (m) sseEvents.push(m[1]); + }, + }; + sseBroker.subscribe(pid, fakeRes, {}); + result = await lc.runProject(pid, { mockFixtureDir: dir }); + }); + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); + + it('returns l2_blocked', () => { + expect(result.ok).toBe(false); + expect(result.final_state).toBe('l2_blocked'); + }); + + it('emits human_intervention_needed SSE', () => { + expect(sseEvents).toContain('human_intervention_needed'); + }); + }); + + describe('④ infra_flake', () => { + let pid, cid, dir, result; + + beforeAll(async () => { + ({ pid, cid, dir } = await setupScenario('infra_flake')); + result = await lc.runProject(pid, { mockFixtureDir: dir }); + }); + afterAll(() => { sseBroker.close(pid); cleanup(cid); }); + + it('ships after re-run without budget burn', () => { + expect(result.ok).toBe(true); + expect(result.final_state).toBe('shipped'); + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); + expect(u.l2_rounds).toBe(0); + }); + + it('history records rerun_integrator', () => { + const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); + expect(actions.some(a => /rerun_integrator/.test(a))).toBe(true); + }); + }); +}); diff --git a/start.sh b/start.sh index 9a11191..8603f7a 100755 --- a/start.sh +++ b/start.sh @@ -37,9 +37,9 @@ for PORT in 3010 5173; do fi done -# 启动后端 Agent Chat Server -echo "🔌 启动 Agent Server (端口 3010)..." -PORT=3010 node server/agent-server.js & +# 启动后端主服务 (全量: Agent + DevLoop + Master + Skills + Arena + Auth + WS) +echo "🔌 启动 LemonBoss Server (端口 3010)..." +PORT=3010 node server/index.js & SERVER_PID=$! # 等待后端启动 diff --git a/web/src/components/devloop/DashboardHeader.vue b/web/src/components/devloop/DashboardHeader.vue new file mode 100644 index 0000000..e358338 --- /dev/null +++ b/web/src/components/devloop/DashboardHeader.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/web/src/components/devloop/FeedbackModal.vue b/web/src/components/devloop/FeedbackModal.vue new file mode 100644 index 0000000..48e974a --- /dev/null +++ b/web/src/components/devloop/FeedbackModal.vue @@ -0,0 +1,82 @@ +