sdd-control.html 28.8 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>SDD · Control · LemonBoss PRD</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,500;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="assets/shell.css" />
</head>
<body data-page="sdd-control.html">
<div class="shell">
  <aside id="sidebar"></aside>
  <main class="page">
    <header class="page-head">
      <div class="crumb"><span class="pill">03 · SDD</span><span class="sep">/</span><span>C · Control</span></div>
      <h1>Control · API + 服务方法签名</h1>
      <p class="deck">所有即时响应代码:7 个 REST 端点 + 3 个核心服务的公共方法。每条带函数签名、契约、典型实现要点。Coder Agent (be) 读这页能生成全部后端代码。</p>
    </header>
    <article class="body">

      <section data-def-name="REST.createProject" data-mvcp="control">
        <h2>C-1 · POST /api/devloop/projects</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/routes/devloop.js</code>
<pre class="code shape">router.<span class="n">post</span>(<span class="s">'/projects'</span>, requireAuth, <span class="k">async</span> (req, res) =&gt; <span class="p">{</span>
  <span class="k">const</span> <span class="p">{</span> company_id, name, requirement <span class="p">}</span> = req.body;
  <span class="k">const</span> pid = <span class="k">await</span> loopController.<span class="n">createProject</span>(<span class="p">{</span> company_id, name, owner: req.user.id <span class="p">}</span>);
  loopController.<span class="n">onHumanFeedback</span>(pid, <span class="p">{</span> text: requirement, origin: req.user.id <span class="p">}</span>);
  res.<span class="n">status</span>(<span class="v">201</span>).<span class="n">json</span>(<span class="p">{</span>
    project_id: pid,
    status:     <span class="s">'prd_drafting'</span>,
    stream_url: <span class="s">`/api/devloop/projects/</span><span class="v">${pid}</span><span class="s">/stream`</span>,
  <span class="p">}</span>);
<span class="p">}</span>);</pre>
        <ul class="contract">
          <li>请求体 <code>{company_id, name, requirement}</code> 三字段必填</li>
          <li>company_id 必须与 JWT 中的匹配(防越权)</li>
          <li>立即返回 201 + project_id;PM Agent 异步起</li>
        </ul>
        <ul class="tests">
          <li>合法请求 → 201 + 含 project_id</li>
          <li>company_id 不匹配 JWT → 403</li>
          <li>缺字段 → 400 + 字段名</li>
        </ul>
      </section>

      <section data-def-name="REST.postRequirement" data-mvcp="control">
        <h2>C-2 · POST /api/devloop/projects/:pid/requirement</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/routes/devloop.js</code>
<pre class="code shape">router.<span class="n">post</span>(<span class="s">'/projects/:pid/requirement'</span>, requireAuth, <span class="k">async</span> (req, res) =&gt; <span class="p">{</span>
  <span class="k">const</span> <span class="p">{</span> text, origin <span class="p">}</span> = req.body;
  loopController.<span class="n">onHumanFeedback</span>(req.params.pid, <span class="p">{</span> text, origin: origin || req.user.id <span class="p">}</span>);
  res.<span class="n">status</span>(<span class="v">202</span>).<span class="n">json</span>(<span class="p">{</span> accepted: <span class="v">true</span>, queued: <span class="v">true</span> <span class="p">}</span>);
<span class="p">}</span>);</pre>
        <ul class="contract">
          <li>立即 202,不等 PM 跑完</li>
          <li>多个反馈同时到 → 队列化,按时序处理</li>
          <li>项目 status=shipped-final 或 abandoned → 拒绝(409)</li>
        </ul>
      </section>

      <section data-def-name="REST.run" data-mvcp="control">
        <h2>C-3 · POST /api/devloop/projects/:pid/run</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/routes/devloop.js</code>
<pre class="code shape">router.<span class="n">post</span>(<span class="s">'/projects/:pid/run'</span>, requireAuth, <span class="k">async</span> (req, res) =&gt; <span class="p">{</span>
  <span class="c">// 续跑(如果之前 blocked / paused,重启循环)</span>
  loopController.<span class="n">runProject</span>(req.params.pid);
  res.<span class="n">json</span>(<span class="p">{</span> running: <span class="v">true</span> <span class="p">}</span>);
<span class="p">}</span>);</pre>
        <ul class="contract">
          <li>等价于「resume」,对 status=running 是 no-op</li>
          <li>blocked / l2_exhausted 状态调用此端点 → 重置预算并继续</li>
        </ul>
      </section>

      <section data-def-name="REST.state" data-mvcp="control">
        <h2>C-4 · GET /api/devloop/projects/:pid/state</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/routes/devloop.js</code>
<pre class="code shape">router.<span class="n">get</span>(<span class="s">'/projects/:pid/state'</span>, requireAuth, (req, res) =&gt; <span class="p">{</span>
  res.<span class="n">json</span>(loopController.<span class="n">getState</span>(req.params.pid));
<span class="p">}</span>);

<span class="c">// 返回结构:</span>
<span class="p">{</span>
  project_id, prd_rev, sdd_rev,
  current_loop: <span class="s">'L1'</span> | <span class="s">'L2'</span> | <span class="s">'L3'</span> | <span class="s">'idle'</span>,
  units: Unit[],
  l2_rounds, l2_budget,
  agents_active: <span class="n">string</span>[],
  token_used, token_budget,
<span class="p">}</span></pre>
        <ul class="contract">
          <li>读时不加锁(不一致几毫秒可接受)</li>
          <li>response 体限 100KB 以内(大项目 unit 多)</li>
        </ul>
      </section>

      <section data-def-name="REST.stream" data-mvcp="control">
        <h2>C-5 · GET /api/devloop/projects/:pid/stream</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/routes/devloop.js</code>
<pre class="code shape">router.<span class="n">get</span>(<span class="s">'/projects/:pid/stream'</span>, requireAuth, (req, res) =&gt; <span class="p">{</span>
  res.<span class="n">writeHead</span>(<span class="v">200</span>, <span class="p">{</span>
    <span class="s">'Content-Type'</span>:  <span class="s">'text/event-stream'</span>,
    <span class="s">'Cache-Control'</span>: <span class="s">'no-cache'</span>,
    <span class="s">'Connection'</span>:    <span class="s">'keep-alive'</span>,
  <span class="p">}</span>);
  sseBroker.<span class="n">subscribe</span>(req.params.pid, res, <span class="p">{</span>
    replayFromOffset: req.query.last_event_id,
  <span class="p">}</span>);
<span class="p">}</span>);</pre>
        <ul class="contract">
          <li>支持 Last-Event-ID 断线重连</li>
          <li>心跳:每 25s 发一条 <code>:keepalive</code> 注释</li>
          <li>项目结束(shipped/abandoned)→ 发 close 帧后关闭</li>
        </ul>
      </section>

      <section data-def-name="REST.signOff" data-mvcp="control">
        <h2>C-6 · POST /api/devloop/projects/:pid/sign-off</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/routes/devloop.js</code>
<pre class="code shape">router.<span class="n">post</span>(<span class="s">'/projects/:pid/sign-off'</span>, requireAuth, (req, res) =&gt; <span class="p">{</span>
  loopController.<span class="n">signOff</span>(req.params.pid, req.user.id);
  res.<span class="n">json</span>(<span class="p">{</span> status: <span class="s">'shipped-final'</span> <span class="p">}</span>);
<span class="p">}</span>);</pre>
        <p class="sig">仅项目 owner 或 admin 能调用。设置 status=shipped-final,停止接收任何新反馈。</p>
      </section>

      <section data-def-name="REST.abandon" data-mvcp="control">
        <h2>C-7 · POST /api/devloop/projects/:pid/abandon</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/routes/devloop.js</code>
<pre class="code shape">router.<span class="n">post</span>(<span class="s">'/projects/:pid/abandon'</span>, requireAuth, (req, res) =&gt; <span class="p">{</span>
  loopController.<span class="n">abandon</span>(req.params.pid, req.user.id);
  res.<span class="n">json</span>(<span class="p">{</span> status: <span class="s">'abandoned'</span> <span class="p">}</span>);
<span class="p">}</span>);</pre>
      </section>

      <section data-def-name="LoopController" data-mvcp="control">
        <h2>C-8 · LoopController(核心服务)</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/src/core/loop-controller.js</code>
<pre class="code shape"><span class="k">module.exports</span>.loopController = <span class="p">{</span>
  <span class="n">createProject</span>(<span class="p">{</span> company_id, name, owner <span class="p">}</span>): <span class="n">Promise</span>&lt;<span class="n">string</span>&gt;,

  <span class="n">runProject</span>(pid: <span class="n">string</span>): <span class="n">Promise</span>&lt;<span class="s">'shipped'</span>|<span class="s">'blocked'</span>|<span class="s">'l2_exhausted'</span>&gt;,
  <span class="n">runUnit</span>(pid: <span class="n">string</span>, unitId: <span class="n">string</span>): <span class="n">Promise</span>&lt;UnitResult&gt;,

  <span class="n">onHumanFeedback</span>(pid: <span class="n">string</span>, <span class="p">{</span> text, origin <span class="p">}</span>): <span class="n">void</span>,
  <span class="n">onL2Failure</span>(pid: <span class="n">string</span>, runId: <span class="n">string</span>): <span class="n">Promise</span>&lt;<span class="n">void</span>&gt;,

  <span class="n">signOff</span>(pid: <span class="n">string</span>, byUser: <span class="n">string</span>): <span class="n">void</span>,
  <span class="n">abandon</span>(pid: <span class="n">string</span>, byUser: <span class="n">string</span>): <span class="n">void</span>,

  <span class="n">getState</span>(pid: <span class="n">string</span>): ProjectState,
<span class="p">}</span>;</pre>
        <ul class="contract">
          <li>所有 Agent 调用通过 <code>agentManager.callAgent(name, input)</code>,不直接起进程</li>
          <li>runProject 是幂等的:同一 pid 多次调用合并为一个执行</li>
          <li>onHumanFeedback 同 pid 多次调用按时序队列处理</li>
        </ul>
      </section>

      <section data-def-name="DocStore" data-mvcp="control">
        <h2>C-9 · DocStore</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/services/doc-store.js</code>
<pre class="code shape"><span class="k">module.exports</span>.docStore = <span class="p">{</span>
  <span class="c">// 读</span>
  <span class="n">readPRD</span>(pid): <span class="n">Promise</span>&lt;<span class="n">string</span>&gt;,
  <span class="n">readSDD</span>(pid): <span class="n">Promise</span>&lt;<span class="n">string</span>&gt;,
  <span class="n">readUnits</span>(pid): <span class="n">Promise</span>&lt;UnitsJson&gt;,
  <span class="n">readIntegrationSpec</span>(pid): <span class="n">Promise</span>&lt;IntegrationSpec&gt;,

  <span class="c">// 写(自动归档)</span>
  <span class="n">writePRD</span>(pid, html): <span class="n">Promise</span>&lt;<span class="p">{</span> rev: <span class="n">number</span> <span class="p">}</span>&gt;,
  <span class="n">writeSDD</span>(pid, html): <span class="n">Promise</span>&lt;<span class="p">{</span> rev: <span class="n">number</span> <span class="p">}</span>&gt;,
  <span class="n">writeUnits</span>(pid, json): <span class="n">Promise</span>&lt;<span class="p">{</span> rev: <span class="n">number</span> <span class="p">}</span>&gt;,
  <span class="n">writeIntegrationSpec</span>(pid, json): <span class="n">Promise</span>&lt;<span class="n">void</span>&gt;,

  <span class="c">// 校验</span>
  <span class="n">validateHTML</span>(html, kind): <span class="p">{</span> ok: <span class="n">boolean</span>, errors: <span class="n">string</span>[] <span class="p">}</span>,
  <span class="n">validateUnitsSchema</span>(json): <span class="p">{</span> ok: <span class="n">boolean</span>, errors: <span class="n">string</span>[] <span class="p">}</span>,

  <span class="c">// diff / 归档</span>
  <span class="n">diffPRD</span>(pid, fromRev, toRev): SectionDiff[],
  <span class="n">archive</span>(pid, kind, oldContent): <span class="n">Promise</span>&lt;<span class="n">void</span>&gt;,

  <span class="c">// 工具</span>
  <span class="n">hashContract</span>(contractText): <span class="n">string</span>,    <span class="c">// → 'sha256:...'</span>
<span class="p">}</span>;</pre>
        <ul class="contract">
          <li>writeXxx 调用前先 validate;不通过抛 ValidationError</li>
          <li>写入前自动归档旧版到 history/</li>
          <li>所有方法可并发安全(用文件锁或队列)</li>
        </ul>
      </section>

      <section data-def-name="UnitRegistry" data-mvcp="control">
        <h2>C-10 · UnitRegistry</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/services/unit-registry.js</code>
<pre class="code shape"><span class="k">module.exports</span>.unitRegistry = <span class="p">{</span>
  <span class="n">list</span>(pid): Unit[],
  <span class="n">pending</span>(pid): Unit[],
  <span class="n">passed</span>(pid): Unit[],
  <span class="n">blocked</span>(pid): Unit[],
  <span class="n">anyBlocked</span>(pid): <span class="n">boolean</span>,

  <span class="n">l2GateOpen</span>(pid): <span class="n">boolean</span>,

  <span class="n">markCoding</span>(pid, unitId): <span class="n">void</span>,
  <span class="n">markTesting</span>(pid, unitId): <span class="n">void</span>,
  <span class="n">pass</span>(pid, unitId, contractHash): <span class="n">void</span>,
  <span class="n">fail</span>(pid, unitId, reason): <span class="n">void</span>,
  <span class="n">block</span>(pid, unitId, reason): <span class="n">void</span>,
  <span class="n">reset</span>(pid, unitIds: <span class="n">string</span>[]): <span class="n">void</span>,

  <span class="n">diffWithSDD</span>(pid): <span class="p">{</span> added: <span class="n">string</span>[], changed: <span class="n">string</span>[], removed: <span class="n">string</span>[] <span class="p">}</span>,
<span class="p">}</span>;</pre>
        <ul class="contract">
          <li>所有 markXxx 内部走 FSM 校验(非法转换抛错)</li>
          <li>每次状态变化同步发 SSE <code>unit.state</code></li>
          <li>状态来源单一:units.json + 内存缓存</li>
        </ul>
      </section>

      <section data-def-name="SSEBroker" data-mvcp="control">
        <h2>C-11 · SSEBroker</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/services/sse-broker.js</code>
<pre class="code shape"><span class="k">module.exports</span>.sseBroker = <span class="p">{</span>
  <span class="n">subscribe</span>(pid: <span class="n">string</span>, res: <span class="n">ServerResponse</span>, opts?: <span class="p">{</span>
    replayFromOffset?: <span class="n">string</span>
  <span class="p">}</span>): <span class="n">void</span>,

  <span class="n">emit</span>(pid: <span class="n">string</span>, event: <span class="n">string</span>, data: any): <span class="n">void</span>,

  <span class="n">close</span>(pid: <span class="n">string</span>): <span class="n">void</span>,           <span class="c">// 项目结束</span>
<span class="p">}</span>;</pre>
        <ul class="contract">
          <li>同一 pid 多订阅者广播</li>
          <li>事件落盘到 <code>stream.log</code>,支持断线 replay</li>
          <li>每 25s 心跳</li>
        </ul>
        <p class="sig">SSE 事件枚举见 <a href="api-sse.html">api-sse</a>。</p>
      </section>

      <section data-def-name="Middleware" data-mvcp="control">
        <h2>C-12 · 中间件</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/middleware/</code>
<pre class="code shape"><span class="c">// 中间件标准签名 (Express 4)</span>
<span class="k">type</span> Middleware = (req: Request, res: Response, next: NextFunction) =&gt; <span class="n">void</span>;
<span class="c">// 4 个中间件:requireAuth · jsonSanitizer · jsonGuard(NEW) · tokenAccountant(NEW)</span></pre>
        <table class="dense">
          <thead><tr><th>name</th><th>path</th><th>用途</th></tr></thead>
          <tbody>
            <tr><td><code>requireAuth</code></td><td><code>server/middleware/auth.js</code> · 复用</td><td>JWT 校验 + req.user 注入</td></tr>
            <tr><td><code>jsonSanitizer</code></td><td><code>server/index.js</code> · 复用</td><td>清控制字符防 JSON.parse 崩</td></tr>
            <tr><td><code>jsonGuard</code> <span class="tag NEW">NEW</span></td><td><code>server/middleware/json-guard.js</code></td><td>Agent Bash 工具层检测 inline JSON 命令并拒绝</td></tr>
            <tr><td><code>tokenAccountant</code> <span class="tag NEW">NEW</span></td><td><code>server/middleware/token-accountant.js</code></td><td>每次 Agent 调用后累计 token_used,超 budget 阻断</td></tr>
          </tbody>
        </table>
      </section>

      <section data-def-name="AgentCallConvention" data-mvcp="control">
        <h2>C-13 · agentManager.callAgent 约定</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/src/core/agent-manager.js · 复用既有,不新增文件</code>
<pre class="code shape">agentManager.<span class="n">callAgent</span>(name: <span class="n">string</span>, input: <span class="p">{</span>
  pid:        <span class="n">string</span>,         <span class="c">// 项目 id</span>
  unit_id?:   <span class="n">string</span>,         <span class="c">// Coder/Tester 时必填</span>
  mode?:      <span class="s">'generate'</span> | <span class="s">'patch'</span>,  <span class="c">// Architect 专属</span>
  history?:   any[],            <span class="c">// retry 时上次失败</span>
  ...其余 Agent 特定参数
<span class="p">}</span>): <span class="n">Promise</span>&lt;AgentResult&gt;

<span class="c">// AgentResult 必须含:</span>
<span class="p">{</span>
  ok:           <span class="n">boolean</span>,
  outputs:      <span class="n">string</span>[],     <span class="c">// 写入的文件路径</span>
  duration_ms:  <span class="n">number</span>,
  tokens_used:  <span class="n">number</span>,
  error?:       <span class="n">string</span>,
<span class="p">}</span></pre>
        <ul class="contract">
          <li>SessionPool 按 <code>(name, cid, pid)</code> 复用会话</li>
          <li>每次调用先校验 name 在 _registry.json 中存在</li>
          <li>失败时 stderr 自动落 stream.log</li>
        </ul>
      </section>

      <section data-def-name="LoopController.getProgress" data-mvcp="control">
        <h2>C-14 · LoopController.getProgress(pid)</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/src/core/loop-controller.js · 新方法</code>
<pre class="code shape"><span class="c">// 聚合进度 API,Dashboard 主用</span>
<span class="n">getProgress</span>(pid: <span class="n">string</span>): <span class="p">{</span>
  percent:       <span class="n">number</span>,         <span class="c">// 0-100 整体加权进度</span>
  current_phase: <span class="s">'prd_drafting'</span> | <span class="s">'sdd_drafting'</span> | <span class="s">'L1'</span> | <span class="s">'L2'</span> | <span class="s">'shipped'</span> | <span class="s">'blocked'</span>,
  eta_seconds:   <span class="n">number</span>,         <span class="c">// 估算剩余秒,基于历史数据</span>
  breakdown: <span class="p">{</span>
    prd:    <span class="p">{</span> ok: <span class="n">boolean</span>, rev: <span class="n">number</span> <span class="p">}</span>,
    sdd:    <span class="p">{</span> ok: <span class="n">boolean</span>, rev: <span class="n">number</span> <span class="p">}</span>,
    units:  <span class="p">{</span> passed: <span class="n">number</span>, total: <span class="n">number</span>, failed: <span class="n">number</span>, blocked: <span class="n">number</span> <span class="p">}</span>,
    l2:     <span class="p">{</span> rounds: <span class="n">number</span>, budget: <span class="n">number</span> <span class="p">}</span>,
    tokens: <span class="p">{</span> used: <span class="n">number</span>, budget: <span class="n">number</span> <span class="p">}</span>,
  <span class="p">}</span>,
  active_agents: <span class="p">[{</span>                <span class="c">// 当前正在跑的 Agent</span>
    agent:    <span class="n">string</span>,
    unit_id?: <span class="n">string</span>,
    step:     <span class="n">string</span>,             <span class="c">// "3/5"</span>
    desc:     <span class="n">string</span>,             <span class="c">// 当前 todo item description</span>
    started_at: <span class="n">string</span>,
  <span class="p">}]</span>,
  recent_events: TimelineEvent[],   <span class="c">// 最近 10 条</span>
<span class="p">}</span></pre>
        <ul class="contract">
          <li>聚合自 UnitsJson · agent-state/*.json · timeline.ndjson 三处</li>
          <li>读时不加锁(毫秒级偏差可接受)</li>
          <li>percent 加权算法:PRD(10) + SDD(20) + Units(50) + L2(15) + Ship(5) = 100</li>
          <li>eta 基于历史项目均值 + 当前阶段剩余项数线性外推</li>
        </ul>
        <ul class="tests">
          <li>空项目 → percent=0, current_phase='prd_drafting'</li>
          <li>所有 unit passed 但 L2 未跑 → percent ≈ 80</li>
          <li>shipped → percent=100, eta_seconds=0</li>
        </ul>
      </section>

      <section data-def-name="TimelineLogger" data-mvcp="control">
        <h2>C-15 · TimelineLogger · 全流程日志写入</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/services/timeline-logger.js</code>
<pre class="code shape"><span class="k">module.exports</span>.timelineLogger = <span class="p">{</span>
  <span class="n">append</span>(pid: <span class="n">string</span>, event: <span class="p">{</span>
    source: <span class="n">string</span>, kind: <span class="n">string</span>,
    ref?: <span class="n">string</span>, summary: <span class="n">string</span>, payload?: <span class="n">object</span>
  <span class="p">}</span>): <span class="n">void</span>,

  <span class="n">read</span>(pid: <span class="n">string</span>, opts?: <span class="p">{</span>
    limit?: <span class="n">number</span>,      <span class="c">// 默认 100</span>
    since?: <span class="n">number</span>,      <span class="c">// unix ts</span>
    source?: <span class="n">string</span>,
    kind?: <span class="n">string</span>,
  <span class="p">}</span>): TimelineEvent[],

  <span class="n">tail</span>(pid: <span class="n">string</span>, n: <span class="n">number</span> = <span class="v">10</span>): TimelineEvent[],
<span class="p">}</span>;</pre>
        <ul class="contract">
          <li>append 是同步操作(fs.appendFileSync),确保事件原子写入</li>
          <li>ts 自动填当前时间,调用方不传</li>
          <li>summary &gt; 280 字符自动截断 + 省略号</li>
          <li>payload 太大(&gt; 4KB)→ 强制改为引用形式 <code>{ x_ref: "..." }</code></li>
          <li>read 用流式 readline 解析,不一次性 load 全文件</li>
        </ul>
      </section>

      <section data-def-name="BugTracker" data-mvcp="control">
        <h2>C-16 · BugTracker · bugs.ndjson CRUD</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/services/bug-tracker.js</code>
<pre class="code shape"><span class="k">module.exports</span>.bugTracker = <span class="p">{</span>
  <span class="c">// 写</span>
  <span class="n">open</span>(pid: <span class="n">string</span>, bug: <span class="n">Omit</span>&lt;Bug, <span class="s">'id'</span>|<span class="s">'discovered_at'</span>|<span class="s">'status'</span>|<span class="s">'status_history'</span>&gt;): Bug,
  <span class="n">transition</span>(pid: <span class="n">string</span>, bugId: <span class="n">string</span>, <span class="p">{</span>
    to:    BugStatus,
    by:    <span class="n">string</span>,
    note?: <span class="n">string</span>,
    fix_refs?: <span class="n">string</span>[],
  <span class="p">}</span>): <span class="n">void</span>,

  <span class="c">// 读 (流式 + 终态聚合)</span>
  <span class="n">list</span>(pid: <span class="n">string</span>, filter?: <span class="p">{</span>
    status?:   BugStatus[],
    severity?: <span class="n">string</span>[],
    unit_id?:  <span class="n">string</span>,
    since?:    <span class="n">string</span>,
  <span class="p">}</span>): Bug[],                          <span class="c">// 返回每个 bug.id 的最新态</span>

  <span class="n">history</span>(pid: <span class="n">string</span>, bugId: <span class="n">string</span>): BugStatusHistory[],

  <span class="n">stats</span>(pid: <span class="n">string</span>): <span class="p">{</span>
    open: <span class="n">number</span>, fixing: <span class="n">number</span>, fixed: <span class="n">number</span>,
    verified: <span class="n">number</span>, wontfix: <span class="n">number</span>,
    by_severity: <span class="n">Record</span>&lt;<span class="n">string</span>, <span class="n">number</span>&gt;,
  <span class="p">}</span>,

  <span class="c">// 工具</span>
  <span class="n">dedupKey</span>(bug: <span class="n">Partial</span>&lt;Bug&gt;): <span class="n">string</span>,  <span class="c">// "auth-be:login.invalid-pwd" 用于检测重复</span>
<span class="p">}</span>;</pre>
        <ul class="contract">
          <li>open() 内部先算 dedupKey,命中已存在 open/fixing 的 bug → 不重复,返回旧 id</li>
          <li>transition() 必经 P-9 状态机校验,非法转换抛错</li>
          <li>list() 内部流式读 NDJSON(不一次性加载)</li>
          <li>同时写入一条 timeline 事件 <code>{ kind: 'bug_opened'|'bug_transitioned', ref:bugId }</code></li>
          <li>所有写操作同步发 SSE <code>bug.*</code> 事件</li>
        </ul>
        <ul class="tests">
          <li>同一 test 两次失败 → dedupKey 命中,只有一条 bug</li>
          <li>transition open → verified(跳级)→ 抛错</li>
          <li>stats() 与 list() 长度一致</li>
        </ul>
      </section>

      <section data-def-name="ChangelogWriter" data-mvcp="control">
        <h2>C-17 · ChangelogWriter</h2>
        <code class="path mono" style="color:var(--ink-soft)">server/services/changelog-writer.js</code>
<pre class="code shape"><span class="k">module.exports</span>.changelogWriter = <span class="p">{</span>
  <span class="n">append</span>(pid: <span class="n">string</span>, <span class="p">{</span>
    kind, rev, by, trigger, summary, diff, archive_ref
  <span class="p">}</span>): <span class="n">void</span>,

  <span class="n">read</span>(pid: <span class="n">string</span>, opts?: <span class="p">{</span> limit?, kind?, since? <span class="p">}</span>): ChangelogEntry[],

  <span class="n">latest</span>(pid: <span class="n">string</span>, kind: <span class="n">string</span>): ChangelogEntry | <span class="n">null</span>,
<span class="p">}</span>;

<span class="c">// 被 DocStore 内部调用</span>
<span class="c">// DocStore.writePRD/writeSDD 成功后 → changelogWriter.append</span></pre>
        <ul class="contract">
          <li>由 DocStore 内部触发,外部不直接调</li>
          <li>diff 字段计算由 DocStore 提供(cheerio 算 section 差异 + 拓扑算 units 差异)</li>
        </ul>
      </section>

      <section>
        <h2>端点-handler-服务依赖矩阵</h2>
        <table class="dense">
          <thead><tr><th>端点</th><th>LoopController</th><th>DocStore</th><th>UnitRegistry</th><th>SSEBroker</th><th>agentManager</th></tr></thead>
          <tbody>
            <tr><td>POST /projects</td><td>createProject + onHumanFeedback</td><td>—</td><td>—</td><td>—</td><td>(异步)</td></tr>
            <tr><td>POST /requirement</td><td>onHumanFeedback</td><td>(异步)</td><td>(异步)</td><td>(异步)</td><td>callAgent(pm)</td></tr>
            <tr><td>POST /run</td><td>runProject</td><td>read*</td><td>list + pending</td><td>—</td><td>callAgent(*)</td></tr>
            <tr><td>GET /state</td><td>getState</td><td>readUnits</td><td>list</td><td>—</td><td>—</td></tr>
            <tr><td>GET /stream</td><td>—</td><td>—</td><td>—</td><td>subscribe</td><td>—</td></tr>
            <tr><td>POST /sign-off</td><td>signOff</td><td>—</td><td>—</td><td>emit close</td><td>—</td></tr>
            <tr><td>POST /abandon</td><td>abandon</td><td>—</td><td>—</td><td>emit close</td><td>—</td></tr>
          </tbody>
        </table>
      </section>

    </article>
    <nav class="prev-next"></nav>
    <footer class="site"><div><b>Section</b>03 · SDD</div><div><b>Page</b>sdd-control.html</div></footer>
  </main>
</div>
<script src="assets/shell.js"></script>
</body>
</html>