C-1 · POST /api/devloop/projects
server/routes/devloop.js
router.post('/projects', requireAuth, async (req, res) => { const { company_id, name, requirement, mode = 'sandbox', // 默认 sandbox target_path // linked-* 时必填 } = req.body; const { pid, root_path } = await loopController.createProject({ company_id, name, owner: req.user.id, mode, target_path, }); loopController.onHumanFeedback(pid, { text: requirement, origin: req.user.id }); res.status(201).json({ project_id: pid, mode, root_path, status: 'prd_drafting', stream_url: `/api/devloop/projects/${pid}/stream`, }); });
- 请求体必填:
{company_id, name, requirement};可选:{mode, target_path} - mode 默认
sandbox;linked-new时 target_path 必填且必须是绝对空目录或不存在;linked-existingv1 拒收(v2 启用) - target_path 不允许指向系统敏感目录(详见 arch-project-root §⑫)
- company_id 必须与 JWT 中的匹配(防越权)
- 立即返回 201 + project_id + root_path;PM Agent 异步起
- 合法 sandbox 请求 → 201 + 含 project_id + root_path=data/.../projects/{pid}/
- linked-new + target_path 合法 → 201 + root_path=用户指定路径 + .git 已初始化
- linked-new + target_path 不存在父目录 → 400 拒绝
- linked-new + target_path 是 /etc → 403 sensitive_path
- company_id 不匹配 JWT → 403
- 缺字段 → 400 + 字段名
C-2 · POST /api/devloop/projects/:pid/requirement
server/routes/devloop.js
router.post('/projects/:pid/requirement', requireAuth, async (req, res) => { const { text, origin } = req.body; loopController.onHumanFeedback(req.params.pid, { text, origin: origin || req.user.id }); res.status(202).json({ accepted: true, queued: true }); });
- 立即 202,不等 PM 跑完
- 多个反馈同时到 → 队列化,按时序处理
- 项目 status=shipped-final 或 abandoned → 拒绝(409)
C-3 · POST /api/devloop/projects/:pid/run
server/routes/devloop.js
router.post('/projects/:pid/run', requireAuth, async (req, res) => { // 续跑(如果之前 blocked / paused,重启循环) loopController.runProject(req.params.pid); res.json({ running: true }); });
- 等价于「resume」,对 status=running 是 no-op
- blocked / l2_exhausted 状态调用此端点 → 重置预算并继续
C-4 · GET /api/devloop/projects/:pid/state
server/routes/devloop.js
router.get('/projects/:pid/state', requireAuth, (req, res) => { res.json(loopController.getState(req.params.pid)); }); // 返回结构: { project_id, prd_rev, sdd_rev, current_loop: 'L1' | 'L2' | 'L3' | 'idle', units: Unit[], l2_rounds, l2_budget, agents_active: string[], token_used, token_budget, }
- 读时不加锁(不一致几毫秒可接受)
- response 体限 100KB 以内(大项目 unit 多)
C-5 · GET /api/devloop/projects/:pid/stream
server/routes/devloop.js
router.get('/projects/:pid/stream', requireAuth, (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }); sseBroker.subscribe(req.params.pid, res, { replayFromOffset: req.query.last_event_id, }); });
- 支持 Last-Event-ID 断线重连
- 心跳:每 25s 发一条
:keepalive注释 - 项目结束(shipped/abandoned)→ 发 close 帧后关闭
C-6 · POST /api/devloop/projects/:pid/sign-off
server/routes/devloop.js
router.post('/projects/:pid/sign-off', requireAuth, (req, res) => { loopController.signOff(req.params.pid, req.user.id); res.json({ status: 'shipped-final' }); });
仅项目 owner 或 admin 能调用。设置 status=shipped-final,停止接收任何新反馈。
C-7 · POST /api/devloop/projects/:pid/abandon
server/routes/devloop.js
router.post('/projects/:pid/abandon', requireAuth, (req, res) => { loopController.abandon(req.params.pid, req.user.id); res.json({ status: 'abandoned' }); });
C-8 · LoopController(核心服务)
server/src/core/loop-controller.js
module.exports.loopController = { createProject({ company_id, name, owner }): Promise<string>, runProject(pid: string): Promise<'shipped'|'blocked'|'l2_exhausted'>, runUnit(pid: string, unitId: string): Promise<UnitResult>, onHumanFeedback(pid: string, { text, origin }): void, onL2Failure(pid: string, runId: string): Promise<void>, signOff(pid: string, byUser: string): void, abandon(pid: string, byUser: string): void, getState(pid: string): ProjectState, };
- 所有 Agent 调用通过
agentManager.callAgent(name, input),不直接起进程 - runProject 是幂等的:同一 pid 多次调用合并为一个执行
- onHumanFeedback 同 pid 多次调用按时序队列处理
C-9 · DocStore
server/services/doc-store.js
module.exports.docStore = { // 读 readPRD(pid): Promise<string>, readSDD(pid): Promise<string>, readUnits(pid): Promise<UnitsJson>, readIntegrationSpec(pid): Promise<IntegrationSpec>, // 写(自动归档) writePRD(pid, html): Promise<{ rev: number }>, writeSDD(pid, html): Promise<{ rev: number }>, writeUnits(pid, json): Promise<{ rev: number }>, writeIntegrationSpec(pid, json): Promise<void>, // 校验 validateHTML(html, kind): { ok: boolean, errors: string[] }, validateUnitsSchema(json): { ok: boolean, errors: string[] }, // diff / 归档 diffPRD(pid, fromRev, toRev): SectionDiff[], archive(pid, kind, oldContent): Promise<void>, // 工具 hashContract(contractText): string, // → 'sha256:...' };
- writeXxx 调用前先 validate;不通过抛 ValidationError
- 写入前自动归档旧版到 history/
- 所有方法可并发安全(用文件锁或队列)
C-10 · UnitRegistry
server/services/unit-registry.js
module.exports.unitRegistry = { list(pid): Unit[], pending(pid): Unit[], passed(pid): Unit[], blocked(pid): Unit[], anyBlocked(pid): boolean, l2GateOpen(pid): boolean, markCoding(pid, unitId): void, markTesting(pid, unitId): void, pass(pid, unitId, contractHash): void, fail(pid, unitId, reason): void, block(pid, unitId, reason): void, reset(pid, unitIds: string[]): void, diffWithSDD(pid): { added: string[], changed: string[], removed: string[] }, };
- 所有 markXxx 内部走 FSM 校验(非法转换抛错)
- 每次状态变化同步发 SSE
unit.state - 状态来源单一:units.json + 内存缓存
C-11 · SSEBroker
server/services/sse-broker.js
module.exports.sseBroker = { subscribe(pid: string, res: ServerResponse, opts?: { replayFromOffset?: string }): void, emit(pid: string, event: string, data: any): void, close(pid: string): void, // 项目结束 };
- 同一 pid 多订阅者广播
- 事件落盘到
stream.log,支持断线 replay - 每 25s 心跳
SSE 事件枚举见 api-sse。
C-12 · 中间件
server/middleware/
// 中间件标准签名 (Express 4) type Middleware = (req: Request, res: Response, next: NextFunction) => void; // 4 个中间件:requireAuth · jsonSanitizer · jsonGuard(NEW) · tokenAccountant(NEW)
| name | path | 用途 |
|---|---|---|
requireAuth | server/middleware/auth.js · 复用 | JWT 校验 + req.user 注入 |
jsonSanitizer | server/index.js · 复用 | 清控制字符防 JSON.parse 崩 |
jsonGuard NEW | server/middleware/json-guard.js | Agent Bash 工具层检测 inline JSON 命令并拒绝 |
tokenAccountant NEW | server/middleware/token-accountant.js | 每次 Agent 调用后累计 token_used,超 budget 阻断 |
C-13 · agentManager.callAgent 约定
server/src/core/agent-manager.js · 复用既有,不新增文件
agentManager.callAgent(name: string, input: { pid: string, // 项目 id unit_id?: string, // Coder/Tester 时必填 mode?: 'generate' | 'patch', // Architect 专属 history?: any[], // retry 时上次失败 ...其余 Agent 特定参数 }): Promise<AgentResult> // AgentResult 必须含: { ok: boolean, outputs: string[], // 写入的文件路径 duration_ms: number, tokens_used: number, error?: string, }
- SessionPool 按
(name, cid, pid)复用会话 - 每次调用先校验 name 在 _registry.json 中存在
- 失败时 stderr 自动落 stream.log
C-14 · LoopController.getProgress(pid)
server/src/core/loop-controller.js · 新方法
// 聚合进度 API,Dashboard 主用 getProgress(pid: string): { percent: number, // 0-100 整体加权进度 current_phase: 'prd_drafting' | 'sdd_drafting' | 'L1' | 'L2' | 'shipped' | 'blocked', eta_seconds: number, // 估算剩余秒,基于历史数据 breakdown: { prd: { ok: boolean, rev: number }, sdd: { ok: boolean, rev: number }, units: { passed: number, total: number, failed: number, blocked: number }, l2: { rounds: number, budget: number }, tokens: { used: number, budget: number }, }, active_agents: [{ // 当前正在跑的 Agent agent: string, unit_id?: string, step: string, // "3/5" desc: string, // 当前 todo item description started_at: string, }], recent_events: TimelineEvent[], // 最近 10 条 }
- 聚合自 UnitsJson · agent-state/*.json · timeline.ndjson 三处
- 读时不加锁(毫秒级偏差可接受)
- percent 加权算法:PRD(10) + SDD(20) + Units(50) + L2(15) + Ship(5) = 100
- eta 基于历史项目均值 + 当前阶段剩余项数线性外推
- 空项目 → percent=0, current_phase='prd_drafting'
- 所有 unit passed 但 L2 未跑 → percent ≈ 80
- shipped → percent=100, eta_seconds=0
C-15 · TimelineLogger · 全流程日志写入
server/services/timeline-logger.js
module.exports.timelineLogger = { append(pid: string, event: { source: string, kind: string, ref?: string, summary: string, payload?: object }): void, read(pid: string, opts?: { limit?: number, // 默认 100 since?: number, // unix ts source?: string, kind?: string, }): TimelineEvent[], tail(pid: string, n: number = 10): TimelineEvent[], };
- append 是同步操作(fs.appendFileSync),确保事件原子写入
- ts 自动填当前时间,调用方不传
- summary > 280 字符自动截断 + 省略号
- payload 太大(> 4KB)→ 强制改为引用形式
{ x_ref: "..." } - read 用流式 readline 解析,不一次性 load 全文件
C-16 · BugTracker · bugs.ndjson CRUD
server/services/bug-tracker.js
module.exports.bugTracker = { // 写 open(pid: string, bug: Omit<Bug, 'id'|'discovered_at'|'status'|'status_history'>): Bug, transition(pid: string, bugId: string, { to: BugStatus, by: string, note?: string, fix_refs?: string[], }): void, // 读 (流式 + 终态聚合) list(pid: string, filter?: { status?: BugStatus[], severity?: string[], unit_id?: string, since?: string, }): Bug[], // 返回每个 bug.id 的最新态 history(pid: string, bugId: string): BugStatusHistory[], stats(pid: string): { open: number, fixing: number, fixed: number, verified: number, wontfix: number, by_severity: Record<string, number>, }, // 工具 dedupKey(bug: Partial<Bug>): string, // "auth-be:login.invalid-pwd" 用于检测重复 };
- open() 内部先算 dedupKey,命中已存在 open/fixing 的 bug → 不重复,返回旧 id
- transition() 必经 P-9 状态机校验,非法转换抛错
- list() 内部流式读 NDJSON(不一次性加载)
- 同时写入一条 timeline 事件
{ kind: 'bug_opened'|'bug_transitioned', ref:bugId } - 所有写操作同步发 SSE
bug.*事件
- 同一 test 两次失败 → dedupKey 命中,只有一条 bug
- transition open → verified(跳级)→ 抛错
- stats() 与 list() 长度一致
C-17 · ChangelogWriter
server/services/changelog-writer.js
module.exports.changelogWriter = { append(pid: string, { kind, rev, by, trigger, summary, diff, archive_ref }): void, read(pid: string, opts?: { limit?, kind?, since? }): ChangelogEntry[], latest(pid: string, kind: string): ChangelogEntry | null, }; // 被 DocStore 内部调用 // DocStore.writePRD/writeSDD 成功后 → changelogWriter.append
- 由 DocStore 内部触发,外部不直接调
- diff 字段计算由 DocStore 提供(cheerio 算 section 差异 + 拓扑算 units 差异)
C-18 · GitVault · 每项目 git 仓库管理
server/services/git-vault.js
// 详尽设计见 arch-git-versioning.html module.exports.gitVault = { init(pid, opts?): Promise<{ ok, head }>, commit(pid, { kind, unit_id?, run_id?, summary, files?, author?, skipEmpty? }): Promise<{ ok, sha?, skipped? }>, tag(pid, tagName, opts?): Promise<{ ok, tag }>, log(pid, opts?: { limit?, since?, grep? }): Promise<Commit[]>, diff(pid, refA, refB, opts?): Promise<string>, show(pid, sha): Promise<Commit + diff>, listTags(pid): Promise<Tag[]>, status(pid): Promise<{ dirty, files }>, resetWorkingTree(pid, ref = 'HEAD'): Promise<{ ok }>, revert(pid, sha): Promise<{ ok, newSha }>, archive(pid, ref = 'HEAD', format = 'zip'): Promise<{ path }>, // v2 留口 addRemote?(pid, name, url), push?(pid, remote), };
- 实现用
child_process.spawn('git', ...),不依赖第三方 npm 包(避免凭据管理复杂化) - 所有方法按 pid 串行化(per-pid mutex),避免同项目并发 git 操作打架
- commit author 标 Agent 名(e.g. "PM Agent <pm@devloop.local>"),git log 可读「哪个 Agent 干的」
- nothing-to-commit 不抛错,返
{ ok: true, skipped: true } - commit 失败不阻塞 Agent 返回(git 是 nice-to-have,不是阻塞依赖)
- init 后 .git 存在,
.gitignore已 commit - commit({kind:'prd'}) 后 git log 有该 commit,author 是 "PM Agent"
- nothing-to-commit 返 skipped=true 不抛
- tag('shipped-v1') 后 listTags 包含 shipped-v1
- archive 返回 zip 路径,
unzip -l看仅 tracked files
端点-handler-服务依赖矩阵
| 端点 | LoopController | DocStore | UnitRegistry | SSEBroker | agentManager |
|---|---|---|---|---|---|
| POST /projects | createProject + onHumanFeedback | — | — | — | (异步) |
| POST /requirement | onHumanFeedback | (异步) | (异步) | (异步) | callAgent(pm) |
| POST /run | runProject | read* | list + pending | — | callAgent(*) |
| GET /state | getState | readUnits | list | — | — |
| GET /stream | — | — | — | subscribe | — |
| POST /sign-off | signOff | — | — | emit close | — |
| POST /abandon | abandon | — | — | emit close | — |