baa-conductor

git clone 

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
M README.md
+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、自启动和本地探针稳定
M apps/control-api-worker/src/contracts.ts
+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
M apps/control-api-worker/src/handlers.ts
+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 
M apps/control-api-worker/src/router.ts
+8, -0
 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",
M apps/control-api-worker/src/runtime.ts
+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 {
M apps/control-api-worker/src/schemas.ts
+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>;
M apps/status-api/src/runtime.ts
+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,
M apps/status-api/src/service.ts
+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,
A docs/api/README.md
+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+```
M docs/firefox/README.md
+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 ```
M docs/ops/README.md
+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`,建议先读它再做后续请求
M packages/auth/src/actions.ts
+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,
M packages/auth/src/control-api.ts
+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",
M packages/auth/src/policy.ts
+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 = {
M packages/db/src/index.ts
+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)[] = []