baa-conductor

git clone 

commit
ca9ac1f
parent
6e1864f
author
im_wower
date
2026-03-21 23:52:07 +0800 CST
Merge branch 'feat/T-010-status-api'
7 files changed,  +625, -31
A apps/status-api/src/contracts.ts
+63, -0
 1@@ -0,0 +1,63 @@
 2+import type { AutomationMode } from "../../../packages/db/src/index.js";
 3+
 4+export type StatusSnapshotSource = "empty" | "d1";
 5+export type StatusApiRouteMethod = "GET";
 6+
 7+export interface StatusSnapshot {
 8+  source: StatusSnapshotSource;
 9+  mode: AutomationMode;
10+  leaderId: string | null;
11+  leaderHost: string | null;
12+  leaseTerm: number | null;
13+  leaseExpiresAt: string | null;
14+  leaseActive: boolean;
15+  queueDepth: number;
16+  activeRuns: number;
17+  updatedAt: string;
18+  observedAt: string;
19+}
20+
21+export interface StatusSnapshotLoader {
22+  loadSnapshot(): Promise<StatusSnapshot>;
23+}
24+
25+export interface StatusApiRoute {
26+  method: StatusApiRouteMethod;
27+  path: string;
28+  summary: string;
29+  contentType: "application/json" | "text/html" | "text/plain";
30+}
31+
32+export interface StatusApiRequest {
33+  method: string;
34+  path: string;
35+}
36+
37+export interface StatusApiResponse {
38+  status: number;
39+  headers: Record<string, string>;
40+  body: string;
41+}
42+
43+export interface StatusApiHandler {
44+  routes: StatusApiRoute[];
45+  handle(request: StatusApiRequest): Promise<StatusApiResponse>;
46+}
47+
48+export function createEmptyStatusSnapshot(now: Date = new Date()): StatusSnapshot {
49+  const observedAt = now.toISOString();
50+
51+  return {
52+    source: "empty",
53+    mode: "paused",
54+    leaderId: null,
55+    leaderHost: null,
56+    leaseTerm: null,
57+    leaseExpiresAt: null,
58+    leaseActive: false,
59+    queueDepth: 0,
60+    activeRuns: 0,
61+    updatedAt: observedAt,
62+    observedAt
63+  };
64+}
A apps/status-api/src/data-source.ts
+122, -0
  1@@ -0,0 +1,122 @@
  2+import {
  3+  D1ControlPlaneRepository,
  4+  type AutomationStateRecord,
  5+  type D1DatabaseLike,
  6+  type LeaderLeaseRecord
  7+} from "../../../packages/db/src/index.js";
  8+import { createEmptyStatusSnapshot, type StatusSnapshot, type StatusSnapshotLoader } from "./contracts.js";
  9+
 10+const SELECT_QUEUED_TASK_COUNT_SQL = `
 11+  SELECT COUNT(*) AS value
 12+  FROM tasks
 13+  WHERE status = 'queued'
 14+`;
 15+
 16+const SELECT_ACTIVE_RUN_COUNT_SQL = `
 17+  SELECT COUNT(*) AS value
 18+  FROM task_runs
 19+  WHERE started_at IS NOT NULL
 20+    AND finished_at IS NULL
 21+`;
 22+
 23+export interface StatusSnapshotSourceReader {
 24+  countActiveRuns(): Promise<number>;
 25+  countQueuedTasks(): Promise<number>;
 26+  getAutomationState(): Promise<AutomationStateRecord | null>;
 27+  getCurrentLease(): Promise<LeaderLeaseRecord | null>;
 28+}
 29+
 30+export class StaticStatusSnapshotLoader implements StatusSnapshotLoader {
 31+  constructor(private readonly snapshot: StatusSnapshot = createEmptyStatusSnapshot()) {}
 32+
 33+  async loadSnapshot(): Promise<StatusSnapshot> {
 34+    return this.snapshot;
 35+  }
 36+}
 37+
 38+export class D1StatusSnapshotLoader implements StatusSnapshotLoader {
 39+  private readonly repository: D1ControlPlaneRepository;
 40+
 41+  constructor(
 42+    private readonly db: D1DatabaseLike,
 43+    private readonly now: () => Date = () => new Date()
 44+  ) {
 45+    this.repository = new D1ControlPlaneRepository(db);
 46+  }
 47+
 48+  async loadSnapshot(): Promise<StatusSnapshot> {
 49+    return readStatusSnapshot(
 50+      {
 51+        countActiveRuns: () => countRows(this.db, SELECT_ACTIVE_RUN_COUNT_SQL),
 52+        countQueuedTasks: () => countRows(this.db, SELECT_QUEUED_TASK_COUNT_SQL),
 53+        getAutomationState: () => this.repository.getAutomationState(),
 54+        getCurrentLease: () => this.repository.getCurrentLease()
 55+      },
 56+      this.now()
 57+    );
 58+  }
 59+}
 60+
 61+export async function createStatusSnapshotFromDatabase(
 62+  db: D1DatabaseLike,
 63+  observedAt: Date = new Date()
 64+): Promise<StatusSnapshot> {
 65+  return new D1StatusSnapshotLoader(db, () => observedAt).loadSnapshot();
 66+}
 67+
 68+export async function readStatusSnapshot(
 69+  source: StatusSnapshotSourceReader,
 70+  observedAt: Date = new Date()
 71+): Promise<StatusSnapshot> {
 72+  const fallback = createEmptyStatusSnapshot(observedAt);
 73+  const [automationState, lease, queueDepth, activeRuns] = await Promise.all([
 74+    source.getAutomationState(),
 75+    source.getCurrentLease(),
 76+    source.countQueuedTasks(),
 77+    source.countActiveRuns()
 78+  ]);
 79+
 80+  const observedAtUnixSeconds = toUnixSeconds(observedAt);
 81+  const latestDurableTimestamp = Math.max(automationState?.updatedAt ?? 0, lease?.renewedAt ?? 0);
 82+
 83+  return {
 84+    source: "d1",
 85+    mode: automationState?.mode ?? fallback.mode,
 86+    leaderId: lease?.holderId ?? null,
 87+    leaderHost: lease?.holderHost ?? null,
 88+    leaseTerm: lease?.term ?? null,
 89+    leaseExpiresAt: toIsoFromUnixSeconds(lease?.leaseExpiresAt),
 90+    leaseActive: lease != null && lease.leaseExpiresAt > observedAtUnixSeconds,
 91+    queueDepth,
 92+    activeRuns,
 93+    updatedAt:
 94+      latestDurableTimestamp > 0 ? toIsoFromUnixSeconds(latestDurableTimestamp) ?? fallback.updatedAt : fallback.updatedAt,
 95+    observedAt: fallback.observedAt
 96+  };
 97+}
 98+
 99+async function countRows(db: D1DatabaseLike, sql: string): Promise<number> {
100+  const value = await db.prepare(sql).first<number>("value");
101+
102+  if (value == null) {
103+    return 0;
104+  }
105+
106+  if (!Number.isFinite(value) || value < 0) {
107+    throw new TypeError(`Expected count query to return a non-negative finite number, received "${String(value)}".`);
108+  }
109+
110+  return value;
111+}
112+
113+function toUnixSeconds(date: Date): number {
114+  return Math.floor(date.getTime() / 1000);
115+}
116+
117+function toIsoFromUnixSeconds(value: number | null | undefined): string | null {
118+  if (value == null) {
119+    return null;
120+  }
121+
122+  return new Date(value * 1000).toISOString();
123+}
M apps/status-api/src/index.ts
+4, -20
 1@@ -1,20 +1,4 @@
 2-export type AutomationMode = "running" | "draining" | "paused";
 3-
 4-export interface StatusSnapshot {
 5-  leaderHost: string | null;
 6-  mode: AutomationMode;
 7-  queueDepth: number;
 8-  activeRuns: number;
 9-  updatedAt: string;
10-}
11-
12-export function createEmptyStatusSnapshot(): StatusSnapshot {
13-  return {
14-    leaderHost: null,
15-    mode: "paused",
16-    queueDepth: 0,
17-    activeRuns: 0,
18-    updatedAt: "1970-01-01T00:00:00.000Z"
19-  };
20-}
21-
22+export * from "./contracts.js";
23+export * from "./data-source.js";
24+export * from "./render.js";
25+export * from "./service.js";
A apps/status-api/src/render.ts
+299, -0
  1@@ -0,0 +1,299 @@
  2+import type { StatusSnapshot } from "./contracts.js";
  3+
  4+export function renderStatusPage(snapshot: StatusSnapshot): string {
  5+  const modeLabel = formatMode(snapshot.mode);
  6+  const leaderLabel = snapshot.leaderHost ?? "No active leader lease";
  7+  const leaderDetail = snapshot.leaderId == null ? "Waiting for a holder in D1." : snapshot.leaderId;
  8+  const leaseLabel = snapshot.leaseExpiresAt == null ? "No lease expiry" : formatTimestamp(snapshot.leaseExpiresAt);
  9+  const leaseDetail = snapshot.leaseActive ? "Lease is currently valid." : "Lease is missing or stale.";
 10+
 11+  return `<!doctype html>
 12+<html lang="en">
 13+  <head>
 14+    <meta charset="utf-8" />
 15+    <meta name="viewport" content="width=device-width, initial-scale=1" />
 16+    <title>BAA Conductor Status</title>
 17+    <style>
 18+      :root {
 19+        color-scheme: light;
 20+        --bg: #f4ead7;
 21+        --bg-deep: #e8d7b8;
 22+        --panel: rgba(255, 250, 241, 0.88);
 23+        --panel-strong: #fff7ea;
 24+        --ink: #1e1a15;
 25+        --muted: #6d6155;
 26+        --line: rgba(30, 26, 21, 0.12);
 27+        --accent: #005f73;
 28+        --accent-soft: rgba(0, 95, 115, 0.12);
 29+        --success: #2d6a4f;
 30+        --warning: #9a3412;
 31+        --shadow: 0 20px 60px rgba(83, 62, 35, 0.14);
 32+      }
 33+
 34+      * {
 35+        box-sizing: border-box;
 36+      }
 37+
 38+      body {
 39+        margin: 0;
 40+        min-height: 100vh;
 41+        font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
 42+        color: var(--ink);
 43+        background:
 44+          radial-gradient(circle at top left, rgba(255, 255, 255, 0.55), transparent 38%),
 45+          radial-gradient(circle at bottom right, rgba(0, 95, 115, 0.08), transparent 28%),
 46+          linear-gradient(135deg, var(--bg), var(--bg-deep));
 47+      }
 48+
 49+      body::before {
 50+        content: "";
 51+        position: fixed;
 52+        inset: 0;
 53+        pointer-events: none;
 54+        background-image:
 55+          linear-gradient(rgba(30, 26, 21, 0.03) 1px, transparent 1px),
 56+          linear-gradient(90deg, rgba(30, 26, 21, 0.03) 1px, transparent 1px);
 57+        background-size: 26px 26px;
 58+        mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent 85%);
 59+      }
 60+
 61+      main {
 62+        width: min(1040px, calc(100% - 32px));
 63+        margin: 0 auto;
 64+        padding: 48px 0 56px;
 65+      }
 66+
 67+      .hero,
 68+      .card,
 69+      .footer {
 70+        backdrop-filter: blur(14px);
 71+        background: var(--panel);
 72+        border: 1px solid var(--line);
 73+        box-shadow: var(--shadow);
 74+      }
 75+
 76+      .hero {
 77+        padding: 28px;
 78+        border-radius: 28px;
 79+        margin-bottom: 18px;
 80+      }
 81+
 82+      .eyebrow,
 83+      .detail,
 84+      .meta {
 85+        font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
 86+      }
 87+
 88+      .eyebrow {
 89+        margin: 0 0 14px;
 90+        color: var(--accent);
 91+        text-transform: uppercase;
 92+        letter-spacing: 0.16em;
 93+        font-size: 12px;
 94+      }
 95+
 96+      h1 {
 97+        margin: 0;
 98+        font-size: clamp(34px, 5vw, 62px);
 99+        line-height: 0.96;
100+        max-width: 12ch;
101+      }
102+
103+      .hero-copy {
104+        margin: 16px 0 0;
105+        max-width: 56ch;
106+        color: var(--muted);
107+        font-size: 18px;
108+        line-height: 1.55;
109+      }
110+
111+      .hero-strip {
112+        display: flex;
113+        flex-wrap: wrap;
114+        gap: 10px;
115+        margin-top: 22px;
116+      }
117+
118+      .pill {
119+        display: inline-flex;
120+        align-items: center;
121+        gap: 8px;
122+        padding: 10px 14px;
123+        border-radius: 999px;
124+        font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
125+        font-size: 13px;
126+        background: var(--panel-strong);
127+        border: 1px solid var(--line);
128+      }
129+
130+      .pill::before {
131+        content: "";
132+        width: 9px;
133+        height: 9px;
134+        border-radius: 50%;
135+        background: var(--accent);
136+      }
137+
138+      .pill.running::before {
139+        background: var(--success);
140+      }
141+
142+      .pill.draining::before {
143+        background: var(--warning);
144+      }
145+
146+      .pill.paused::before {
147+        background: var(--muted);
148+      }
149+
150+      .grid {
151+        display: grid;
152+        grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
153+        gap: 16px;
154+      }
155+
156+      .card {
157+        border-radius: 22px;
158+        padding: 20px;
159+      }
160+
161+      .label {
162+        margin: 0 0 18px;
163+        color: var(--muted);
164+        font-size: 14px;
165+        letter-spacing: 0.08em;
166+        text-transform: uppercase;
167+      }
168+
169+      .value {
170+        margin: 0;
171+        font-size: clamp(30px, 4vw, 42px);
172+        line-height: 1.05;
173+      }
174+
175+      .detail {
176+        margin: 12px 0 0;
177+        color: var(--muted);
178+        font-size: 13px;
179+        line-height: 1.55;
180+        word-break: break-word;
181+      }
182+
183+      .footer {
184+        margin-top: 18px;
185+        border-radius: 22px;
186+        padding: 18px 20px;
187+        display: grid;
188+        gap: 8px;
189+      }
190+
191+      .meta {
192+        margin: 0;
193+        font-size: 13px;
194+        color: var(--muted);
195+      }
196+
197+      .accent-panel {
198+        background:
199+          linear-gradient(160deg, var(--accent-soft), rgba(255, 255, 255, 0)),
200+          var(--panel);
201+      }
202+
203+      @media (max-width: 640px) {
204+        main {
205+          width: min(100% - 20px, 1040px);
206+          padding-top: 20px;
207+          padding-bottom: 28px;
208+        }
209+
210+        .hero,
211+        .card,
212+        .footer {
213+          border-radius: 20px;
214+        }
215+      }
216+    </style>
217+  </head>
218+  <body>
219+    <main>
220+      <section class="hero">
221+        <p class="eyebrow">BAA Conductor / Status Surface</p>
222+        <h1>Readable automation state for people and browser controls.</h1>
223+        <p class="hero-copy">
224+          This page is the minimal control-facing surface for the conductor stack. It exposes the four fields the browser
225+          panel needs first: mode, leader, queue depth, and active runs.
226+        </p>
227+        <div class="hero-strip">
228+          <span class="pill ${escapeHtml(snapshot.mode)}">${escapeHtml(modeLabel)}</span>
229+          <span class="pill">${escapeHtml(snapshot.source.toUpperCase())} snapshot</span>
230+          <span class="pill">${escapeHtml(formatTimestamp(snapshot.observedAt))}</span>
231+        </div>
232+      </section>
233+
234+      <section class="grid" aria-label="status metrics">
235+        ${renderMetricCard("Mode", modeLabel, describeMode(snapshot.mode), true)}
236+        ${renderMetricCard("Leader", leaderLabel, leaderDetail)}
237+        ${renderMetricCard("Queue Depth", String(snapshot.queueDepth), "Queued tasks waiting to be claimed.")}
238+        ${renderMetricCard("Active Runs", String(snapshot.activeRuns), "Task runs with a started timestamp and no finish timestamp.")}
239+        ${renderMetricCard("Lease Expiry", leaseLabel, leaseDetail)}
240+        ${renderMetricCard("Snapshot Updated", formatTimestamp(snapshot.updatedAt), "Latest durable timestamp observed from automation state or lease renewal.")}
241+      </section>
242+
243+      <section class="footer">
244+        <p class="meta">JSON endpoint: <strong>/v1/status</strong></p>
245+        <p class="meta">HTML endpoint: <strong>/</strong> or <strong>/v1/status/ui</strong></p>
246+        <p class="meta">Observed at: ${escapeHtml(formatTimestamp(snapshot.observedAt))}</p>
247+      </section>
248+    </main>
249+  </body>
250+</html>`;
251+}
252+
253+function renderMetricCard(label: string, value: string, detail: string, accent = false): string {
254+  return `<article class="card${accent ? " accent-panel" : ""}">
255+    <p class="label">${escapeHtml(label)}</p>
256+    <p class="value">${escapeHtml(value)}</p>
257+    <p class="detail">${escapeHtml(detail)}</p>
258+  </article>`;
259+}
260+
261+function describeMode(mode: StatusSnapshot["mode"]): string {
262+  switch (mode) {
263+    case "running":
264+      return "Workers may claim new work and continue normal scheduling.";
265+    case "draining":
266+      return "Existing work continues, but the leader should stop launching new steps.";
267+    case "paused":
268+      return "Automation is paused until a control-plane resume action is issued.";
269+  }
270+}
271+
272+function formatMode(mode: StatusSnapshot["mode"]): string {
273+  switch (mode) {
274+    case "running":
275+      return "Running";
276+    case "draining":
277+      return "Draining";
278+    case "paused":
279+      return "Paused";
280+  }
281+}
282+
283+function formatTimestamp(value: string): string {
284+  const date = new Date(value);
285+
286+  if (Number.isNaN(date.getTime())) {
287+    return value;
288+  }
289+
290+  return date.toISOString().replace(".000Z", "Z");
291+}
292+
293+function escapeHtml(value: string): string {
294+  return value
295+    .replaceAll("&", "&amp;")
296+    .replaceAll("<", "&lt;")
297+    .replaceAll(">", "&gt;")
298+    .replaceAll('"', "&quot;")
299+    .replaceAll("'", "&#39;");
300+}
A apps/status-api/src/service.ts
+115, -0
  1@@ -0,0 +1,115 @@
  2+import {
  3+  type StatusApiHandler,
  4+  type StatusApiRequest,
  5+  type StatusApiResponse,
  6+  type StatusApiRoute,
  7+  type StatusSnapshotLoader
  8+} from "./contracts.js";
  9+import { renderStatusPage } from "./render.js";
 10+
 11+const JSON_HEADERS = {
 12+  "content-type": "application/json; charset=utf-8",
 13+  "cache-control": "no-store"
 14+} as const;
 15+
 16+const HTML_HEADERS = {
 17+  "content-type": "text/html; charset=utf-8",
 18+  "cache-control": "no-store"
 19+} as const;
 20+
 21+const TEXT_HEADERS = {
 22+  "content-type": "text/plain; charset=utf-8",
 23+  "cache-control": "no-store"
 24+} as const;
 25+
 26+export const STATUS_API_ROUTES: StatusApiRoute[] = [
 27+  { method: "GET", path: "/healthz", summary: "状态服务健康检查", contentType: "text/plain" },
 28+  { method: "GET", path: "/v1/status", summary: "读取全局自动化状态快照", contentType: "application/json" },
 29+  { method: "GET", path: "/v1/status/ui", summary: "读取最小 HTML 状态面板", contentType: "text/html" },
 30+  { method: "GET", path: "/", summary: "最小状态面板首页", contentType: "text/html" }
 31+];
 32+
 33+export function describeStatusApiSurface(): string[] {
 34+  return STATUS_API_ROUTES.map((route) => `${route.method} ${route.path} - ${route.summary}`);
 35+}
 36+
 37+export function createStatusApiHandler(snapshotLoader: StatusSnapshotLoader): StatusApiHandler {
 38+  return {
 39+    routes: STATUS_API_ROUTES,
 40+    handle: (request) => handleStatusApiRequest(request, snapshotLoader)
 41+  };
 42+}
 43+
 44+export async function handleStatusApiRequest(
 45+  request: StatusApiRequest,
 46+  snapshotLoader: StatusSnapshotLoader
 47+): Promise<StatusApiResponse> {
 48+  const method = request.method.toUpperCase();
 49+  const path = normalizePath(request.path);
 50+
 51+  if (method !== "GET") {
 52+    return jsonResponse(
 53+      405,
 54+      {
 55+        ok: false,
 56+        error: "method_not_allowed",
 57+        message: "Status API is read-only and only accepts GET requests."
 58+      },
 59+      { Allow: "GET" }
 60+    );
 61+  }
 62+
 63+  if (path === "/healthz") {
 64+    return {
 65+      status: 200,
 66+      headers: { ...TEXT_HEADERS },
 67+      body: "ok"
 68+    };
 69+  }
 70+
 71+  const snapshot = await snapshotLoader.loadSnapshot();
 72+
 73+  if (path === "/" || path === "/ui" || path === "/v1/status/ui") {
 74+    return {
 75+      status: 200,
 76+      headers: { ...HTML_HEADERS },
 77+      body: renderStatusPage(snapshot)
 78+    };
 79+  }
 80+
 81+  if (path === "/v1/status") {
 82+    return jsonResponse(200, {
 83+      ok: true,
 84+      data: snapshot
 85+    });
 86+  }
 87+
 88+  return jsonResponse(404, {
 89+    ok: false,
 90+    error: "not_found",
 91+    message: `No status route matches "${path}".`
 92+  });
 93+}
 94+
 95+function jsonResponse(
 96+  status: number,
 97+  payload: unknown,
 98+  extraHeaders: Record<string, string> = {}
 99+): StatusApiResponse {
100+  return {
101+    status,
102+    headers: {
103+      ...JSON_HEADERS,
104+      ...extraHeaders
105+    },
106+    body: `${JSON.stringify(payload, null, 2)}\n`
107+  };
108+}
109+
110+function normalizePath(value: string): string {
111+  const baseUrl = "http://status.local";
112+  const url = new URL(value || "/", baseUrl);
113+  const normalized = url.pathname.replace(/\/+$/, "");
114+
115+  return normalized === "" ? "/" : normalized;
116+}
M apps/status-api/tsconfig.json
+2, -3
 1@@ -1,9 +1,8 @@
 2 {
 3   "extends": "../../tsconfig.base.json",
 4   "compilerOptions": {
 5-    "rootDir": "src",
 6+    "rootDir": "../..",
 7     "outDir": "dist"
 8   },
 9-  "include": ["src/**/*.ts"]
10+  "include": ["src/**/*.ts", "../../packages/db/src/**/*.ts"]
11 }
12-
M coordination/tasks/T-010-status-api.md
+20, -8
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-010
 4 title: Status API 与基础 UI
 5-status: todo
 6+status: review
 7 branch: feat/T-010-status-api
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main@28829de
10+base_ref: main@56e9a4f
11 depends_on:
12   - T-003
13   - T-004
14@@ -21,7 +21,7 @@ updated_at: 2026-03-21
15 
16 ## 统一开工要求
17 
18-- 必须从 `main@28829de` 切出该分支
19+- 必须从 `main@56e9a4f` 切出该分支
20 - 新 worktree 进入后先执行 `npx --yes pnpm install`
21 - 不允许从其他任务分支切分支
22 
23@@ -52,24 +52,36 @@ updated_at: 2026-03-21
24 
25 ## files_changed
26 
27-- 待填写
28+- `apps/status-api/src/contracts.ts`
29+- `apps/status-api/src/data-source.ts`
30+- `apps/status-api/src/render.ts`
31+- `apps/status-api/src/service.ts`
32+- `apps/status-api/src/index.ts`
33+- `apps/status-api/tsconfig.json`
34+- `coordination/tasks/T-010-status-api.md`
35 
36 ## commands_run
37 
38-- 待填写
39+- `npx --yes pnpm install`
40+- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
41+- `npx --yes tsx -e '...'`
42 
43 ## result
44 
45-- 待填写
46+- 已实现最小 status snapshot 契约,包含 `mode`、`leader`、`queueDepth`、`activeRuns`、lease 元数据和观测时间。
47+- 已实现基于 D1 的只读快照加载器,读取 automation state、leader lease、queued task 数、active run 数。
48+- 已实现最小状态 API/渲染骨架,支持 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui` 与 `/` HTML 面板。
49 
50 ## risks
51 
52-- 待填写
53+- 目前只交付包内 handler 与渲染层,尚未绑定真实 HTTP server;后续需要由整合者或依赖任务接入运行时。
54+- `queueDepth` 当前按 `tasks.status = 'queued'` 统计,`activeRuns` 按 `task_runs.started_at IS NOT NULL AND finished_at IS NULL` 统计;如果后续运行态枚举收敛,需要一起校准。
55 
56 ## next_handoff
57 
58-- 提供给浏览器控制面或 CLI 面板使用
59+- 将 `D1StatusSnapshotLoader` 接到真实 `D1DatabaseLike`,再把 `createStatusApiHandler()` 挂到 status-api 运行时或 control/browse 面板入口。
60 
61 ## notes
62 
63 - `2026-03-21`: 创建任务卡
64+- `2026-03-21`: 从 `main@56e9a4f` 建立独立 worktree,完成状态 API 与最小 HTML 面板骨架并进入 review。