diff --git a/CLAUDE.md b/CLAUDE.md index 1e607fa..2df826a 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 的进程。 @@ -58,17 +61,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, …) | @@ -115,7 +118,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/packages/acp-core/protocol/ClaudeCliProtocol.js b/packages/acp-core/protocol/ClaudeCliProtocol.js index c6edeb8..b4a2502 100644 --- a/packages/acp-core/protocol/ClaudeCliProtocol.js +++ b/packages/acp-core/protocol/ClaudeCliProtocol.js @@ -76,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); diff --git a/scripts/real-pm-smoke.js b/scripts/real-pm-smoke.js new file mode 100644 index 0000000..4c6626f --- /dev/null +++ b/scripts/real-pm-smoke.js @@ -0,0 +1,113 @@ +#!/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 + +const fs = require('fs'); +const path = require('path'); + +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/server/routes/devloop.js b/server/routes/devloop.js index ec4ea5d..733d984 100644 --- a/server/routes/devloop.js +++ b/server/routes/devloop.js @@ -73,12 +73,62 @@ 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 }); })); +// 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 兼容) diff --git a/server/services/devloop/path-resolver.js b/server/services/devloop/path-resolver.js index f76d365..cd8713a 100644 --- a/server/services/devloop/path-resolver.js +++ b/server/services/devloop/path-resolver.js @@ -282,9 +282,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(); - return Array.from(cache.entries()).map(([pid, entry]) => ({ pid, ...entry })); + 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(); + 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 +383,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 00038cd..bbaab84 100644 --- a/server/src/core/devloop/loop-controller.js +++ b/server/src/core/devloop/loop-controller.js @@ -158,21 +158,31 @@ 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+ 阶段) - mockFixtureDir: opts.mockFixtureDir, // 可选,P3 scenario 测试用 + mock: useMock, + mockFixtureDir: opts.mockFixtureDir, + // real 模式专属: + systemPrompt: useMock ? undefined : systemPrompt, + maxBudgetUsd: opts.maxBudgetUsd, // 调用方可设上限 }, ); @@ -197,36 +207,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 */ } @@ -926,6 +944,35 @@ async function submitFeedback(pid, text, opts = {}) { }; } +/** + * 扫项目根递归收集 (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'); 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/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 index 6c9d7e4..199537f 100644 --- a/web/src/components/devloop/DashboardHeader.vue +++ b/web/src/components/devloop/DashboardHeader.vue @@ -1,7 +1,7 @@