- commit
- 37f4a25
- parent
- 5e7ea0f
- author
- im_wower
- date
- 2026-03-22 16:43:00 +0800 CST
Add describe and read-only control API surfaces
15 files changed,
+1523,
-69
+28,
-0
1@@ -17,6 +17,7 @@
2 2. [`coordination/TASK_OVERVIEW.md`](./coordination/TASK_OVERVIEW.md)
3 3. [`docs/runtime/README.md`](./docs/runtime/README.md)
4 4. [`docs/ops/README.md`](./docs/ops/README.md)
5+5. [`docs/api/README.md`](./docs/api/README.md)
6
7 ## 当前目录结构
8
9@@ -63,6 +64,33 @@ docs/
10 - 历史主备资料不再在主线保留,靠 tag 回溯
11 - 当前仓库优先做运维、修补和最小必要的控制面维护
12
13+## HTTP 入口
14+
15+当前公开 HTTP 入口只有两类:
16+
17+| 入口 | 地址 | 职责 | 首个请求 |
18+| --- | --- | --- | --- |
19+| control-api | `https://control-api.makefile.so` | AI / 浏览器 / 运维的控制面与只读查询面 | `GET /describe` |
20+| status-api | `https://conductor.makefile.so` | 只读状态视图与 HTML 状态页 | `GET /describe` |
21+
22+推荐工作流:
23+
24+1. 先请求 `GET /describe`
25+2. 再看 `endpoints` / `capabilities` / `examples`
26+3. 先用只读接口,再决定是否调用写接口
27+
28+control-api 当前新增的可发现性与只读接口包括:
29+
30+- `GET /describe`
31+- `GET /version`
32+- `GET /health`
33+- `GET /v1/capabilities`
34+- `GET /v1/controllers`
35+- `GET /v1/tasks`
36+- `GET /v1/runs`
37+
38+更完整的接口说明和 curl 示例见 [`docs/api/README.md`](./docs/api/README.md)。
39+
40 ## 当前最重要的事
41
42 - 保持 `mini` launchd、自启动和本地探针稳定
+23,
-3
1@@ -31,10 +31,26 @@ export const CONTROL_API_SECRET_ENV_NAMES = [
2 ] as const;
3
4 export type ControlApiRouteMethod = "GET" | "POST";
5+export type ControlApiRouteAccess = "public" | "protected";
6+export type ControlApiRouteCategory =
7+ | "discoverability"
8+ | "controllers"
9+ | "leader"
10+ | "tasks"
11+ | "steps"
12+ | "system"
13+ | "runs";
14+export type ControlApiRouteImplementation = "implemented" | "placeholder";
15
16 export type ControlApiRouteId =
17+ | "service.describe"
18+ | "service.version"
19+ | "service.health"
20+ | "system.capabilities"
21+ | "controllers.list"
22 | "controllers.heartbeat"
23 | "leader.acquire"
24+ | "tasks.list"
25 | "tasks.create"
26 | "tasks.plan"
27 | "tasks.claim"
28@@ -48,6 +64,7 @@ export type ControlApiRouteId =
29 | "system.state"
30 | "tasks.read"
31 | "tasks.logs.read"
32+ | "runs.list"
33 | "runs.read";
34
35 export interface ControlApiRouteSchemaDescriptor {
36@@ -85,8 +102,8 @@ export interface ControlApiOwnershipResolverInput {
37 }
38
39 export interface ControlApiRouteAuthorization {
40- mode: "skipped" | "verified";
41- rule: ControlApiAuthRule;
42+ mode: "public" | "skipped" | "verified";
43+ rule: ControlApiAuthRule | null;
44 principal?: AuthPrincipal;
45 skipReason?: string;
46 }
47@@ -147,7 +164,10 @@ export interface ControlApiRouteDefinition {
48 method: ControlApiRouteMethod;
49 pathPattern: string;
50 summary: string;
51- authRule: ControlApiAuthRule;
52+ category: ControlApiRouteCategory;
53+ access: ControlApiRouteAccess;
54+ implementation: ControlApiRouteImplementation;
55+ authRule: ControlApiAuthRule | null;
56 schema: ControlApiRouteSchemaDescriptor;
57 ownershipResolver?: (
58 input: ControlApiOwnershipResolverInput
+765,
-26
1@@ -1,24 +1,63 @@
2-import { findControlApiAuthRule } from "@baa-conductor/auth";
3 import {
4+ AUTH_ACTION_DESCRIPTORS,
5+ AUTH_ROLE_BOUNDARIES,
6+ findControlApiAuthRule,
7+ getAllowedRolesForAction
8+} from "@baa-conductor/auth";
9+import {
10+ AUTOMATION_STATE_KEY,
11 DEFAULT_AUTOMATION_MODE,
12+ TASK_STATUS_VALUES,
13+ parseJsonText,
14 stringifyJson,
15 type AutomationMode,
16+ type ControlPlaneRepository,
17+ type ControllerRecord,
18 type JsonObject,
19- type JsonValue
20+ type JsonValue,
21+ type TaskRecord,
22+ type TaskRunRecord,
23+ type TaskStatus
24 } from "@baa-conductor/db";
25 import type {
26 ControlApiHandlerFailure,
27 ControlApiHandlerResult,
28 ControlApiOwnershipResolverInput,
29+ ControlApiRouteAccess,
30 ControlApiRouteContext,
31 ControlApiRouteDefinition,
32 ControlApiRouteHandler,
33 ControlApiRouteMethod
34 } from "./contracts.js";
35-import { CONTROL_API_D1_BINDING_NAME } from "./contracts.js";
36+import {
37+ CONTROL_API_AUTH_REQUIRED_ENV_NAME,
38+ CONTROL_API_CUSTOM_DOMAIN,
39+ CONTROL_API_D1_BINDING_NAME,
40+ CONTROL_API_VERSION_ENV_NAME,
41+ CONTROL_API_WORKER_NAME
42+} from "./contracts.js";
43 import { CONTROL_API_ROUTE_SCHEMAS } from "./schemas.js";
44
45 const DEFAULT_TASK_PRIORITY = 50;
46+const DEFAULT_LIST_LIMIT = 20;
47+const MAX_LIST_LIMIT = 100;
48+const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
49+const FALSE_ENV_VALUES = new Set(["0", "false", "no", "off"]);
50+const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
51+const MODE_NOTES = [
52+ {
53+ mode: "running",
54+ summary: "正常调度,允许领取和启动新的工作。"
55+ },
56+ {
57+ mode: "draining",
58+ summary: "停止新分配,让已启动 run 自然完成。"
59+ },
60+ {
61+ mode: "paused",
62+ summary: "暂停新的调度动作;已运行中的工作是否继续由后端策略决定。"
63+ }
64+] as const;
65
66 function requireAuthRule(method: ControlApiRouteMethod, pathPattern: string) {
67 const authRule = findControlApiAuthRule(method, pathPattern);
68@@ -30,6 +69,122 @@ function requireAuthRule(method: ControlApiRouteMethod, pathPattern: string) {
69 return authRule;
70 }
71
72+function parseBooleanEnv(value: string | undefined): boolean | undefined {
73+ if (value == null) {
74+ return undefined;
75+ }
76+
77+ const normalized = value.trim().toLowerCase();
78+
79+ if (TRUE_ENV_VALUES.has(normalized)) {
80+ return true;
81+ }
82+
83+ if (FALSE_ENV_VALUES.has(normalized)) {
84+ return false;
85+ }
86+
87+ return undefined;
88+}
89+
90+function resolveControlApiVersion(context: ControlApiRouteContext): string {
91+ const version = context.env[CONTROL_API_VERSION_ENV_NAME]?.trim();
92+ return version && version.length > 0 ? version : "dev";
93+}
94+
95+function resolveAuthMode(context: ControlApiRouteContext): "disabled" | "bearer" | "optional" {
96+ const envValue = parseBooleanEnv(context.env[CONTROL_API_AUTH_REQUIRED_ENV_NAME]);
97+
98+ if (envValue === false) {
99+ return "disabled";
100+ }
101+
102+ if (context.services.authHook == null) {
103+ return "optional";
104+ }
105+
106+ return "bearer";
107+}
108+
109+function toUnixMilliseconds(value: number | null | undefined): number | null {
110+ if (value == null) {
111+ return null;
112+ }
113+
114+ return value * 1000;
115+}
116+
117+function coercePositiveIntegerQuery(value: string | null): number | null {
118+ if (value == null || value.trim() === "") {
119+ return null;
120+ }
121+
122+ const numeric = Number(value);
123+
124+ if (!Number.isInteger(numeric) || numeric <= 0) {
125+ return null;
126+ }
127+
128+ return numeric;
129+}
130+
131+function readListLimit(
132+ context: ControlApiRouteContext,
133+ defaultLimit: number = DEFAULT_LIST_LIMIT
134+): number | ControlApiHandlerFailure {
135+ const rawValue = context.url.searchParams.get("limit");
136+
137+ if (rawValue == null || rawValue.trim() === "") {
138+ return defaultLimit;
139+ }
140+
141+ const parsed = coercePositiveIntegerQuery(rawValue);
142+
143+ if (parsed == null) {
144+ return buildInvalidRequestFailure(context, "Query parameter \"limit\" must be a positive integer.", {
145+ field: "limit"
146+ });
147+ }
148+
149+ if (parsed > MAX_LIST_LIMIT) {
150+ return buildInvalidRequestFailure(
151+ context,
152+ `Query parameter "limit" must be less than or equal to ${MAX_LIST_LIMIT}.`,
153+ {
154+ field: "limit",
155+ maximum: MAX_LIST_LIMIT
156+ }
157+ );
158+ }
159+
160+ return parsed;
161+}
162+
163+function readTaskStatusFilter(
164+ context: ControlApiRouteContext
165+): TaskStatus | undefined | ControlApiHandlerFailure {
166+ const rawValue = context.url.searchParams.get("status");
167+
168+ if (rawValue == null || rawValue.trim() === "") {
169+ return undefined;
170+ }
171+
172+ const normalized = rawValue.trim();
173+
174+ if (!TASK_STATUS_SET.has(normalized as TaskStatus)) {
175+ return buildInvalidRequestFailure(
176+ context,
177+ `Query parameter "status" must be one of ${TASK_STATUS_VALUES.join(", ")}.`,
178+ {
179+ field: "status",
180+ allowed_values: [...TASK_STATUS_VALUES]
181+ }
182+ );
183+ }
184+
185+ return normalized as TaskStatus;
186+}
187+
188 function asJsonObject(value: JsonValue): JsonObject | null {
189 if (value === null || Array.isArray(value) || typeof value !== "object") {
190 return null;
191@@ -289,7 +444,9 @@ function buildNotImplementedFailure(context: ControlApiRouteContext): ControlApi
192 path: context.route.pathPattern,
193 request_schema: context.route.schema.requestBody,
194 response_schema: context.route.schema.responseBody,
195- auth_action: context.auth.rule.action,
196+ access: context.route.access,
197+ category: context.route.category,
198+ auth_action: context.auth.rule?.action ?? null,
199 authorization_mode: context.auth.mode,
200 authorization_skip_reason: context.auth.skipReason ?? null,
201 principal_role: context.auth.principal?.role ?? null,
202@@ -357,6 +514,303 @@ function findHandlerFailure(...values: unknown[]): ControlApiHandlerFailure | nu
203 return null;
204 }
205
206+function summarizeTask(task: TaskRecord): JsonObject {
207+ return {
208+ task_id: task.taskId,
209+ repo: task.repo,
210+ task_type: task.taskType,
211+ title: task.title,
212+ goal: task.goal,
213+ source: task.source,
214+ priority: task.priority,
215+ status: task.status,
216+ planner_provider: task.plannerProvider,
217+ planning_strategy: task.planningStrategy,
218+ branch_name: task.branchName,
219+ base_ref: task.baseRef,
220+ target_host: task.targetHost,
221+ assigned_controller_id: task.assignedControllerId,
222+ current_step_index: task.currentStepIndex,
223+ result_summary: task.resultSummary,
224+ error_text: task.errorText,
225+ created_at: toUnixMilliseconds(task.createdAt),
226+ updated_at: toUnixMilliseconds(task.updatedAt),
227+ started_at: toUnixMilliseconds(task.startedAt),
228+ finished_at: toUnixMilliseconds(task.finishedAt)
229+ };
230+}
231+
232+function summarizeRun(run: TaskRunRecord): JsonObject {
233+ return {
234+ run_id: run.runId,
235+ task_id: run.taskId,
236+ step_id: run.stepId,
237+ worker_id: run.workerId,
238+ controller_id: run.controllerId,
239+ host: run.host,
240+ status: run.status,
241+ pid: run.pid,
242+ checkpoint_seq: run.checkpointSeq,
243+ exit_code: run.exitCode,
244+ created_at: toUnixMilliseconds(run.createdAt),
245+ started_at: toUnixMilliseconds(run.startedAt),
246+ finished_at: toUnixMilliseconds(run.finishedAt),
247+ heartbeat_at: toUnixMilliseconds(run.heartbeatAt),
248+ lease_expires_at: toUnixMilliseconds(run.leaseExpiresAt)
249+ };
250+}
251+
252+function summarizeController(controller: ControllerRecord, leaderControllerId: string | null): JsonObject {
253+ return {
254+ controller_id: controller.controllerId,
255+ host: controller.host,
256+ role: controller.role,
257+ priority: controller.priority,
258+ status: controller.status,
259+ version: controller.version,
260+ last_heartbeat_at: toUnixMilliseconds(controller.lastHeartbeatAt),
261+ last_started_at: toUnixMilliseconds(controller.lastStartedAt),
262+ is_leader: leaderControllerId != null && leaderControllerId === controller.controllerId
263+ };
264+}
265+
266+function extractAutomationMetadata(valueJson: string | null | undefined): {
267+ mode: AutomationMode | null;
268+ requestedBy: string | null;
269+ reason: string | null;
270+ source: string | null;
271+} {
272+ const payload = parseJsonText<{
273+ mode?: unknown;
274+ requested_by?: unknown;
275+ reason?: unknown;
276+ source?: unknown;
277+ }>(valueJson);
278+
279+ const mode = payload?.mode;
280+
281+ return {
282+ mode: mode === "running" || mode === "draining" || mode === "paused" ? mode : null,
283+ requestedBy: typeof payload?.requested_by === "string" ? payload.requested_by : null,
284+ reason: typeof payload?.reason === "string" ? payload.reason : null,
285+ source: typeof payload?.source === "string" ? payload.source : null
286+ };
287+}
288+
289+async function buildSystemStateData(
290+ repository: ControlPlaneRepository
291+): Promise<JsonObject> {
292+ const [automationState, lease, activeRuns, queuedTasks] = await Promise.all([
293+ repository.getAutomationState(),
294+ repository.getCurrentLease(),
295+ repository.countActiveRuns(),
296+ repository.countQueuedTasks()
297+ ]);
298+
299+ const leaderController = lease?.holderId ? await repository.getController(lease.holderId) : null;
300+ const automationMetadata = extractAutomationMetadata(automationState?.valueJson);
301+ const mode = automationMetadata.mode ?? automationState?.mode ?? DEFAULT_AUTOMATION_MODE;
302+ const updatedAt = toUnixMilliseconds(automationState?.updatedAt);
303+ const leaseExpiresAt = toUnixMilliseconds(lease?.leaseExpiresAt);
304+
305+ return {
306+ mode,
307+ updated_at: updatedAt,
308+ holder_id: lease?.holderId ?? null,
309+ holder_host: lease?.holderHost ?? null,
310+ lease_expires_at: leaseExpiresAt,
311+ term: lease?.term ?? null,
312+ automation: {
313+ mode,
314+ updated_at: updatedAt,
315+ requested_by: automationMetadata.requestedBy,
316+ reason: automationMetadata.reason,
317+ source: automationMetadata.source
318+ },
319+ leader: {
320+ controller_id: lease?.holderId ?? null,
321+ host: lease?.holderHost ?? leaderController?.host ?? null,
322+ role: leaderController?.role ?? null,
323+ status: leaderController?.status ?? null,
324+ version: leaderController?.version ?? null,
325+ lease_expires_at: leaseExpiresAt,
326+ term: lease?.term ?? null
327+ },
328+ queue: {
329+ active_runs: activeRuns,
330+ queued_tasks: queuedTasks
331+ }
332+ };
333+}
334+
335+function classifyRoute(route: ControlApiRouteDefinition): "read" | "write" {
336+ if (route.access === "public") {
337+ return route.method === "GET" ? "read" : "write";
338+ }
339+
340+ return AUTH_ACTION_DESCRIPTORS[route.authRule!.action].mutatesState ? "write" : "read";
341+}
342+
343+function describeRoute(route: ControlApiRouteDefinition): JsonObject {
344+ let auth: JsonObject;
345+
346+ if (route.access === "public") {
347+ auth = {
348+ mode: "public"
349+ };
350+ } else {
351+ auth = {
352+ mode: "bearer",
353+ action: route.authRule!.action,
354+ allowed_roles: getAllowedRolesForAction(route.authRule!.action),
355+ summary: route.authRule!.summary
356+ };
357+ }
358+
359+ return {
360+ id: route.id,
361+ method: route.method,
362+ path: route.pathPattern,
363+ category: route.category,
364+ access: route.access,
365+ kind: classifyRoute(route),
366+ implementation: route.implementation,
367+ summary: route.summary,
368+ request_body: route.schema.requestBody,
369+ response_body: route.schema.responseBody,
370+ notes: [...route.schema.notes],
371+ auth
372+ };
373+}
374+
375+function buildCurlExample(
376+ context: ControlApiRouteContext,
377+ route: ControlApiRouteDefinition,
378+ body?: JsonObject
379+): string {
380+ const authHeader =
381+ route.access === "protected" && context.services.authHook != null
382+ ? " \\\n -H 'Authorization: Bearer <token>'"
383+ : "";
384+ const contentTypeHeader = body ? " \\\n -H 'Content-Type: application/json'" : "";
385+ const payload = body ? ` \\\n -d '${JSON.stringify(body)}'` : "";
386+
387+ return `curl -X ${route.method} '${context.url.origin}${route.pathPattern}'${authHeader}${contentTypeHeader}${payload}`;
388+}
389+
390+function buildCapabilitiesData(context: ControlApiRouteContext): JsonObject {
391+ const readEndpoints = CONTROL_API_ROUTES.filter((route) => classifyRoute(route) === "read").map(describeRoute);
392+ const writeEndpoints = CONTROL_API_ROUTES.filter((route) => classifyRoute(route) === "write").map(describeRoute);
393+ const publicEndpoints = CONTROL_API_ROUTES.filter((route) => route.access === "public").map(describeRoute);
394+ const implementedEndpoints = CONTROL_API_ROUTES.filter((route) => route.implementation === "implemented").map(
395+ describeRoute
396+ );
397+
398+ return {
399+ deployment_mode: "single-node mini",
400+ auth_mode: resolveAuthMode(context),
401+ repository_configured: context.services.repository !== null,
402+ workflow: [
403+ "GET /describe",
404+ "GET /v1/capabilities",
405+ "GET /v1/system/state",
406+ "GET /v1/tasks or /v1/runs",
407+ "Use POST control routes only when a write is intended"
408+ ],
409+ read_endpoints: readEndpoints,
410+ write_endpoints: writeEndpoints,
411+ public_endpoints: publicEndpoints,
412+ implemented_endpoints: implementedEndpoints
413+ };
414+}
415+
416+async function buildDescribeData(context: ControlApiRouteContext): Promise<JsonObject> {
417+ const repository = context.services.repository;
418+ const system = repository ? await buildSystemStateData(repository) : null;
419+ const capabilities = buildCapabilitiesData(context);
420+
421+ return {
422+ name: CONTROL_API_WORKER_NAME,
423+ version: resolveControlApiVersion(context),
424+ description:
425+ "BAA conductor control surface for a temporary single-node mini deployment. Call /describe first, then choose read or write endpoints.",
426+ environment: {
427+ summary: "single-node mini",
428+ deployment_mode: "single-node mini",
429+ topology: "No primary/secondary failover or switchback path is active on this branch.",
430+ auth_mode: resolveAuthMode(context),
431+ repository_binding: CONTROL_API_D1_BINDING_NAME,
432+ repository_configured: repository !== null,
433+ custom_domain: CONTROL_API_CUSTOM_DOMAIN,
434+ origin: context.url.origin
435+ },
436+ system,
437+ endpoints: CONTROL_API_ROUTES.map(describeRoute),
438+ capabilities,
439+ auth_roles: Object.entries(AUTH_ROLE_BOUNDARIES).map(([role, boundary]) => ({
440+ role,
441+ summary: boundary.summary,
442+ token_kinds: [...boundary.tokenKinds],
443+ allowed_actions: [...boundary.allowedActions]
444+ })),
445+ actions: CONTROL_API_ROUTES.filter((route) => classifyRoute(route) === "write").map((route) => ({
446+ action: route.access === "protected" ? route.authRule!.action : route.id,
447+ method: route.method,
448+ path: route.pathPattern,
449+ summary: route.summary,
450+ implementation: route.implementation
451+ })),
452+ modes: MODE_NOTES.map((note) => ({ ...note })),
453+ examples: [
454+ {
455+ title: "Read the full self-description first",
456+ method: "GET",
457+ path: "/describe",
458+ curl: buildCurlExample(context, CONTROL_API_ROUTES.find((route) => route.id === "service.describe")!)
459+ },
460+ {
461+ title: "Inspect the narrower capability surface",
462+ method: "GET",
463+ path: "/v1/capabilities",
464+ curl: buildCurlExample(context, CONTROL_API_ROUTES.find((route) => route.id === "system.capabilities")!)
465+ },
466+ {
467+ title: "Read the current automation state",
468+ method: "GET",
469+ path: "/v1/system/state",
470+ curl: buildCurlExample(context, CONTROL_API_ROUTES.find((route) => route.id === "system.state")!)
471+ },
472+ {
473+ title: "List recent queued tasks",
474+ method: "GET",
475+ path: "/v1/tasks?status=queued&limit=5",
476+ curl: `curl '${context.url.origin}/v1/tasks?status=queued&limit=5'${
477+ context.services.authHook != null ? " \\\n -H 'Authorization: Bearer <token>'" : ""
478+ }`
479+ },
480+ {
481+ title: "Pause automation when a human explicitly wants a write",
482+ method: "POST",
483+ path: "/v1/system/pause",
484+ curl: buildCurlExample(
485+ context,
486+ CONTROL_API_ROUTES.find((route) => route.id === "system.pause")!,
487+ {
488+ requested_by: "browser_admin",
489+ reason: "human_clicked_pause",
490+ source: "human_control_surface"
491+ }
492+ )
493+ }
494+ ],
495+ notes: [
496+ "This repo currently targets the single-node mini path only.",
497+ "Public discoverability routes are safe to call before any authenticated workflow.",
498+ "Several control-plane write routes still expose placeholder contracts; check each endpoint's implementation field before using it."
499+ ]
500+ };
501+}
502+
503 async function handleControllerHeartbeat(
504 context: ControlApiRouteContext
505 ): Promise<ControlApiHandlerResult> {
506@@ -567,8 +1021,9 @@ function createSystemMutationHandler(mode: AutomationMode): ControlApiRouteHandl
507
508 const reason = readOptionalStringField(context, body, "reason");
509 const requestedBy = readOptionalStringField(context, body, "requested_by");
510+ const source = readOptionalStringField(context, body, "source");
511
512- const failure = findHandlerFailure(reason, requestedBy);
513+ const failure = findHandlerFailure(reason, requestedBy, source);
514
515 if (failure) {
516 return failure;
517@@ -576,12 +1031,23 @@ function createSystemMutationHandler(mode: AutomationMode): ControlApiRouteHandl
518
519 const reasonValue = reason as string | undefined;
520 const requestedByValue = requestedBy as string | undefined;
521-
522- await repository.setAutomationMode(mode, context.services.now());
523+ const sourceValue = source as string | undefined;
524+
525+ await repository.putSystemState({
526+ stateKey: AUTOMATION_STATE_KEY,
527+ updatedAt: context.services.now(),
528+ valueJson: JSON.stringify({
529+ mode,
530+ ...(requestedByValue ? { requested_by: requestedByValue } : {}),
531+ ...(reasonValue ? { reason: reasonValue } : {}),
532+ ...(sourceValue ? { source: sourceValue } : {})
533+ })
534+ });
535
536 const summarySuffix = [
537 requestedByValue ? `requested by ${requestedByValue}` : null,
538- reasonValue ? `reason: ${reasonValue}` : null
539+ reasonValue ? `reason: ${reasonValue}` : null,
540+ sourceValue ? `source: ${sourceValue}` : null
541 ].filter((value) => value !== null);
542
543 return buildAppliedAck(
544@@ -592,6 +1058,71 @@ function createSystemMutationHandler(mode: AutomationMode): ControlApiRouteHandl
545 };
546 }
547
548+async function handleDescribeRead(
549+ context: ControlApiRouteContext
550+): Promise<ControlApiHandlerResult> {
551+ return {
552+ ok: true,
553+ status: 200,
554+ data: await buildDescribeData(context)
555+ };
556+}
557+
558+async function handleVersionRead(
559+ context: ControlApiRouteContext
560+): Promise<ControlApiHandlerResult> {
561+ return {
562+ ok: true,
563+ status: 200,
564+ data: {
565+ description: "BAA conductor control surface",
566+ name: CONTROL_API_WORKER_NAME,
567+ version: resolveControlApiVersion(context)
568+ }
569+ };
570+}
571+
572+async function handleHealthRead(
573+ context: ControlApiRouteContext
574+): Promise<ControlApiHandlerResult> {
575+ const repository = context.services.repository;
576+ const system = repository ? await buildSystemStateData(repository) : null;
577+ const status = repository ? "ok" : "degraded";
578+
579+ return {
580+ ok: true,
581+ status: 200,
582+ data: {
583+ name: CONTROL_API_WORKER_NAME,
584+ version: resolveControlApiVersion(context),
585+ status,
586+ deployment_mode: "single-node mini",
587+ auth_mode: resolveAuthMode(context),
588+ repository_configured: repository !== null,
589+ system
590+ }
591+ };
592+}
593+
594+async function handleCapabilitiesRead(
595+ context: ControlApiRouteContext
596+): Promise<ControlApiHandlerResult> {
597+ const repository = context.services.repository;
598+
599+ return {
600+ ok: true,
601+ status: 200,
602+ data: {
603+ ...buildCapabilitiesData(context),
604+ system: repository ? await buildSystemStateData(repository) : null,
605+ notes: [
606+ "Read routes are safe for discovery and inspection.",
607+ "Write routes should only be used after a human or higher-level agent has made a control decision."
608+ ]
609+ }
610+ };
611+}
612+
613 async function handleSystemStateRead(
614 context: ControlApiRouteContext
615 ): Promise<ControlApiHandlerResult> {
616@@ -601,19 +1132,111 @@ async function handleSystemStateRead(
617 return repository;
618 }
619
620- const [automationState, lease] = await Promise.all([
621- repository.getAutomationState(),
622- repository.getCurrentLease()
623- ]);
624+ return {
625+ ok: true,
626+ status: 200,
627+ data: await buildSystemStateData(repository)
628+ };
629+}
630+
631+async function handleControllersList(
632+ context: ControlApiRouteContext
633+): Promise<ControlApiHandlerResult> {
634+ const repository = requireRepository(context);
635+
636+ if (isHandlerFailure(repository)) {
637+ return repository;
638+ }
639+
640+ const limit = readListLimit(context);
641+
642+ if (isHandlerFailure(limit)) {
643+ return limit;
644+ }
645+
646+ const lease = await repository.getCurrentLease();
647+ const limitValue = limit as number;
648+ const controllers = await repository.listControllers({
649+ limit: limitValue
650+ });
651
652 return {
653 ok: true,
654 status: 200,
655 data: {
656- holder_id: lease?.holderId ?? null,
657- lease_expires_at: lease?.leaseExpiresAt ?? null,
658- mode: automationState?.mode ?? DEFAULT_AUTOMATION_MODE,
659- term: lease?.term ?? null
660+ active_controller_id: lease?.holderId ?? null,
661+ count: controllers.length,
662+ controllers: controllers.map((controller) => summarizeController(controller, lease?.holderId ?? null)),
663+ limit: limitValue
664+ }
665+ };
666+}
667+
668+async function handleTasksList(
669+ context: ControlApiRouteContext
670+): Promise<ControlApiHandlerResult> {
671+ const repository = requireRepository(context);
672+
673+ if (isHandlerFailure(repository)) {
674+ return repository;
675+ }
676+
677+ const limit = readListLimit(context);
678+ const status = readTaskStatusFilter(context);
679+ const failure = findHandlerFailure(limit, status);
680+
681+ if (failure) {
682+ return failure;
683+ }
684+
685+ const tasks = await repository.listTasks({
686+ limit: limit as number,
687+ status: status as TaskStatus | undefined
688+ });
689+ const limitValue = limit as number;
690+ const statusValue = status as TaskStatus | undefined;
691+
692+ return {
693+ ok: true,
694+ status: 200,
695+ data: {
696+ count: tasks.length,
697+ filters: {
698+ limit: limitValue,
699+ status: statusValue ?? null
700+ },
701+ tasks: tasks.map(summarizeTask)
702+ }
703+ };
704+}
705+
706+async function handleRunsList(
707+ context: ControlApiRouteContext
708+): Promise<ControlApiHandlerResult> {
709+ const repository = requireRepository(context);
710+
711+ if (isHandlerFailure(repository)) {
712+ return repository;
713+ }
714+
715+ const limit = readListLimit(context);
716+
717+ if (isHandlerFailure(limit)) {
718+ return limit;
719+ }
720+
721+ const limitValue = limit as number;
722+ const runs = await repository.listRuns({
723+ limit: limitValue
724+ });
725+
726+ return {
727+ ok: true,
728+ status: 200,
729+ data: {
730+ count: runs.length,
731+ limit: limitValue,
732+ runs: runs.map(summarizeRun)
733 }
734 };
735 }
736@@ -644,30 +1267,112 @@ async function handleTaskRead(
737 return {
738 ok: true,
739 status: 200,
740- data: {
741- current_step_index: task.currentStepIndex,
742- status: task.status,
743- task_id: task.taskId,
744- title: task.title
745- }
746+ data: summarizeTask(task)
747+ };
748+}
749+
750+async function handleRunRead(
751+ context: ControlApiRouteContext
752+): Promise<ControlApiHandlerResult> {
753+ const repository = requireRepository(context);
754+
755+ if (isHandlerFailure(repository)) {
756+ return repository;
757+ }
758+
759+ const runId = context.params.run_id;
760+
761+ if (!runId) {
762+ return buildInvalidRequestFailure(context, "Route parameter \"run_id\" is required.", {
763+ field: "run_id"
764+ });
765+ }
766+
767+ const run = await repository.getRun(runId);
768+
769+ if (!run) {
770+ return buildNotFoundFailure(context, "run", runId);
771+ }
772+
773+ return {
774+ ok: true,
775+ status: 200,
776+ data: summarizeRun(run)
777 };
778 }
779
780 function defineRoute(
781- definition: Omit<ControlApiRouteDefinition, "authRule" | "handler"> & {
782+ definition: Omit<ControlApiRouteDefinition, "access" | "authRule" | "handler" | "implementation"> & {
783+ access?: ControlApiRouteAccess;
784 handler?: ControlApiRouteHandler;
785 }
786 ): ControlApiRouteDefinition {
787+ const access = definition.access ?? "protected";
788+ const authRule = access === "public" ? null : requireAuthRule(definition.method, definition.pathPattern);
789+ const handler = definition.handler ?? createPlaceholderHandler();
790+
791 return {
792 ...definition,
793- authRule: requireAuthRule(definition.method, definition.pathPattern),
794- handler: definition.handler ?? createPlaceholderHandler()
795+ access,
796+ authRule,
797+ handler,
798+ implementation: definition.handler ? "implemented" : "placeholder"
799 };
800 }
801
802 export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
803+ defineRoute({
804+ id: "service.describe",
805+ access: "public",
806+ category: "discoverability",
807+ method: "GET",
808+ pathPattern: "/describe",
809+ summary: "读取完整自描述 JSON",
810+ schema: CONTROL_API_ROUTE_SCHEMAS["service.describe"],
811+ handler: handleDescribeRead
812+ }),
813+ defineRoute({
814+ id: "service.version",
815+ access: "public",
816+ category: "discoverability",
817+ method: "GET",
818+ pathPattern: "/version",
819+ summary: "读取服务版本",
820+ schema: CONTROL_API_ROUTE_SCHEMAS["service.version"],
821+ handler: handleVersionRead
822+ }),
823+ defineRoute({
824+ id: "service.health",
825+ access: "public",
826+ category: "discoverability",
827+ method: "GET",
828+ pathPattern: "/health",
829+ summary: "读取服务健康摘要",
830+ schema: CONTROL_API_ROUTE_SCHEMAS["service.health"],
831+ handler: handleHealthRead
832+ }),
833+ defineRoute({
834+ id: "system.capabilities",
835+ access: "public",
836+ category: "discoverability",
837+ method: "GET",
838+ pathPattern: "/v1/capabilities",
839+ summary: "读取能力发现摘要",
840+ schema: CONTROL_API_ROUTE_SCHEMAS["system.capabilities"],
841+ handler: handleCapabilitiesRead
842+ }),
843+ defineRoute({
844+ id: "controllers.list",
845+ category: "controllers",
846+ method: "GET",
847+ pathPattern: "/v1/controllers",
848+ summary: "列出 controller 摘要",
849+ schema: CONTROL_API_ROUTE_SCHEMAS["controllers.list"],
850+ handler: handleControllersList
851+ }),
852 defineRoute({
853 id: "controllers.heartbeat",
854+ category: "controllers",
855 method: "POST",
856 pathPattern: "/v1/controllers/heartbeat",
857 summary: "controller 心跳",
858@@ -677,6 +1382,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
859 }),
860 defineRoute({
861 id: "leader.acquire",
862+ category: "leader",
863 method: "POST",
864 pathPattern: "/v1/leader/acquire",
865 summary: "获取或续租 leader lease",
866@@ -684,8 +1390,18 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
867 ownershipResolver: resolveControllerOwnership,
868 handler: handleLeaderAcquire
869 }),
870+ defineRoute({
871+ id: "tasks.list",
872+ category: "tasks",
873+ method: "GET",
874+ pathPattern: "/v1/tasks",
875+ summary: "列出 task 摘要",
876+ schema: CONTROL_API_ROUTE_SCHEMAS["tasks.list"],
877+ handler: handleTasksList
878+ }),
879 defineRoute({
880 id: "tasks.create",
881+ category: "tasks",
882 method: "POST",
883 pathPattern: "/v1/tasks",
884 summary: "创建 task",
885@@ -694,6 +1410,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
886 }),
887 defineRoute({
888 id: "tasks.plan",
889+ category: "tasks",
890 method: "POST",
891 pathPattern: "/v1/tasks/:task_id/plan",
892 summary: "持久化已验收 plan",
893@@ -702,6 +1419,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
894 }),
895 defineRoute({
896 id: "tasks.claim",
897+ category: "tasks",
898 method: "POST",
899 pathPattern: "/v1/tasks/claim",
900 summary: "领取待规划 task 或 runnable step",
901@@ -710,6 +1428,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
902 }),
903 defineRoute({
904 id: "steps.heartbeat",
905+ category: "steps",
906 method: "POST",
907 pathPattern: "/v1/steps/:step_id/heartbeat",
908 summary: "step 心跳",
909@@ -718,6 +1437,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
910 }),
911 defineRoute({
912 id: "steps.checkpoint",
913+ category: "steps",
914 method: "POST",
915 pathPattern: "/v1/steps/:step_id/checkpoint",
916 summary: "写 step checkpoint",
917@@ -726,6 +1446,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
918 }),
919 defineRoute({
920 id: "steps.complete",
921+ category: "steps",
922 method: "POST",
923 pathPattern: "/v1/steps/:step_id/complete",
924 summary: "标记 step 完成",
925@@ -734,6 +1455,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
926 }),
927 defineRoute({
928 id: "steps.fail",
929+ category: "steps",
930 method: "POST",
931 pathPattern: "/v1/steps/:step_id/fail",
932 summary: "标记 step 失败",
933@@ -742,6 +1464,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
934 }),
935 defineRoute({
936 id: "system.pause",
937+ category: "system",
938 method: "POST",
939 pathPattern: "/v1/system/pause",
940 summary: "暂停自动化",
941@@ -750,6 +1473,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
942 }),
943 defineRoute({
944 id: "system.resume",
945+ category: "system",
946 method: "POST",
947 pathPattern: "/v1/system/resume",
948 summary: "恢复自动化",
949@@ -758,6 +1482,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
950 }),
951 defineRoute({
952 id: "system.drain",
953+ category: "system",
954 method: "POST",
955 pathPattern: "/v1/system/drain",
956 summary: "drain 自动化",
957@@ -766,6 +1491,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
958 }),
959 defineRoute({
960 id: "system.state",
961+ category: "system",
962 method: "GET",
963 pathPattern: "/v1/system/state",
964 summary: "读取系统状态",
965@@ -774,6 +1500,7 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
966 }),
967 defineRoute({
968 id: "tasks.read",
969+ category: "tasks",
970 method: "GET",
971 pathPattern: "/v1/tasks/:task_id",
972 summary: "读取 task 详情",
973@@ -782,17 +1509,29 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
974 }),
975 defineRoute({
976 id: "tasks.logs.read",
977+ category: "tasks",
978 method: "GET",
979 pathPattern: "/v1/tasks/:task_id/logs",
980 summary: "读取 task 日志",
981 schema: CONTROL_API_ROUTE_SCHEMAS["tasks.logs.read"]
982 }),
983+ defineRoute({
984+ id: "runs.list",
985+ category: "runs",
986+ method: "GET",
987+ pathPattern: "/v1/runs",
988+ summary: "列出 run 摘要",
989+ schema: CONTROL_API_ROUTE_SCHEMAS["runs.list"],
990+ handler: handleRunsList
991+ }),
992 defineRoute({
993 id: "runs.read",
994+ category: "runs",
995 method: "GET",
996 pathPattern: "/v1/runs/:run_id",
997 summary: "读取 run 详情",
998- schema: CONTROL_API_ROUTE_SCHEMAS["runs.read"]
999+ schema: CONTROL_API_ROUTE_SCHEMAS["runs.read"],
1000+ handler: handleRunRead
1001 })
1002 ];
1003
1@@ -175,6 +175,14 @@ async function resolveAuthorization(
2 ): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure> {
3 const rule = matchedRoute.route.authRule;
4
5+ if (matchedRoute.route.access === "public") {
6+ return {
7+ mode: "public",
8+ rule: null,
9+ skipReason: "public_route"
10+ };
11+ }
12+
13 if (!services.authHook) {
14 return {
15 mode: "skipped",
+15,
-1
1@@ -98,7 +98,21 @@ export function createControlApiAuthHook(
2 body: input.body
3 });
4
5- const verification = await verifyBearerToken(tokenResult.token, input.route.authRule.action, resource, env, tokenVerifier);
6+ if (input.route.authRule == null) {
7+ return {
8+ mode: "public",
9+ rule: null,
10+ skipReason: "public_route"
11+ };
12+ }
13+
14+ const verification = await verifyBearerToken(
15+ tokenResult.token,
16+ input.route.authRule.action,
17+ resource,
18+ env,
19+ tokenVerifier
20+ );
21
22 if (!verification.ok) {
23 return {
+115,
-0
1@@ -25,6 +25,38 @@ export interface LeaderAcquireResponseData {
2 is_leader: boolean;
3 }
4
5+export interface ServiceDescribeResponseData {
6+ name: string;
7+ version: string;
8+ description: string;
9+}
10+
11+export interface ServiceVersionResponseData {
12+ name: string;
13+ version: string;
14+}
15+
16+export interface ServiceHealthResponseData {
17+ name: string;
18+ version: string;
19+ status: "ok" | "degraded";
20+}
21+
22+export interface ControllerSummaryData {
23+ controller_id: string;
24+ host: string;
25+ role: string;
26+ status: string;
27+ version: string | null;
28+ priority: number;
29+ last_heartbeat_at: number;
30+ is_leader: boolean;
31+}
32+
33+export interface ControllersListResponseData {
34+ controllers: ControllerSummaryData[];
35+}
36+
37 export interface TaskCreateRequest {
38 repo: string;
39 task_type: string;
40@@ -43,6 +75,20 @@ export interface TaskCreateResponseData {
41 base_ref: string | null;
42 }
43
44+export interface TaskSummaryData {
45+ task_id: string;
46+ repo: string;
47+ task_type: string;
48+ title: string;
49+ status: TaskStatus;
50+ priority: number;
51+ updated_at: number;
52+}
53+
54+export interface TaskListResponseData {
55+ tasks: TaskSummaryData[];
56+}
57+
58 export interface TaskPlanStepRequest {
59 step_id?: string;
60 step_name: string;
61@@ -115,6 +161,7 @@ export interface StepFailRequest {
62 export interface SystemMutationRequest {
63 reason?: string;
64 requested_by?: string;
65+ source?: string;
66 }
67
68 export interface ControlApiAckResponse {
69@@ -151,6 +198,18 @@ export interface TaskLogsResponseData {
70 entries: TaskLogEntryData[];
71 }
72
73+export interface RunSummaryData {
74+ run_id: string;
75+ task_id: string;
76+ step_id: string;
77+ status: string;
78+ created_at: number;
79+}
80+
81+export interface RunListResponseData {
82+ runs: RunSummaryData[];
83+}
84+
85 export interface RunDetailResponseData {
86 run_id: string;
87 task_id: string;
88@@ -161,6 +220,46 @@ export interface RunDetailResponseData {
89 }
90
91 export const CONTROL_API_ROUTE_SCHEMAS = {
92+ "service.describe": {
93+ requestBody: null,
94+ responseBody: "ServiceDescribeResponseData",
95+ notes: [
96+ "给 AI 或手机端网页先读的完整自描述接口。",
97+ "返回当前服务模式、端点、能力、示例和说明。"
98+ ]
99+ },
100+ "service.version": {
101+ requestBody: null,
102+ responseBody: "ServiceVersionResponseData",
103+ notes: [
104+ "轻量版本查询接口。",
105+ "适合做最小探针或排查部署版本。"
106+ ]
107+ },
108+ "service.health": {
109+ requestBody: null,
110+ responseBody: "ServiceHealthResponseData",
111+ notes: [
112+ "返回服务是否可响应,以及当前 repository 绑定是否就绪。",
113+ "不执行写操作。"
114+ ]
115+ },
116+ "system.capabilities": {
117+ requestBody: null,
118+ responseBody: "JsonObject",
119+ notes: [
120+ "比 /describe 更窄,专门给 AI 做能力发现。",
121+ "重点说明只读接口、写接口、部署模式和鉴权模式。"
122+ ]
123+ },
124+ "controllers.list": {
125+ requestBody: null,
126+ responseBody: "ControllersListResponseData",
127+ notes: [
128+ "单节点 mini 模式下也会返回已注册 controller 摘要。",
129+ "可用于发现当前 active controller。"
130+ ]
131+ },
132 "controllers.heartbeat": {
133 requestBody: "ControllerHeartbeatRequest",
134 responseBody: "ControlApiAckResponse",
135@@ -185,6 +284,14 @@ export const CONTROL_API_ROUTE_SCHEMAS = {
136 "task 归一化和写表逻辑将在后续任务接入。"
137 ]
138 },
139+ "tasks.list": {
140+ requestBody: null,
141+ responseBody: "TaskListResponseData",
142+ notes: [
143+ "返回最近 task 摘要,支持最小 status/limit 查询。",
144+ "适合 AI 先了解系统最近有哪些任务。"
145+ ]
146+ },
147 "tasks.plan": {
148 requestBody: "TaskPlanRequest",
149 responseBody: "ControlApiAckResponse",
150@@ -288,5 +395,13 @@ export const CONTROL_API_ROUTE_SCHEMAS = {
151 "返回 step 运行态与最近一次 heartbeat。",
152 "后续可补 checkpoint/artifact 索引。"
153 ]
154+ },
155+ "runs.list": {
156+ requestBody: null,
157+ responseBody: "RunListResponseData",
158+ notes: [
159+ "列出最近 run 摘要。",
160+ "适合 AI 或运维快速判断系统最近执行了什么。"
161+ ]
162 }
163 } satisfies Record<ControlApiRouteId, ControlApiRouteSchemaDescriptor>;
+10,
-2
1@@ -1,4 +1,4 @@
2-import { createDefaultStatusSnapshotLoader } from "./data-source.js";
3+import { createDefaultStatusSnapshotLoader, resolveStatusApiControlApiBase } from "./data-source.js";
4 import type {
5 StatusApiEnvironment,
6 StatusApiHandler,
7@@ -19,7 +19,15 @@ export interface StatusApiRuntimeOptions {
8 }
9
10 export function createStatusApiRuntime(options: StatusApiRuntimeOptions = {}): StatusApiRuntime {
11- const handler = createStatusApiHandler(options.snapshotLoader ?? createDefaultStatusSnapshotLoader({ env: options.env }));
12+ const env = options.env;
13+ const handler = createStatusApiHandler(
14+ options.snapshotLoader ?? createDefaultStatusSnapshotLoader({ env }),
15+ {
16+ controlApiBase: resolveStatusApiControlApiBase(env),
17+ publicBaseUrl: env?.BAA_STATUS_API_PUBLIC_BASE,
18+ version: env?.BAA_STATUS_API_VERSION
19+ }
20+ );
21
22 return {
23 routes: handler.routes,
+92,
-4
1@@ -22,13 +22,26 @@ const TEXT_HEADERS = {
2 "cache-control": "no-store"
3 } as const;
4
5-type StatusApiRouteId = "healthz" | "status" | "ui";
6+type StatusApiRouteId = "describe" | "healthz" | "status" | "ui";
7
8 type StatusApiRouteDefinition = StatusApiRoute & {
9 id: StatusApiRouteId;
10 };
11
12+export interface StatusApiHandlerOptions {
13+ controlApiBase?: string;
14+ publicBaseUrl?: string;
15+ version?: string;
16+}
17+
18 const STATUS_API_ROUTE_DEFINITIONS: ReadonlyArray<StatusApiRouteDefinition> = [
19+ {
20+ id: "describe",
21+ method: "GET",
22+ path: "/describe",
23+ summary: "读取状态服务自描述 JSON",
24+ contentType: "application/json"
25+ },
26 {
27 id: "healthz",
28 method: "GET",
29@@ -71,16 +84,20 @@ export function describeStatusApiSurface(): string[] {
30 });
31 }
32
33-export function createStatusApiHandler(snapshotLoader: StatusSnapshotLoader): StatusApiHandler {
34+export function createStatusApiHandler(
35+ snapshotLoader: StatusSnapshotLoader,
36+ options: StatusApiHandlerOptions = {}
37+): StatusApiHandler {
38 return {
39 routes: STATUS_API_ROUTES,
40- handle: (request) => handleStatusApiRequest(request, snapshotLoader)
41+ handle: (request) => handleStatusApiRequest(request, snapshotLoader, options)
42 };
43 }
44
45 export async function handleStatusApiRequest(
46 request: StatusApiRequest,
47- snapshotLoader: StatusSnapshotLoader
48+ snapshotLoader: StatusSnapshotLoader,
49+ options: StatusApiHandlerOptions = {}
50 ): Promise<StatusApiResponse> {
51 const method = request.method.toUpperCase();
52 const path = normalizePath(request.path);
53@@ -108,6 +125,12 @@ export async function handleStatusApiRequest(
54 }
55
56 switch (route.id) {
57+ case "describe":
58+ return jsonResponse(200, {
59+ ok: true,
60+ data: buildStatusApiDescribeData(options)
61+ });
62+
63 case "healthz":
64 return {
65 status: 200,
66@@ -130,6 +153,71 @@ export async function handleStatusApiRequest(
67 }
68 }
69
70+function buildStatusApiDescribeData(options: StatusApiHandlerOptions): Record<string, unknown> {
71+ const processInfo = getProcessInfo();
72+
73+ return {
74+ name: "baa-conductor-status-api",
75+ version: resolveStatusApiVersion(options.version),
76+ description:
77+ "Read-only status view service. It does not own conductor truth; it renders a narrow status snapshot for humans, browsers, and AI clients.",
78+ pid: processInfo.pid,
79+ uptime_sec: processInfo.uptimeSec,
80+ cwd: processInfo.cwd,
81+ truth_source: {
82+ summary: "Current truth comes from control-api /v1/system/state.",
83+ type: "control-api",
84+ base_url: options.controlApiBase ?? "https://control-api.makefile.so",
85+ endpoint: "/v1/system/state"
86+ },
87+ endpoints: STATUS_API_ROUTE_DEFINITIONS.map((route) => ({
88+ method: route.method,
89+ path: route.path,
90+ aliases: route.aliases ?? [],
91+ summary: route.summary,
92+ content_type: route.contentType
93+ })),
94+ responses: {
95+ "/healthz": "plain text ok",
96+ "/v1/status": "{ ok, data } JSON snapshot",
97+ "/v1/status/ui": "HTML status panel",
98+ "/describe": "{ ok, data } JSON service description"
99+ },
100+ examples: [
101+ `curl '${options.publicBaseUrl ?? "https://conductor.makefile.so"}/describe'`,
102+ `curl '${options.publicBaseUrl ?? "https://conductor.makefile.so"}/v1/status'`
103+ ],
104+ notes: [
105+ "Status API is read-only.",
106+ "If control-api is unreachable, status-api cannot mint its own truth."
107+ ]
108+ };
109+}
110+
111+function resolveStatusApiVersion(value: string | undefined): string {
112+ if (value == null || value.trim() === "") {
113+ return "dev";
114+ }
115+
116+ return value.trim();
117+}
118+
119+function getProcessInfo(): { cwd: string | null; pid: number | null; uptimeSec: number | null } {
120+ const runtimeProcess = (typeof process !== "undefined" ? process : undefined) as
121+ | {
122+ cwd?: () => string;
123+ pid?: number;
124+ uptime?: () => number;
125+ }
126+ | undefined;
127+
128+ return {
129+ pid: typeof runtimeProcess?.pid === "number" ? runtimeProcess.pid : null,
130+ uptimeSec: typeof runtimeProcess?.uptime === "function" ? Math.floor(runtimeProcess.uptime()) : null,
131+ cwd: typeof runtimeProcess?.cwd === "function" ? runtimeProcess.cwd() : null
132+ };
133+}
134+
135 function jsonResponse(
136 status: number,
137 payload: unknown,
+102,
-0
1@@ -0,0 +1,102 @@
2+# HTTP API Surfaces
3+
4+`baa-conductor` 当前只维护单节点 `mini` 方案,因此 HTTP 面也按这个约束描述:
5+
6+- 不恢复主备切换
7+- 不要求 token 的临时模式仍可工作
8+- 推荐所有 AI 客户端先读 `/describe`
9+
10+## 入口
11+
12+| 服务 | 公开地址 | 说明 |
13+| --- | --- | --- |
14+| control-api | `https://control-api.makefile.so` | 控制面、能力发现、任务/run/controller 只读查询 |
15+| status-api | `https://conductor.makefile.so` | 状态快照 JSON 和 HTML 面板 |
16+
17+## Describe First
18+
19+新对话建议顺序:
20+
21+1. `GET https://control-api.makefile.so/describe`
22+2. 如需更窄的能力发现,再读 `GET https://control-api.makefile.so/v1/capabilities`
23+3. 先用只读接口确认当前状态、最近任务和最近 run
24+4. 只有在明确需要控制动作时,才调用 `pause` / `resume` / `drain` / `tasks.create`
25+
26+状态视图服务同样支持:
27+
28+1. `GET https://conductor.makefile.so/describe`
29+2. `GET https://conductor.makefile.so/v1/status`
30+
31+## Control API
32+
33+### 可发现性接口
34+
35+| 方法 | 路径 | 说明 |
36+| --- | --- | --- |
37+| `GET` | `/describe` | 完整自描述 JSON,包含 `name`、`version`、`environment`、`endpoints`、`capabilities`、`examples`、`notes` |
38+| `GET` | `/version` | 轻量版本查询 |
39+| `GET` | `/health` | 健康摘要、部署模式、鉴权模式、repository 是否就绪 |
40+| `GET` | `/v1/capabilities` | 更窄的能力发现接口,区分只读/写接口与当前模式 |
41+
42+### 只读 / 功能型接口
43+
44+| 方法 | 路径 | 说明 |
45+| --- | --- | --- |
46+| `GET` | `/v1/system/state` | 当前 automation / leader / queue 摘要 |
47+| `GET` | `/v1/controllers?limit=20` | 已注册 controller 摘要,带 active controller 线索 |
48+| `GET` | `/v1/tasks?status=queued&limit=20` | 最近任务摘要,可按 `status` 过滤 |
49+| `GET` | `/v1/tasks/:task_id` | 单个 task 详情 |
50+| `GET` | `/v1/runs?limit=20` | 最近 run 摘要 |
51+| `GET` | `/v1/runs/:run_id` | 单个 run 详情 |
52+
53+### 控制型接口
54+
55+| 方法 | 路径 | 说明 |
56+| --- | --- | --- |
57+| `POST` | `/v1/tasks` | 创建 task |
58+| `POST` | `/v1/system/pause` | 切到 `paused` |
59+| `POST` | `/v1/system/resume` | 切到 `running` |
60+| `POST` | `/v1/system/drain` | 切到 `draining` |
61+
62+其它 controller / worker 写接口仍保留在 control-api 中,但部分还是占位合同;先读 `/describe` 里的 `implementation` 字段再决定是否调用。
63+
64+## Status API
65+
66+`status-api` 是只读视图服务,不拥有真相。
67+
68+truth source:
69+
70+- 默认回源 `control-api /v1/system/state`
71+- 当前只负责把这个真相整理成 JSON 或 HTML
72+
73+当前端点:
74+
75+| 方法 | 路径 | 说明 |
76+| --- | --- | --- |
77+| `GET` | `/describe` | 说明 status-api 本身、truth source 和返回格式 |
78+| `GET` | `/healthz` | 纯健康检查,返回 `ok` |
79+| `GET` | `/v1/status` | JSON 状态快照 |
80+| `GET` | `/v1/status/ui` | HTML 状态面板 |
81+| `GET` | `/` / `/ui` | `/v1/status/ui` 别名 |
82+
83+## Minimal Curl
84+
85+```bash
86+curl https://control-api.makefile.so/describe
87+```
88+
89+```bash
90+curl https://control-api.makefile.so/v1/capabilities
91+```
92+
93+```bash
94+curl 'https://control-api.makefile.so/v1/tasks?status=queued&limit=5'
95+```
96+
97+```bash
98+curl 'https://control-api.makefile.so/v1/runs?limit=5'
99+```
100+
101+```bash
102+curl https://conductor.makefile.so/describe
103+```
+60,
-30
1@@ -75,6 +75,7 @@
2 推荐入口:
3
4 - Base URL: `https://control-api.makefile.so`
5+- 新对话或新接入 AI 客户端时,先读 `GET /describe`
6
7 当前单节点临时模式:
8
9@@ -87,6 +88,13 @@
10 如果后续重新启用鉴权,再恢复 `Authorization: Bearer <token>` 即可。
11 如果后续需要兼容旧的 browser-proxy WS,再在插件里手动填写 `ws://` / `wss://` 地址并显式启用。
12
13+推荐 describe-first 顺序:
14+
15+1. `GET /describe`
16+2. `GET /v1/capabilities`
17+3. `GET /v1/system/state`
18+4. 如需更强只读发现能力,再查 `GET /v1/controllers`、`GET /v1/tasks`、`GET /v1/runs`
19+
20 ## 4. 读取状态
21
22 ### `GET /v1/system/state`
23@@ -113,21 +121,23 @@ Firefox 插件至少要读取这些字段:
24 {
25 "ok": true,
26 "request_id": "req_123",
27- "automation": {
28- "mode": "running",
29- "updated_at": 1760000000000,
30- "requested_by": "browser_admin",
31- "reason": "human_clicked_resume"
32- },
33- "leader": {
34- "controller_id": "mini-main",
35- "host": "mini",
36- "role": "primary",
37- "lease_expires_at": 1760000030000
38- },
39- "queue": {
40- "active_runs": 2,
41- "queued_tasks": 7
42+ "data": {
43+ "automation": {
44+ "mode": "running",
45+ "updated_at": 1760000000000,
46+ "requested_by": "browser_admin",
47+ "reason": "human_clicked_resume"
48+ },
49+ "leader": {
50+ "controller_id": "mini-main",
51+ "host": "mini",
52+ "role": "primary",
53+ "lease_expires_at": 1760000030000
54+ },
55+ "queue": {
56+ "active_runs": 2,
57+ "queued_tasks": 7
58+ }
59 }
60 }
61 ```
62@@ -138,6 +148,24 @@ Firefox 插件至少要读取这些字段:
63 - 建议每 `5` 到 `10` 秒轮询一次;面板关闭后停止轮询。
64 - 每次成功执行 `pause`、`resume`、`drain` 后,优先使用写接口返回的新状态更新 UI;如果后端暂未回传完整状态,则立即补一次 `GET /v1/system/state`。
65
66+## 4.1 能力发现和辅助只读接口
67+
68+这些接口不是插件主循环必需,但适合 Claude、手机端网页或人工排障先读:
69+
70+- `GET /describe`
71+- `GET /v1/capabilities`
72+- `GET /v1/controllers`
73+- `GET /v1/tasks?limit=5`
74+- `GET /v1/runs?limit=5`
75+
76+用途:
77+
78+- `/describe`:读完整端点表、字段结构、示例和当前模式说明
79+- `/v1/capabilities`:快速区分 public/read/write surface
80+- `/v1/controllers`:确认当前注册的 controller 和 active controller
81+- `/v1/tasks`:确认系统最近有哪些任务
82+- `/v1/runs`:确认系统最近执行过哪些 run
83+
84 ## 5. 写接口
85
86 ### 5.1 共用请求体
87@@ -191,21 +219,23 @@ Firefox 插件至少要读取这些字段:
88 {
89 "ok": true,
90 "request_id": "req_124",
91- "automation": {
92- "mode": "paused",
93- "updated_at": 1760000005000,
94- "requested_by": "browser_admin",
95- "reason": "human_clicked_pause"
96- },
97- "leader": {
98- "controller_id": "mini-main",
99- "host": "mini",
100- "role": "primary",
101- "lease_expires_at": 1760000030000
102- },
103- "queue": {
104- "active_runs": 2,
105- "queued_tasks": 7
106+ "data": {
107+ "automation": {
108+ "mode": "paused",
109+ "updated_at": 1760000005000,
110+ "requested_by": "browser_admin",
111+ "reason": "human_clicked_pause"
112+ },
113+ "leader": {
114+ "controller_id": "mini-main",
115+ "host": "mini",
116+ "role": "primary",
117+ "lease_expires_at": 1760000030000
118+ },
119+ "queue": {
120+ "active_runs": 2,
121+ "queued_tasks": 7
122+ }
123 }
124 }
125 ```
+25,
-0
1@@ -28,6 +28,30 @@
2 | --- | --- | --- |
3 | `conductor.makefile.so` | VPS 公网 IP | `100.71.210.78:4317` |
4
5+## HTTP 面说明
6+
7+当前建议把两个公开入口分开理解:
8+
9+| 入口 | 公开地址 | 当前用途 | 推荐首个请求 |
10+| --- | --- | --- | --- |
11+| control-api | `https://control-api.makefile.so` | 控制面、自描述、只读查询、任务创建 | `GET /describe` |
12+| status-api | `https://conductor.makefile.so` | 状态 JSON / HTML 视图 | `GET /describe` |
13+
14+最小排查顺序:
15+
16+1. `curl https://control-api.makefile.so/describe`
17+2. `curl https://control-api.makefile.so/health`
18+3. `curl https://control-api.makefile.so/v1/system/state`
19+4. `curl https://conductor.makefile.so/describe`
20+5. `curl https://conductor.makefile.so/v1/status`
21+
22+control-api 当前新增的只读功能型接口:
23+
24+- `GET /v1/capabilities`
25+- `GET /v1/controllers`
26+- `GET /v1/tasks`
27+- `GET /v1/runs`
28+
29 ## 当前节点监听
30
31 `mini`:
32@@ -105,3 +129,4 @@ ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh --relo
33 - 不依赖 MagicDNS
34 - 是否启用 Cloudflare proxy 由实际证书和 SSL mode 决定
35 - 历史多节点资料只通过 tag `ha-failover-archive-2026-03-22` 回溯
36+- control-api / status-api 都支持 `GET /describe`,建议先读它再做后续请求
+18,
-0
1@@ -3,7 +3,9 @@ export type AuthResourceBinding = "none" | "controller" | "worker";
2 export const AUTH_ACTIONS = [
3 "controllers.heartbeat",
4 "leader.acquire",
5+ "controllers.list",
6 "tasks.create",
7+ "tasks.list",
8 "tasks.plan",
9 "tasks.claim",
10 "steps.heartbeat",
11@@ -14,6 +16,7 @@ export const AUTH_ACTIONS = [
12 "system.resume",
13 "system.drain",
14 "system.state.read",
15+ "runs.list",
16 "tasks.read",
17 "tasks.logs.read",
18 "runs.read",
19@@ -40,11 +43,21 @@ export const AUTH_ACTION_DESCRIPTORS = {
20 mutatesState: true,
21 resourceBinding: "controller"
22 },
23+ "controllers.list": {
24+ summary: "列出已注册 controller 与当前 active controller",
25+ mutatesState: false,
26+ resourceBinding: "none"
27+ },
28 "tasks.create": {
29 summary: "通过可见 control 会话创建新 task",
30 mutatesState: true,
31 resourceBinding: "none"
32 },
33+ "tasks.list": {
34+ summary: "按状态列出 task 摘要",
35+ mutatesState: false,
36+ resourceBinding: "none"
37+ },
38 "tasks.plan": {
39 summary: "leader conductor 持久化已验收的 plan",
40 mutatesState: true,
41@@ -95,6 +108,11 @@ export const AUTH_ACTION_DESCRIPTORS = {
42 mutatesState: false,
43 resourceBinding: "none"
44 },
45+ "runs.list": {
46+ summary: "列出最近 run 摘要",
47+ mutatesState: false,
48+ resourceBinding: "none"
49+ },
50 "tasks.read": {
51 summary: "读取 task 详情",
52 mutatesState: false,
+18,
-0
1@@ -35,12 +35,24 @@ export const CONTROL_API_AUTH_RULES = [
2 action: "leader.acquire",
3 summary: "获取或续租 leader lease"
4 },
5+ {
6+ method: "GET",
7+ pathPattern: "/v1/controllers",
8+ action: "controllers.list",
9+ summary: "列出 controller 摘要"
10+ },
11 {
12 method: "POST",
13 pathPattern: "/v1/tasks",
14 action: "tasks.create",
15 summary: "创建 task"
16 },
17+ {
18+ method: "GET",
19+ pathPattern: "/v1/tasks",
20+ action: "tasks.list",
21+ summary: "列出 task 摘要"
22+ },
23 {
24 method: "POST",
25 pathPattern: "/v1/tasks/claim",
26@@ -113,6 +125,12 @@ export const CONTROL_API_AUTH_RULES = [
27 action: "system.state.read",
28 summary: "读取系统状态"
29 },
30+ {
31+ method: "GET",
32+ pathPattern: "/v1/runs",
33+ action: "runs.list",
34+ summary: "列出 run 摘要"
35+ },
36 {
37 method: "GET",
38 pathPattern: "/v1/tasks/:task_id/logs",
+17,
-3
1@@ -45,24 +45,38 @@ export const AUTH_ROLE_ALLOWED_ACTIONS: Record<AuthRole, readonly AuthAction[]>
2 controller: ["controllers.heartbeat", "leader.acquire", "tasks.plan", "tasks.claim"],
3 worker: ["steps.heartbeat", "steps.checkpoint", "steps.complete", "steps.fail"],
4 browser_admin: [
5+ "controllers.list",
6 "tasks.create",
7+ "tasks.list",
8 "system.pause",
9 "system.resume",
10 "system.drain",
11 "system.state.read",
12+ "runs.list",
13 "tasks.read",
14 "tasks.logs.read",
15 "runs.read"
16 ],
17 ops_admin: [
18+ "controllers.list",
19 "system.state.read",
20+ "tasks.list",
21+ "runs.list",
22 "tasks.read",
23 "tasks.logs.read",
24 "runs.read",
25 "maintenance.promote",
26 "maintenance.demote"
27 ],
28- readonly: ["system.state.read", "tasks.read", "tasks.logs.read", "runs.read"]
29+ readonly: [
30+ "controllers.list",
31+ "system.state.read",
32+ "tasks.list",
33+ "runs.list",
34+ "tasks.read",
35+ "tasks.logs.read",
36+ "runs.read"
37+ ]
38 };
39
40 export const AUTH_ROLE_ALLOWED_TOKEN_KINDS: Record<AuthRole, readonly AuthTokenKind[]> = {
41@@ -76,9 +90,9 @@ export const AUTH_ROLE_ALLOWED_TOKEN_KINDS: Record<AuthRole, readonly AuthTokenK
42 const AUTH_ROLE_SUMMARIES: Record<AuthRole, string> = {
43 controller: "只负责 conductor 级写操作:lease、claim、plan 持久化与 controller 心跳。",
44 worker: "只负责已分配 step 的执行回写,不参与调度和系统级操作。",
45- browser_admin: "代表可见 control 会话,可创建 task、暂停/恢复/drain,并查看队列与日志。",
46+ browser_admin: "代表可见 control 会话,可创建 task、暂停/恢复/drain,并查看队列、controller、run 与日志。",
47 ops_admin: "只做主备切换和维护操作,不参与普通 task 调度。",
48- readonly: "只看状态、task、run 和日志,不允许任何写操作。"
49+ readonly: "只看状态、controller、task、run 和日志,不允许任何写操作。"
50 };
51
52 export const AUTH_ROLE_BOUNDARIES = {
+227,
-0
1@@ -293,13 +293,30 @@ export interface TaskArtifactRecord {
2 createdAt: number;
3 }
4
5+export interface ListControllersOptions {
6+ limit?: number;
7+}
8+
9+export interface ListTasksOptions {
10+ limit?: number;
11+ status?: TaskStatus;
12+}
13+
14+export interface ListRunsOptions {
15+ limit?: number;
16+}
17+
18 export interface ControlPlaneRepository {
19 appendTaskLog(record: NewTaskLogRecord): Promise<number | null>;
20+ countActiveRuns(): Promise<number>;
21+ countQueuedTasks(): Promise<number>;
22 heartbeatController(input: ControllerHeartbeatInput): Promise<ControllerRecord>;
23 ensureAutomationState(mode?: AutomationMode): Promise<void>;
24 acquireLeaderLease(input: LeaderLeaseAcquireInput): Promise<LeaderLeaseAcquireResult>;
25 getAutomationState(): Promise<AutomationStateRecord | null>;
26+ getController(controllerId: string): Promise<ControllerRecord | null>;
27 getCurrentLease(): Promise<LeaderLeaseRecord | null>;
28+ getRun(runId: string): Promise<TaskRunRecord | null>;
29 getSystemState(stateKey: string): Promise<SystemStateRecord | null>;
30 getTask(taskId: string): Promise<TaskRecord | null>;
31 insertTask(record: TaskRecord): Promise<void>;
32@@ -308,6 +325,9 @@ export interface ControlPlaneRepository {
33 insertTaskRun(record: TaskRunRecord): Promise<void>;
34 insertTaskStep(record: TaskStepRecord): Promise<void>;
35 insertTaskSteps(records: TaskStepRecord[]): Promise<void>;
36+ listControllers(options?: ListControllersOptions): Promise<ControllerRecord[]>;
37+ listRuns(options?: ListRunsOptions): Promise<TaskRunRecord[]>;
38+ listTasks(options?: ListTasksOptions): Promise<TaskRecord[]>;
39 listTaskSteps(taskId: string): Promise<TaskStepRecord[]>;
40 putLeaderLease(record: LeaderLeaseRecord): Promise<void>;
41 putSystemState(record: SystemStateRecord): Promise<void>;
42@@ -744,6 +764,37 @@ export const SELECT_CURRENT_LEASE_SQL = `
43 WHERE lease_name = ?
44 `;
45
46+export const SELECT_CONTROLLER_SQL = `
47+ SELECT
48+ controller_id,
49+ host,
50+ role,
51+ priority,
52+ status,
53+ version,
54+ last_heartbeat_at,
55+ last_started_at,
56+ metadata_json
57+ FROM controllers
58+ WHERE controller_id = ?
59+`;
60+
61+export const SELECT_CONTROLLERS_SQL = `
62+ SELECT
63+ controller_id,
64+ host,
65+ role,
66+ priority,
67+ status,
68+ version,
69+ last_heartbeat_at,
70+ last_started_at,
71+ metadata_json
72+ FROM controllers
73+ ORDER BY last_heartbeat_at DESC, priority ASC, controller_id ASC
74+ LIMIT ?
75+`;
76+
77 export const UPSERT_LEADER_LEASE_SQL = `
78 INSERT INTO leader_lease (
79 lease_name,
80@@ -891,6 +942,12 @@ export const SELECT_SYSTEM_STATE_SQL = `
81 WHERE state_key = ?
82 `;
83
84+export const SELECT_QUEUED_TASK_COUNT_SQL = `
85+ SELECT COUNT(*) AS value
86+ FROM tasks
87+ WHERE status = 'queued'
88+`;
89+
90 export const INSERT_TASK_SQL = `
91 INSERT INTO tasks (
92 task_id,
93@@ -953,6 +1010,71 @@ export const SELECT_TASK_SQL = `
94 WHERE task_id = ?
95 `;
96
97+export const SELECT_TASKS_SQL = `
98+ SELECT
99+ task_id,
100+ repo,
101+ task_type,
102+ title,
103+ goal,
104+ source,
105+ priority,
106+ status,
107+ planning_strategy,
108+ planner_provider,
109+ branch_name,
110+ base_ref,
111+ target_host,
112+ assigned_controller_id,
113+ current_step_index,
114+ constraints_json,
115+ acceptance_json,
116+ metadata_json,
117+ result_summary,
118+ result_json,
119+ error_text,
120+ created_at,
121+ updated_at,
122+ started_at,
123+ finished_at
124+ FROM tasks
125+ ORDER BY updated_at DESC, created_at DESC
126+ LIMIT ?
127+`;
128+
129+export const SELECT_TASKS_BY_STATUS_SQL = `
130+ SELECT
131+ task_id,
132+ repo,
133+ task_type,
134+ title,
135+ goal,
136+ source,
137+ priority,
138+ status,
139+ planning_strategy,
140+ planner_provider,
141+ branch_name,
142+ base_ref,
143+ target_host,
144+ assigned_controller_id,
145+ current_step_index,
146+ constraints_json,
147+ acceptance_json,
148+ metadata_json,
149+ result_summary,
150+ result_json,
151+ error_text,
152+ created_at,
153+ updated_at,
154+ started_at,
155+ finished_at
156+ FROM tasks
157+ WHERE status = ?
158+ ORDER BY updated_at DESC, created_at DESC
159+ LIMIT ?
160+`;
161+
162 export const INSERT_TASK_STEP_SQL = `
163 INSERT INTO task_steps (
164 step_id,
165@@ -1033,6 +1155,68 @@ export const INSERT_TASK_RUN_SQL = `
166 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
167 `;
168
169+export const SELECT_ACTIVE_RUN_COUNT_SQL = `
170+ SELECT COUNT(*) AS value
171+ FROM task_runs
172+ WHERE started_at IS NOT NULL
173+ AND finished_at IS NULL
174+`;
175+
176+export const SELECT_RUN_SQL = `
177+ SELECT
178+ run_id,
179+ task_id,
180+ step_id,
181+ worker_id,
182+ controller_id,
183+ host,
184+ pid,
185+ status,
186+ lease_expires_at,
187+ heartbeat_at,
188+ log_dir,
189+ stdout_path,
190+ stderr_path,
191+ worker_log_path,
192+ checkpoint_seq,
193+ exit_code,
194+ result_json,
195+ error_text,
196+ created_at,
197+ started_at,
198+ finished_at
199+ FROM task_runs
200+ WHERE run_id = ?
201+`;
202+
203+export const SELECT_RUNS_SQL = `
204+ SELECT
205+ run_id,
206+ task_id,
207+ step_id,
208+ worker_id,
209+ controller_id,
210+ host,
211+ pid,
212+ status,
213+ lease_expires_at,
214+ heartbeat_at,
215+ log_dir,
216+ stdout_path,
217+ stderr_path,
218+ worker_log_path,
219+ checkpoint_seq,
220+ exit_code,
221+ result_json,
222+ error_text,
223+ created_at,
224+ started_at,
225+ finished_at
226+ FROM task_runs
227+ ORDER BY created_at DESC, run_id DESC
228+ LIMIT ?
229+`;
230+
231 export const INSERT_TASK_CHECKPOINT_SQL = `
232 INSERT INTO task_checkpoints (
233 checkpoint_id,
234@@ -1270,6 +1454,14 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
235 ]);
236 }
237
238+ async countActiveRuns(): Promise<number> {
239+ return this.count(SELECT_ACTIVE_RUN_COUNT_SQL);
240+ }
241+
242+ async countQueuedTasks(): Promise<number> {
243+ return this.count(SELECT_QUEUED_TASK_COUNT_SQL);
244+ }
245+
246 async heartbeatController(input: ControllerHeartbeatInput): Promise<ControllerRecord> {
247 const record = buildControllerHeartbeatRecord(input);
248 await this.upsertController(record);
249@@ -1298,11 +1490,21 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
250 return row == null ? null : mapAutomationStateRow(row);
251 }
252
253+ async getController(controllerId: string): Promise<ControllerRecord | null> {
254+ const row = await this.fetchFirst(SELECT_CONTROLLER_SQL, [controllerId]);
255+ return row == null ? null : mapControllerRow(row);
256+ }
257+
258 async getCurrentLease(): Promise<LeaderLeaseRecord | null> {
259 const row = await this.fetchFirst(SELECT_CURRENT_LEASE_SQL, [GLOBAL_LEASE_NAME]);
260 return row == null ? null : mapLeaderLeaseRow(row);
261 }
262
263+ async getRun(runId: string): Promise<TaskRunRecord | null> {
264+ const row = await this.fetchFirst(SELECT_RUN_SQL, [runId]);
265+ return row == null ? null : mapTaskRunRow(row);
266+ }
267+
268 async getSystemState(stateKey: string): Promise<SystemStateRecord | null> {
269 const row = await this.fetchFirst(SELECT_SYSTEM_STATE_SQL, [stateKey]);
270 return row == null ? null : mapSystemStateRow(row);
271@@ -1341,6 +1543,23 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
272 await this.db.batch(records.map((record) => this.bind(INSERT_TASK_STEP_SQL, taskStepParams(record))));
273 }
274
275+ async listControllers(options: ListControllersOptions = {}): Promise<ControllerRecord[]> {
276+ const rows = await this.fetchAll(SELECT_CONTROLLERS_SQL, [options.limit ?? 20]);
277+ return rows.map(mapControllerRow);
278+ }
279+
280+ async listRuns(options: ListRunsOptions = {}): Promise<TaskRunRecord[]> {
281+ const rows = await this.fetchAll(SELECT_RUNS_SQL, [options.limit ?? 20]);
282+ return rows.map(mapTaskRunRow);
283+ }
284+
285+ async listTasks(options: ListTasksOptions = {}): Promise<TaskRecord[]> {
286+ const query = options.status == null ? SELECT_TASKS_SQL : SELECT_TASKS_BY_STATUS_SQL;
287+ const params = options.status == null ? [options.limit ?? 20] : [options.status, options.limit ?? 20];
288+ const rows = await this.fetchAll(query, params);
289+ return rows.map(mapTaskRow);
290+ }
291+
292 async listTaskSteps(taskId: string): Promise<TaskStepRecord[]> {
293 const rows = await this.fetchAll(SELECT_TASK_STEPS_SQL, [taskId]);
294 return rows.map(mapTaskStepRow);
295@@ -1401,6 +1620,14 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
296 return this.bind(query, params).first<DatabaseRow>();
297 }
298
299+ private async count(
300+ query: string,
301+ params: readonly (D1Bindable | undefined)[] = []
302+ ): Promise<number> {
303+ const row = await this.fetchFirst(query, params);
304+ return row == null ? 0 : readRequiredNumber(row, "value");
305+ }
306+
307 private async run(
308 query: string,
309 params: readonly (D1Bindable | undefined)[] = []