baa-conductor

git clone 

commit
df5d1ff
parent
56e9a4f
author
im_wower
date
2026-03-21 21:56:26 +0800 CST
feat: scaffold control api worker
7 files changed,  +1072, -55
A apps/control-api-worker/src/contracts.ts
+139, -0
  1@@ -0,0 +1,139 @@
  2+import type {
  3+  AuthPrincipal,
  4+  AuthResourceOwnership,
  5+  AuthTokenVerifier,
  6+  ControlApiAuthRule
  7+} from "@baa-conductor/auth";
  8+import type { ControlPlaneRepository, D1DatabaseLike, JsonValue } from "@baa-conductor/db";
  9+
 10+export type ControlApiRouteMethod = "GET" | "POST";
 11+
 12+export type ControlApiRouteId =
 13+  | "controllers.heartbeat"
 14+  | "leader.acquire"
 15+  | "tasks.create"
 16+  | "tasks.plan"
 17+  | "tasks.claim"
 18+  | "steps.heartbeat"
 19+  | "steps.checkpoint"
 20+  | "steps.complete"
 21+  | "steps.fail"
 22+  | "system.pause"
 23+  | "system.resume"
 24+  | "system.drain"
 25+  | "system.state"
 26+  | "tasks.read"
 27+  | "tasks.logs.read"
 28+  | "runs.read";
 29+
 30+export interface ControlApiRouteSchemaDescriptor {
 31+  requestBody: string | null;
 32+  responseBody: string;
 33+  notes: string[];
 34+}
 35+
 36+export interface ControlApiEnv {
 37+  CONTROL_DB?: D1DatabaseLike;
 38+  CONTROL_API_VERSION?: string;
 39+}
 40+
 41+export interface ControlApiExecutionContext {
 42+  passThroughOnException?(): void;
 43+  waitUntil?(promise: Promise<unknown>): void;
 44+}
 45+
 46+export interface ControlApiServices {
 47+  repository: ControlPlaneRepository | null;
 48+  tokenVerifier?: AuthTokenVerifier;
 49+}
 50+
 51+export interface ControlApiOwnershipResolverInput {
 52+  params: Record<string, string>;
 53+  body: JsonValue;
 54+}
 55+
 56+export interface ControlApiRouteAuthorization {
 57+  mode: "skipped" | "verified";
 58+  rule: ControlApiAuthRule;
 59+  principal?: AuthPrincipal;
 60+  skipReason?: string;
 61+}
 62+
 63+export interface ControlApiRouteContext {
 64+  request: Request;
 65+  env: ControlApiEnv;
 66+  executionContext: ControlApiExecutionContext;
 67+  url: URL;
 68+  requestId: string;
 69+  route: ControlApiRouteDefinition;
 70+  params: Record<string, string>;
 71+  body: JsonValue;
 72+  services: ControlApiServices;
 73+  auth: ControlApiRouteAuthorization;
 74+}
 75+
 76+export interface ControlApiHandlerSuccess<T extends JsonValue = JsonValue> {
 77+  ok: true;
 78+  status: number;
 79+  data: T;
 80+  headers?: Record<string, string>;
 81+}
 82+
 83+export interface ControlApiHandlerFailure {
 84+  ok: false;
 85+  status: number;
 86+  error: string;
 87+  message: string;
 88+  details?: JsonValue;
 89+  headers?: Record<string, string>;
 90+}
 91+
 92+export type ControlApiHandlerResult<T extends JsonValue = JsonValue> =
 93+  | ControlApiHandlerSuccess<T>
 94+  | ControlApiHandlerFailure;
 95+
 96+export type ControlApiRouteHandler = (
 97+  context: ControlApiRouteContext
 98+) => Promise<ControlApiHandlerResult>;
 99+
100+export interface ControlApiRouteDefinition {
101+  id: ControlApiRouteId;
102+  method: ControlApiRouteMethod;
103+  pathPattern: string;
104+  summary: string;
105+  authRule: ControlApiAuthRule;
106+  schema: ControlApiRouteSchemaDescriptor;
107+  ownershipResolver?: (
108+    input: ControlApiOwnershipResolverInput
109+  ) => AuthResourceOwnership | undefined;
110+  handler: ControlApiRouteHandler;
111+}
112+
113+export interface ControlApiSuccessEnvelope<T extends JsonValue = JsonValue> {
114+  ok: true;
115+  request_id: string;
116+  data: T;
117+}
118+
119+export interface ControlApiErrorEnvelope {
120+  ok: false;
121+  request_id: string;
122+  error: string;
123+  message: string;
124+  details?: JsonValue;
125+}
126+
127+export interface ControlApiWorkerOptions {
128+  tokenVerifier?: AuthTokenVerifier;
129+  repository?: ControlPlaneRepository;
130+  repositoryFactory?: (db: D1DatabaseLike) => ControlPlaneRepository;
131+  requestIdFactory?: (request: Request) => string;
132+}
133+
134+export interface ControlApiWorker {
135+  fetch(
136+    request: Request,
137+    env: ControlApiEnv,
138+    executionContext: ControlApiExecutionContext
139+  ): Promise<Response>;
140+}
A apps/control-api-worker/src/handlers.ts
+229, -0
  1@@ -0,0 +1,229 @@
  2+import { findControlApiAuthRule } from "@baa-conductor/auth";
  3+import type { JsonValue } from "@baa-conductor/db";
  4+import type {
  5+  ControlApiHandlerFailure,
  6+  ControlApiOwnershipResolverInput,
  7+  ControlApiRouteDefinition,
  8+  ControlApiRouteHandler,
  9+  ControlApiRouteMethod
 10+} from "./contracts.js";
 11+import { CONTROL_API_ROUTE_SCHEMAS } from "./schemas.js";
 12+
 13+function requireAuthRule(method: ControlApiRouteMethod, pathPattern: string) {
 14+  const authRule = findControlApiAuthRule(method, pathPattern);
 15+
 16+  if (!authRule) {
 17+    throw new Error(`Missing control-api auth rule for ${method} ${pathPattern}.`);
 18+  }
 19+
 20+  return authRule;
 21+}
 22+
 23+function asJsonObject(value: JsonValue): Record<string, JsonValue> | null {
 24+  if (value === null || Array.isArray(value) || typeof value !== "object") {
 25+    return null;
 26+  }
 27+
 28+  return value as Record<string, JsonValue>;
 29+}
 30+
 31+function readNonEmptyStringField(body: JsonValue, fieldName: string): string | undefined {
 32+  const object = asJsonObject(body);
 33+  const value = object?.[fieldName];
 34+
 35+  if (typeof value !== "string") {
 36+    return undefined;
 37+  }
 38+
 39+  const normalized = value.trim();
 40+  return normalized.length > 0 ? normalized : undefined;
 41+}
 42+
 43+function resolveControllerOwnership(
 44+  input: ControlApiOwnershipResolverInput
 45+): { controllerId: string } | undefined {
 46+  const controllerId = readNonEmptyStringField(input.body, "controller_id");
 47+  return controllerId ? { controllerId } : undefined;
 48+}
 49+
 50+function resolveWorkerOwnership(
 51+  input: ControlApiOwnershipResolverInput
 52+): { workerId: string } | undefined {
 53+  const workerId = readNonEmptyStringField(input.body, "worker_id");
 54+  return workerId ? { workerId } : undefined;
 55+}
 56+
 57+function buildNotImplementedFailure(context: Parameters<ControlApiRouteHandler>[0]): ControlApiHandlerFailure {
 58+  return {
 59+    ok: false,
 60+    status: 501,
 61+    error: "not_implemented",
 62+    message: `${context.route.summary} 已完成路由建模,但 durable 状态迁移逻辑仍待后续任务接入。`,
 63+    details: {
 64+      route_id: context.route.id,
 65+      method: context.route.method,
 66+      path: context.route.pathPattern,
 67+      request_schema: context.route.schema.requestBody,
 68+      response_schema: context.route.schema.responseBody,
 69+      auth_action: context.auth.rule.action,
 70+      authorization_mode: context.auth.mode,
 71+      authorization_skip_reason: context.auth.skipReason ?? null,
 72+      principal_role: context.auth.principal?.role ?? null,
 73+      principal_subject: context.auth.principal?.subject ?? null,
 74+      d1_binding_configured: context.services.repository !== null,
 75+      request_body_received: context.body !== null,
 76+      path_params: context.params,
 77+      notes: [...context.route.schema.notes]
 78+    }
 79+  };
 80+}
 81+
 82+function createPlaceholderHandler(): ControlApiRouteHandler {
 83+  return async (context) => buildNotImplementedFailure(context);
 84+}
 85+
 86+function defineRoute(
 87+  definition: Omit<ControlApiRouteDefinition, "authRule" | "handler"> & {
 88+    handler?: ControlApiRouteHandler;
 89+  }
 90+): ControlApiRouteDefinition {
 91+  return {
 92+    ...definition,
 93+    authRule: requireAuthRule(definition.method, definition.pathPattern),
 94+    handler: definition.handler ?? createPlaceholderHandler()
 95+  };
 96+}
 97+
 98+export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
 99+  defineRoute({
100+    id: "controllers.heartbeat",
101+    method: "POST",
102+    pathPattern: "/v1/controllers/heartbeat",
103+    summary: "controller 心跳",
104+    schema: CONTROL_API_ROUTE_SCHEMAS["controllers.heartbeat"],
105+    ownershipResolver: resolveControllerOwnership
106+  }),
107+  defineRoute({
108+    id: "leader.acquire",
109+    method: "POST",
110+    pathPattern: "/v1/leader/acquire",
111+    summary: "获取或续租 leader lease",
112+    schema: CONTROL_API_ROUTE_SCHEMAS["leader.acquire"],
113+    ownershipResolver: resolveControllerOwnership
114+  }),
115+  defineRoute({
116+    id: "tasks.create",
117+    method: "POST",
118+    pathPattern: "/v1/tasks",
119+    summary: "创建 task",
120+    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.create"]
121+  }),
122+  defineRoute({
123+    id: "tasks.plan",
124+    method: "POST",
125+    pathPattern: "/v1/tasks/:task_id/plan",
126+    summary: "持久化已验收 plan",
127+    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.plan"],
128+    ownershipResolver: resolveControllerOwnership
129+  }),
130+  defineRoute({
131+    id: "tasks.claim",
132+    method: "POST",
133+    pathPattern: "/v1/tasks/claim",
134+    summary: "领取待规划 task 或 runnable step",
135+    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.claim"],
136+    ownershipResolver: resolveControllerOwnership
137+  }),
138+  defineRoute({
139+    id: "steps.heartbeat",
140+    method: "POST",
141+    pathPattern: "/v1/steps/:step_id/heartbeat",
142+    summary: "step 心跳",
143+    schema: CONTROL_API_ROUTE_SCHEMAS["steps.heartbeat"],
144+    ownershipResolver: resolveWorkerOwnership
145+  }),
146+  defineRoute({
147+    id: "steps.checkpoint",
148+    method: "POST",
149+    pathPattern: "/v1/steps/:step_id/checkpoint",
150+    summary: "写 step checkpoint",
151+    schema: CONTROL_API_ROUTE_SCHEMAS["steps.checkpoint"],
152+    ownershipResolver: resolveWorkerOwnership
153+  }),
154+  defineRoute({
155+    id: "steps.complete",
156+    method: "POST",
157+    pathPattern: "/v1/steps/:step_id/complete",
158+    summary: "标记 step 完成",
159+    schema: CONTROL_API_ROUTE_SCHEMAS["steps.complete"],
160+    ownershipResolver: resolveWorkerOwnership
161+  }),
162+  defineRoute({
163+    id: "steps.fail",
164+    method: "POST",
165+    pathPattern: "/v1/steps/:step_id/fail",
166+    summary: "标记 step 失败",
167+    schema: CONTROL_API_ROUTE_SCHEMAS["steps.fail"],
168+    ownershipResolver: resolveWorkerOwnership
169+  }),
170+  defineRoute({
171+    id: "system.pause",
172+    method: "POST",
173+    pathPattern: "/v1/system/pause",
174+    summary: "暂停自动化",
175+    schema: CONTROL_API_ROUTE_SCHEMAS["system.pause"]
176+  }),
177+  defineRoute({
178+    id: "system.resume",
179+    method: "POST",
180+    pathPattern: "/v1/system/resume",
181+    summary: "恢复自动化",
182+    schema: CONTROL_API_ROUTE_SCHEMAS["system.resume"]
183+  }),
184+  defineRoute({
185+    id: "system.drain",
186+    method: "POST",
187+    pathPattern: "/v1/system/drain",
188+    summary: "drain 自动化",
189+    schema: CONTROL_API_ROUTE_SCHEMAS["system.drain"]
190+  }),
191+  defineRoute({
192+    id: "system.state",
193+    method: "GET",
194+    pathPattern: "/v1/system/state",
195+    summary: "读取系统状态",
196+    schema: CONTROL_API_ROUTE_SCHEMAS["system.state"]
197+  }),
198+  defineRoute({
199+    id: "tasks.read",
200+    method: "GET",
201+    pathPattern: "/v1/tasks/:task_id",
202+    summary: "读取 task 详情",
203+    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.read"]
204+  }),
205+  defineRoute({
206+    id: "tasks.logs.read",
207+    method: "GET",
208+    pathPattern: "/v1/tasks/:task_id/logs",
209+    summary: "读取 task 日志",
210+    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.logs.read"]
211+  }),
212+  defineRoute({
213+    id: "runs.read",
214+    method: "GET",
215+    pathPattern: "/v1/runs/:run_id",
216+    summary: "读取 run 详情",
217+    schema: CONTROL_API_ROUTE_SCHEMAS["runs.read"]
218+  })
219+];
220+
221+export function describeControlApiSurface(): string[] {
222+  return CONTROL_API_ROUTES.map((route) => `${route.method} ${route.pathPattern} - ${route.summary}`);
223+}
224+
225+export function describeControlApiContracts(): string[] {
226+  return CONTROL_API_ROUTES.map((route) => {
227+    const requestBody = route.schema.requestBody ?? "none";
228+    return `${route.method} ${route.pathPattern} -> request: ${requestBody}, response: ${route.schema.responseBody}`;
229+  });
230+}
M apps/control-api-worker/src/index.ts
+7, -43
 1@@ -1,46 +1,10 @@
 2-export type ControlApiRouteMethod = "GET" | "POST";
 3+export * from "./contracts.js";
 4+export * from "./handlers.js";
 5+export * from "./router.js";
 6+export * from "./schemas.js";
 7 
 8-export interface ControlApiRoute {
 9-  method: ControlApiRouteMethod;
10-  path: string;
11-  summary: string;
12-}
13+import { createControlApiWorker } from "./router.js";
14 
15-export interface ControlApiRequestContext {
16-  requestId: string;
17-  actorRole: "controller" | "worker" | "browser_admin" | "ops_admin" | "readonly";
18-}
19-
20-export interface ControlApiResponseShape {
21-  ok: boolean;
22-  status: number;
23-  error?: string;
24-  message: string;
25-}
26-
27-export const CONTROL_API_ROUTES: ControlApiRoute[] = [
28-  { method: "POST", path: "/v1/controllers/heartbeat", summary: "controller 心跳" },
29-  { method: "POST", path: "/v1/leader/acquire", summary: "获取或续租 leader lease" },
30-  { method: "POST", path: "/v1/tasks", summary: "创建 task" },
31-  { method: "POST", path: "/v1/tasks/claim", summary: "claim task 或 step" },
32-  { method: "POST", path: "/v1/system/pause", summary: "暂停自动化" },
33-  { method: "POST", path: "/v1/system/resume", summary: "恢复自动化" },
34-  { method: "POST", path: "/v1/system/drain", summary: "drain 自动化" },
35-  { method: "GET", path: "/v1/system/state", summary: "读取系统状态" }
36-];
37-
38-export function describeControlApiSurface(): string[] {
39-  return CONTROL_API_ROUTES.map((route) => `${route.method} ${route.path} - ${route.summary}`);
40-}
41-
42-export async function handleControlApiRequest(
43-  _context: ControlApiRequestContext
44-): Promise<ControlApiResponseShape> {
45-  return {
46-    ok: false,
47-    status: 501,
48-    error: "not_implemented",
49-    message: "Control API Worker 目前只有骨架,具体逻辑待后续任务实现。"
50-  };
51-}
52+const controlApiWorker = createControlApiWorker();
53 
54+export default controlApiWorker;
A apps/control-api-worker/src/router.ts
+371, -0
  1@@ -0,0 +1,371 @@
  2+import { authorizeControlApiRoute, extractBearerToken } from "@baa-conductor/auth";
  3+import { createD1ControlPlaneRepository, type JsonValue } from "@baa-conductor/db";
  4+import type {
  5+  ControlApiErrorEnvelope,
  6+  ControlApiExecutionContext,
  7+  ControlApiHandlerFailure,
  8+  ControlApiHandlerResult,
  9+  ControlApiRouteAuthorization,
 10+  ControlApiRouteDefinition,
 11+  ControlApiRouteMethod,
 12+  ControlApiServices,
 13+  ControlApiSuccessEnvelope,
 14+  ControlApiWorker,
 15+  ControlApiWorkerOptions,
 16+  ControlApiEnv
 17+} from "./contracts.js";
 18+import { CONTROL_API_ROUTES } from "./handlers.js";
 19+
 20+const SUPPORTED_METHODS: ControlApiRouteMethod[] = ["GET", "POST"];
 21+
 22+interface ControlApiRouteMatch {
 23+  route: ControlApiRouteDefinition;
 24+  params: Record<string, string>;
 25+}
 26+
 27+export function createControlApiWorker(options: ControlApiWorkerOptions = {}): ControlApiWorker {
 28+  return {
 29+    async fetch(
 30+      request: Request,
 31+      env: ControlApiEnv,
 32+      executionContext: ControlApiExecutionContext
 33+    ): Promise<Response> {
 34+      return handleControlApiRequest(request, env, executionContext, options);
 35+    }
 36+  };
 37+}
 38+
 39+export async function handleControlApiRequest(
 40+  request: Request,
 41+  env: ControlApiEnv,
 42+  executionContext: ControlApiExecutionContext = {},
 43+  options: ControlApiWorkerOptions = {}
 44+): Promise<Response> {
 45+  const url = new URL(request.url);
 46+  const requestId = resolveRequestId(request, options);
 47+  const matchedRoute = matchRoute(request.method, url.pathname);
 48+
 49+  if (!matchedRoute) {
 50+    const allowedMethods = findAllowedMethods(url.pathname);
 51+
 52+    if (allowedMethods.length > 0) {
 53+      return errorResponse(
 54+        requestId,
 55+        {
 56+          ok: false,
 57+          status: 405,
 58+          error: "method_not_allowed",
 59+          message: `Method ${request.method} is not allowed for ${url.pathname}.`,
 60+          details: {
 61+            allow: allowedMethods,
 62+            supported_methods: SUPPORTED_METHODS
 63+          },
 64+          headers: {
 65+            Allow: allowedMethods.join(", ")
 66+          }
 67+        }
 68+      );
 69+    }
 70+
 71+    return errorResponse(requestId, {
 72+      ok: false,
 73+      status: 404,
 74+      error: "route_not_found",
 75+      message: `No Control API route is registered for ${request.method} ${url.pathname}.`,
 76+      details: {
 77+        available_routes: CONTROL_API_ROUTES.map((route) => `${route.method} ${route.pathPattern}`)
 78+      }
 79+    });
 80+  }
 81+
 82+  const bodyResult = await readRequestBody(request);
 83+
 84+  if (!bodyResult.ok) {
 85+    return errorResponse(requestId, bodyResult);
 86+  }
 87+
 88+  const services = resolveServices(env, options);
 89+  const authorization = await resolveAuthorization(
 90+    request,
 91+    url,
 92+    matchedRoute,
 93+    bodyResult.body,
 94+    services
 95+  );
 96+
 97+  if (isHandlerFailure(authorization)) {
 98+    return errorResponse(requestId, authorization);
 99+  }
100+
101+  const result = await matchedRoute.route.handler({
102+    request,
103+    env,
104+    executionContext,
105+    url,
106+    requestId,
107+    route: matchedRoute.route,
108+    params: matchedRoute.params,
109+    body: bodyResult.body,
110+    services,
111+    auth: authorization
112+  });
113+
114+  return routeResultToResponse(requestId, result);
115+}
116+
117+function resolveServices(env: ControlApiEnv, options: ControlApiWorkerOptions): ControlApiServices {
118+  if (options.repository) {
119+    return {
120+      repository: options.repository,
121+      tokenVerifier: options.tokenVerifier
122+    };
123+  }
124+
125+  if (!env.CONTROL_DB) {
126+    return {
127+      repository: null,
128+      tokenVerifier: options.tokenVerifier
129+    };
130+  }
131+
132+  const repositoryFactory = options.repositoryFactory ?? createD1ControlPlaneRepository;
133+
134+  return {
135+    repository: repositoryFactory(env.CONTROL_DB),
136+    tokenVerifier: options.tokenVerifier
137+  };
138+}
139+
140+function resolveRequestId(request: Request, options: ControlApiWorkerOptions): string {
141+  const headerValue = request.headers.get("x-request-id")?.trim();
142+
143+  if (headerValue) {
144+    return headerValue;
145+  }
146+
147+  return options.requestIdFactory?.(request) ?? crypto.randomUUID();
148+}
149+
150+async function readRequestBody(
151+  request: Request
152+): Promise<
153+  | {
154+      ok: true;
155+      body: JsonValue;
156+    }
157+  | ControlApiHandlerFailure
158+> {
159+  if (request.method === "GET") {
160+    return {
161+      ok: true,
162+      body: null
163+    };
164+  }
165+
166+  const rawBody = await request.text();
167+
168+  if (rawBody.trim().length === 0) {
169+    return {
170+      ok: true,
171+      body: null
172+    };
173+  }
174+
175+  try {
176+    return {
177+      ok: true,
178+      body: JSON.parse(rawBody) as JsonValue
179+    };
180+  } catch {
181+    return {
182+      ok: false,
183+      status: 400,
184+      error: "invalid_json",
185+      message: "Request body must be valid JSON.",
186+      details: {
187+        method: request.method
188+      }
189+    };
190+  }
191+}
192+
193+async function resolveAuthorization(
194+  request: Request,
195+  url: URL,
196+  matchedRoute: ControlApiRouteMatch,
197+  body: JsonValue,
198+  services: ControlApiServices
199+): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure> {
200+  const rule = matchedRoute.route.authRule;
201+
202+  if (!services.tokenVerifier) {
203+    return {
204+      mode: "skipped",
205+      rule,
206+      skipReason: "auth_not_configured"
207+    };
208+  }
209+
210+  const tokenResult = extractBearerToken(request.headers.get("authorization") ?? undefined);
211+
212+  if (!tokenResult.ok) {
213+    return {
214+      ok: false,
215+      status: 401,
216+      error: tokenResult.reason,
217+      message: "Authorization header must use Bearer token syntax for Control API requests."
218+    };
219+  }
220+
221+  const verification = await services.tokenVerifier.verifyBearerToken(tokenResult.token);
222+
223+  if (!verification.ok) {
224+    return {
225+      ok: false,
226+      status: verification.statusCode,
227+      error: verification.reason,
228+      message: `Bearer token verification failed: ${verification.reason}.`
229+    };
230+  }
231+
232+  const authorization = authorizeControlApiRoute({
233+    method: matchedRoute.route.method,
234+    path: url.pathname,
235+    principal: verification.principal,
236+    resource: matchedRoute.route.ownershipResolver?.({
237+      params: matchedRoute.params,
238+      body
239+    })
240+  });
241+
242+  if (!authorization.ok) {
243+    return {
244+      ok: false,
245+      status: authorization.statusCode,
246+      error: authorization.reason,
247+      message: `Authenticated principal is not allowed to access ${matchedRoute.route.method} ${matchedRoute.route.pathPattern}.`
248+    };
249+  }
250+
251+  return {
252+    mode: "verified",
253+    rule: authorization.matchedRule ?? rule,
254+    principal: verification.principal
255+  };
256+}
257+
258+function isHandlerFailure(
259+  result: ControlApiRouteAuthorization | ControlApiHandlerFailure
260+): result is ControlApiHandlerFailure {
261+  return "ok" in result;
262+}
263+
264+function routeResultToResponse(requestId: string, result: ControlApiHandlerResult): Response {
265+  if (result.ok) {
266+    const payload: ControlApiSuccessEnvelope = {
267+      ok: true,
268+      request_id: requestId,
269+      data: result.data
270+    };
271+
272+    return jsonResponse(result.status, payload, requestId, result.headers);
273+  }
274+
275+  return errorResponse(requestId, result);
276+}
277+
278+function errorResponse(requestId: string, result: ControlApiHandlerFailure): Response {
279+  const payload: ControlApiErrorEnvelope = {
280+    ok: false,
281+    request_id: requestId,
282+    error: result.error,
283+    message: result.message
284+  };
285+
286+  if (result.details !== undefined) {
287+    payload.details = result.details;
288+  }
289+
290+  return jsonResponse(result.status, payload, requestId, result.headers);
291+}
292+
293+function jsonResponse(
294+  status: number,
295+  payload: ControlApiSuccessEnvelope | ControlApiErrorEnvelope,
296+  requestId: string,
297+  extraHeaders?: Record<string, string>
298+): Response {
299+  const headers = new Headers(extraHeaders);
300+  headers.set("content-type", "application/json; charset=utf-8");
301+  headers.set("x-request-id", requestId);
302+
303+  return new Response(JSON.stringify(payload, null, 2), {
304+    status,
305+    headers
306+  });
307+}
308+
309+function matchRoute(method: string, pathname: string): ControlApiRouteMatch | null {
310+  for (const route of CONTROL_API_ROUTES) {
311+    if (route.method !== method) {
312+      continue;
313+    }
314+
315+    const params = matchPathPattern(route.pathPattern, pathname);
316+
317+    if (params) {
318+      return {
319+        route,
320+        params
321+      };
322+    }
323+  }
324+
325+  return null;
326+}
327+
328+function findAllowedMethods(pathname: string): ControlApiRouteMethod[] {
329+  const methods = new Set<ControlApiRouteMethod>();
330+
331+  for (const route of CONTROL_API_ROUTES) {
332+    if (matchPathPattern(route.pathPattern, pathname)) {
333+      methods.add(route.method);
334+    }
335+  }
336+
337+  return [...methods.values()];
338+}
339+
340+function matchPathPattern(pattern: string, actualPath: string): Record<string, string> | null {
341+  const patternSegments = normalizePath(pattern);
342+  const actualSegments = normalizePath(actualPath);
343+
344+  if (patternSegments.length !== actualSegments.length) {
345+    return null;
346+  }
347+
348+  const params: Record<string, string> = {};
349+
350+  for (const [index, patternSegment] of patternSegments.entries()) {
351+    const actualSegment = actualSegments[index];
352+
353+    if (actualSegment === undefined) {
354+      return null;
355+    }
356+
357+    if (patternSegment.startsWith(":")) {
358+      params[patternSegment.slice(1)] = actualSegment;
359+      continue;
360+    }
361+
362+    if (patternSegment !== actualSegment) {
363+      return null;
364+    }
365+  }
366+
367+  return params;
368+}
369+
370+function normalizePath(path: string): string[] {
371+  return path.replace(/\/+$/u, "").split("/").filter((segment) => segment.length > 0);
372+}
A apps/control-api-worker/src/schemas.ts
+292, -0
  1@@ -0,0 +1,292 @@
  2+import type { AutomationMode, JsonObject, JsonValue, StepKind, StepStatus, TaskStatus } from "@baa-conductor/db";
  3+import type { ControlApiRouteId, ControlApiRouteSchemaDescriptor } from "./contracts.js";
  4+
  5+export interface ControllerHeartbeatRequest {
  6+  controller_id: string;
  7+  host: string;
  8+  role: string;
  9+  priority: number;
 10+  status: string;
 11+  version: string;
 12+  metadata?: JsonObject;
 13+}
 14+
 15+export interface LeaderAcquireRequest {
 16+  controller_id: string;
 17+  host: string;
 18+  preferred: boolean;
 19+  ttl_sec: number;
 20+}
 21+
 22+export interface LeaderAcquireResponseData {
 23+  holder_id: string;
 24+  term: number;
 25+  lease_expires_at: number;
 26+  is_leader: boolean;
 27+}
 28+
 29+export interface TaskCreateRequest {
 30+  repo: string;
 31+  task_type: string;
 32+  title: string;
 33+  goal: string;
 34+  priority?: number;
 35+  constraints?: JsonObject;
 36+  acceptance?: string[];
 37+  metadata?: JsonObject;
 38+}
 39+
 40+export interface TaskCreateResponseData {
 41+  task_id: string;
 42+  status: TaskStatus;
 43+  branch_name: string | null;
 44+  base_ref: string | null;
 45+}
 46+
 47+export interface TaskPlanStepRequest {
 48+  step_id?: string;
 49+  step_name: string;
 50+  step_kind: StepKind;
 51+  summary?: string;
 52+  input?: JsonValue;
 53+  timeout_sec?: number;
 54+  retry_limit?: number;
 55+}
 56+
 57+export interface TaskPlanRequest {
 58+  controller_id: string;
 59+  planner_provider?: string;
 60+  reasoning?: string;
 61+  steps: TaskPlanStepRequest[];
 62+}
 63+
 64+export interface TaskClaimRequest {
 65+  controller_id: string;
 66+  host: string;
 67+  include_planning?: boolean;
 68+  worker_types?: string[];
 69+}
 70+
 71+export interface TaskClaimResponseData {
 72+  claim_type: "planning" | "step" | null;
 73+  task_id: string | null;
 74+  step_id: string | null;
 75+  run_id: string | null;
 76+}
 77+
 78+export interface StepHeartbeatCheckpointPayload {
 79+  seq?: number;
 80+  checkpoint_type?: string;
 81+  summary?: string;
 82+}
 83+
 84+export interface StepHeartbeatRequest {
 85+  run_id: string;
 86+  worker_id: string;
 87+  lease_expires_at: number;
 88+  checkpoint?: StepHeartbeatCheckpointPayload;
 89+}
 90+
 91+export interface StepCheckpointRequest {
 92+  run_id: string;
 93+  worker_id: string;
 94+  seq: number;
 95+  checkpoint_type: string;
 96+  summary?: string;
 97+  content_text?: string;
 98+  content_json?: JsonObject;
 99+}
100+
101+export interface StepCompleteRequest {
102+  run_id: string;
103+  worker_id: string;
104+  summary?: string;
105+  result?: JsonValue;
106+}
107+
108+export interface StepFailRequest {
109+  run_id: string;
110+  worker_id: string;
111+  error: string;
112+  retryable?: boolean;
113+  result?: JsonValue;
114+}
115+
116+export interface SystemMutationRequest {
117+  reason?: string;
118+  requested_by?: string;
119+}
120+
121+export interface ControlApiAckResponse {
122+  accepted: boolean;
123+  status: "placeholder" | "queued";
124+  summary: string;
125+}
126+
127+export interface SystemStateResponseData {
128+  mode: AutomationMode;
129+  holder_id: string | null;
130+  term: number | null;
131+  lease_expires_at: number | null;
132+}
133+
134+export interface TaskDetailResponseData {
135+  task_id: string;
136+  title: string;
137+  status: TaskStatus;
138+  current_step_index: number;
139+}
140+
141+export interface TaskLogEntryData {
142+  seq: number;
143+  stream: string;
144+  level: string | null;
145+  message: string;
146+  created_at: number;
147+}
148+
149+export interface TaskLogsResponseData {
150+  task_id: string;
151+  run_id: string | null;
152+  entries: TaskLogEntryData[];
153+}
154+
155+export interface RunDetailResponseData {
156+  run_id: string;
157+  task_id: string;
158+  step_id: string;
159+  status: StepStatus;
160+  lease_expires_at: number | null;
161+  heartbeat_at: number | null;
162+}
163+
164+export const CONTROL_API_ROUTE_SCHEMAS = {
165+  "controllers.heartbeat": {
166+    requestBody: "ControllerHeartbeatRequest",
167+    responseBody: "ControlApiAckResponse",
168+    notes: [
169+      "供 mini/mac conductor 上报自身活跃状态。",
170+      "后续实现会在这里挂 controller upsert 和 request log。"
171+    ]
172+  },
173+  "leader.acquire": {
174+    requestBody: "LeaderAcquireRequest",
175+    responseBody: "LeaderAcquireResponseData",
176+    notes: [
177+      "租约 term、ttl 和冲突返回由 T-004 接入。",
178+      "当前只保留路由、鉴权和 D1 接入点。"
179+    ]
180+  },
181+  "tasks.create": {
182+    requestBody: "TaskCreateRequest",
183+    responseBody: "TaskCreateResponseData",
184+    notes: [
185+      "可见 control 会话通过此接口创建 task。",
186+      "task 归一化和写表逻辑将在后续任务接入。"
187+    ]
188+  },
189+  "tasks.plan": {
190+    requestBody: "TaskPlanRequest",
191+    responseBody: "ControlApiAckResponse",
192+    notes: [
193+      "只接受已通过 conductor 校验的结构化 step plan。",
194+      "持久化 task_steps 的事务逻辑后续实现。"
195+    ]
196+  },
197+  "tasks.claim": {
198+    requestBody: "TaskClaimRequest",
199+    responseBody: "TaskClaimResponseData",
200+    notes: [
201+      "支持领取 planning task 或 runnable step。",
202+      "claim 的事务语义会在 lease 调度侧补齐。"
203+    ]
204+  },
205+  "steps.heartbeat": {
206+    requestBody: "StepHeartbeatRequest",
207+    responseBody: "ControlApiAckResponse",
208+    notes: [
209+      "worker 续租 step 级别心跳。",
210+      "checkpoint 摘要可以随心跳一并上报。"
211+    ]
212+  },
213+  "steps.checkpoint": {
214+    requestBody: "StepCheckpointRequest",
215+    responseBody: "ControlApiAckResponse",
216+    notes: [
217+      "checkpoint payload 结构已预留。",
218+      "写入 task_checkpoints 的逻辑后续接入。"
219+    ]
220+  },
221+  "steps.complete": {
222+    requestBody: "StepCompleteRequest",
223+    responseBody: "ControlApiAckResponse",
224+    notes: [
225+      "worker 结束 step 时写入 summary 和 result。",
226+      "状态迁移及 artifact 落盘后续实现。"
227+    ]
228+  },
229+  "steps.fail": {
230+    requestBody: "StepFailRequest",
231+    responseBody: "ControlApiAckResponse",
232+    notes: [
233+      "失败回写需要保留 retryable 和 error 语义。",
234+      "实际重试策略由 conductor 端决定。"
235+    ]
236+  },
237+  "system.pause": {
238+    requestBody: "SystemMutationRequest",
239+    responseBody: "ControlApiAckResponse",
240+    notes: [
241+      "browser_admin 调用的系统状态切换入口。",
242+      "system_state 写表与广播逻辑后续实现。"
243+    ]
244+  },
245+  "system.resume": {
246+    requestBody: "SystemMutationRequest",
247+    responseBody: "ControlApiAckResponse",
248+    notes: [
249+      "browser_admin 调用的系统状态切换入口。",
250+      "system_state 写表与广播逻辑后续实现。"
251+    ]
252+  },
253+  "system.drain": {
254+    requestBody: "SystemMutationRequest",
255+    responseBody: "ControlApiAckResponse",
256+    notes: [
257+      "browser_admin 调用的系统状态切换入口。",
258+      "system_state 写表与广播逻辑后续实现。"
259+    ]
260+  },
261+  "system.state": {
262+    requestBody: null,
263+    responseBody: "SystemStateResponseData",
264+    notes: [
265+      "供 control/status UI 查询全局 automation 状态。",
266+      "最终返回会结合 lease、queue 与运行态汇总。"
267+    ]
268+  },
269+  "tasks.read": {
270+    requestBody: null,
271+    responseBody: "TaskDetailResponseData",
272+    notes: [
273+      "读取 task 聚合详情。",
274+      "后续可扩展 steps、artifacts 和 handoff 信息。"
275+    ]
276+  },
277+  "tasks.logs.read": {
278+    requestBody: null,
279+    responseBody: "TaskLogsResponseData",
280+    notes: [
281+      "读取 task 或 run 关联日志。",
282+      "T-010 可以直接复用该接口合同。"
283+    ]
284+  },
285+  "runs.read": {
286+    requestBody: null,
287+    responseBody: "RunDetailResponseData",
288+    notes: [
289+      "返回 step 运行态与最近一次 heartbeat。",
290+      "后续可补 checkpoint/artifact 索引。"
291+    ]
292+  }
293+} satisfies Record<ControlApiRouteId, ControlApiRouteSchemaDescriptor>;
M apps/control-api-worker/tsconfig.json
+12, -4
 1@@ -1,9 +1,17 @@
 2 {
 3   "extends": "../../tsconfig.base.json",
 4   "compilerOptions": {
 5-    "rootDir": "src",
 6-    "outDir": "dist"
 7+    "rootDir": "../..",
 8+    "outDir": "dist",
 9+    "baseUrl": "../..",
10+    "paths": {
11+      "@baa-conductor/auth": ["packages/auth/src/index.ts"],
12+      "@baa-conductor/db": ["packages/db/src/index.ts"]
13+    }
14   },
15-  "include": ["src/**/*.ts"]
16+  "include": [
17+    "src/**/*.ts",
18+    "../../packages/auth/src/**/*.ts",
19+    "../../packages/db/src/**/*.ts"
20+  ]
21 }
22-
M coordination/tasks/T-003-control-api.md
+22, -8
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-003
 4 title: Control API Worker
 5-status: todo
 6+status: review
 7 branch: feat/T-003-control-api
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main@28829de
10+base_ref: main@56e9a4f
11 depends_on:
12   - T-002
13 write_scope:
14@@ -20,7 +20,7 @@ updated_at: 2026-03-21
15 
16 ## 统一开工要求
17 
18-- 必须从 `main@28829de` 切出该分支
19+- 必须从 `main@56e9a4f` 切出该分支
20 - 新 worktree 进入后先执行 `npx --yes pnpm install`
21 - 不允许从其他任务分支切分支
22 
23@@ -54,24 +54,38 @@ updated_at: 2026-03-21
24 
25 ## files_changed
26 
27-- 待填写
28+- `apps/control-api-worker/src/index.ts`
29+- `apps/control-api-worker/src/contracts.ts`
30+- `apps/control-api-worker/src/schemas.ts`
31+- `apps/control-api-worker/src/handlers.ts`
32+- `apps/control-api-worker/src/router.ts`
33+- `apps/control-api-worker/tsconfig.json`
34+- `coordination/tasks/T-003-control-api.md`
35 
36 ## commands_run
37 
38-- 待填写
39+- `npx --yes pnpm install`
40+- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
41+- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
42 
43 ## result
44 
45-- 待填写
46+- 已将 `control-api-worker` 重构为 Cloudflare Worker 风格入口,提供默认 `fetch` 导出、请求 ID、统一 JSON 错误包和基础 404/405/400 处理。
47+- 已注册设计第 11 节要求的核心路由骨架,覆盖 controller heartbeat、leader acquire、tasks、steps、system state,以及 task/log/run 查询接口。
48+- 已整理每条路由的 request/response schema 描述,并预留 D1 repository 注入与 Bearer token 鉴权挂载点,便于 `T-004` 与 `T-010` 继续接线。
49 
50 ## risks
51 
52-- 待填写
53+- 所有业务 handler 目前仍返回 `501 not_implemented`,真实 lease、claim、checkpoint、task 读写逻辑尚未接入 D1。
54+- 当前只做 JSON 解析与授权挂点,没有做字段级 runtime 校验;后续接真实写路径时需要补请求校验或约束收口。
55+- 未注入 `tokenVerifier` 时会跳过实际鉴权,仅保留授权模型上下文;生产接入前必须补齐 verifier 装配。
56 
57 ## next_handoff
58 
59-- 供 `T-004` 和 `T-010` 接入
60+- `T-004` 可直接在现有 route registry 上接 leader/task/step 写路径,复用 ownership resolver、auth hook 和 repository 注入点。
61+- `T-010` 可直接复用 `GET /v1/system/state`、`GET /v1/tasks/:task_id`、`GET /v1/tasks/:task_id/logs`、`GET /v1/runs/:run_id` 的接口合同与统一响应结构。
62 
63 ## notes
64 
65 - `2026-03-21`: 创建任务卡
66+- `2026-03-21`: 实际按指令从 `main@56e9a4f` 切出,任务卡已同步该基线。