Control · API + 服务方法签名
所有即时响应代码:7 个 REST 端点 + 3 个核心服务的公共方法。每条带函数签名、契约、典型实现要点。Coder Agent (be) 读这页能生成全部后端代码。
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 异步起
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
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 基于历史项目均值 + 当前阶段剩余项数线性外推
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
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.*事件tests: 同一 test 两次失败 → dedupKey 命中,只有一条 bug
tests: transition open → verified(跳级)→ 抛错
tests: 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,不是阻塞依赖)
tests: init 后 .git 存在,
.gitignore已 committests: 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 | — |