sdd-control.md 15.1 KB

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-existing v1 拒收(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 已 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 —