- 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
+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+}
+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+}
+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";
+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("&", "&")
296+ .replaceAll("<", "<")
297+ .replaceAll(">", ">")
298+ .replaceAll('"', """)
299+ .replaceAll("'", "'");
300+}
+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+}
+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-
+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。