# Control · API + 服务方法签名 > 所有即时响应代码:7 个 REST 端点 + 3 个核心服务的公共方法。每条带函数签名、契约、典型实现要点。Coder Agent (be) 读这页能生成全部后端代码。 ## C-1 · POST /api/devloop/projects · `server/routes/devloop.js` ```ts 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-existing` v1 拒收(v2 启用) - target_path 不允许指向系统敏感目录(详见 [arch-project-root §⑫](arch-project-root.html)) - company_id 必须与 JWT 中的匹配(防越权) - 立即返回 201 + project_id + root_path;PM Agent 异步起 - tests: 合法 sandbox 请求 → 201 + 含 project_id + root_path=data/.../projects/{pid}/ - tests: linked-new + target_path 合法 → 201 + root_path=用户指定路径 + .git 已初始化 - tests: linked-new + target_path 不存在父目录 → 400 拒绝 - tests: linked-new + target_path 是 /etc → 403 sensitive_path - tests: company_id 不匹配 JWT → 403 - tests: 缺字段 → 400 + 字段名 ## C-2 · POST /api/devloop/projects/:pid/requirement · `server/routes/devloop.js` ```ts 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` ```ts 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` ```ts 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` ```ts 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` ```ts 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` ```ts 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` ```ts module.exports.loopController = { createProject({ company_id, name, owner }): Promise, runProject(pid: string): Promise<'shipped'|'blocked'|'l2_exhausted'>, runUnit(pid: string, unitId: string): Promise, onHumanFeedback(pid: string, { text, origin }): void, onL2Failure(pid: string, runId: string): Promise, 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` ```ts module.exports.docStore = { // 读 readPRD(pid): Promise, readSDD(pid): Promise, readUnits(pid): Promise, readIntegrationSpec(pid): Promise, // 写(自动归档) writePRD(pid, html): Promise<{ rev: number }>, writeSDD(pid, html): Promise<{ rev: number }>, writeUnits(pid, json): Promise<{ rev: number }>, writeIntegrationSpec(pid, json): Promise, // 校验 validateHTML(html, kind): { ok: boolean, errors: string[] }, validateUnitsSchema(json): { ok: boolean, errors: string[] }, // diff / 归档 diffPRD(pid, fromRev, toRev): SectionDiff[], archive(pid, kind, oldContent): Promise, // 工具 hashContract(contractText): string, // → 'sha256:...' }; ``` - writeXxx 调用前先 validate;不通过抛 ValidationError - 写入前自动归档旧版到 history/ - 所有方法可并发安全(用文件锁或队列) ## C-10 · UnitRegistry · `server/services/unit-registry.js` ```ts 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` ```ts 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](api-sse.html)。_ ## C-12 · 中间件 · `server/middleware/` ```ts // 中间件标准签名 (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 · 复用既有,不新增文件` ```ts agentManager.callAgent(name: string, input: { pid: string, // 项目 id unit_id?: string, // Coder/Tester 时必填 mode?: 'generate' | 'patch', // Architect 专属 history?: any[], // retry 时上次失败 ...其余 Agent 特定参数 }): Promise // 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 · 新方法` ```ts // 聚合进度 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 基于历史项目均值 + 当前阶段剩余项数线性外推 - tests: 空项目 → percent=0, current_phase='prd_drafting' - tests: 所有 unit passed 但 L2 未跑 → percent ≈ 80 - tests: shipped → percent=100, eta_seconds=0 ## C-15 · TimelineLogger · 全流程日志写入 · `server/services/timeline-logger.js` ```ts 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` ```ts module.exports.bugTracker = { // 写 open(pid: string, bug: Omit): 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, }, // 工具 dedupKey(bug: Partial): 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.*` 事件 - tests: 同一 test 两次失败 → dedupKey 命中,只有一条 bug - tests: transition open → verified(跳级)→ 抛错 - tests: stats() 与 list() 长度一致 ## C-17 · ChangelogWriter · `server/services/changelog-writer.js` ```ts 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` ```ts // 详尽设计见 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, diff(pid, refA, refB, opts?): Promise, show(pid, sha): Promise, listTags(pid): Promise, 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 "),git log 可读「哪个 Agent 干的」 - nothing-to-commit 不抛错,返 `{ ok: true, skipped: true }` - commit 失败不阻塞 Agent 返回(git 是 nice-to-have,不是阻塞依赖) - tests: init 后 .git 存在,`.gitignore` 已 commit - tests: commit({kind:'prd'}) 后 git log 有该 commit,author 是 "PM Agent" - tests: nothing-to-commit 返 skipped=true 不抛 - tests: tag('shipped-v1') 后 listTags 包含 shipped-v1 - tests: 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 | — |