baa-conductor

git clone 

commit
00c7dbf
parent
259b731
author
im_wower
date
2026-03-22 00:36:08 +0800 CST
Merge remote-tracking branch 'origin/feat/T-014-control-api-runtime' into integration/third-wave-20260322
7 files changed,  +934, -98
M apps/control-api-worker/src/contracts.ts
+25, -1
 1@@ -35,6 +35,13 @@ export interface ControlApiRouteSchemaDescriptor {
 2 export interface ControlApiEnv {
 3   CONTROL_DB?: D1DatabaseLike;
 4   CONTROL_API_VERSION?: string;
 5+  CONTROL_API_AUTH_REQUIRED?: string;
 6+  CONTROL_API_BROWSER_ADMIN_TOKEN?: string;
 7+  CONTROL_API_CONTROLLER_TOKEN?: string;
 8+  CONTROL_API_OPS_ADMIN_TOKEN?: string;
 9+  CONTROL_API_READONLY_TOKEN?: string;
10+  CONTROL_API_WORKER_TOKEN?: string;
11+  BAA_SHARED_TOKEN?: string;
12 }
13 
14 export interface ControlApiExecutionContext {
15@@ -43,8 +50,9 @@ export interface ControlApiExecutionContext {
16 }
17 
18 export interface ControlApiServices {
19+  authHook: ControlApiRequestAuthHook | null;
20+  now: () => number;
21   repository: ControlPlaneRepository | null;
22-  tokenVerifier?: AuthTokenVerifier;
23 }
24 
25 export interface ControlApiOwnershipResolverInput {
26@@ -59,6 +67,20 @@ export interface ControlApiRouteAuthorization {
27   skipReason?: string;
28 }
29 
30+export interface ControlApiRequestAuthInput {
31+  body: JsonValue;
32+  params: Record<string, string>;
33+  request: Request;
34+  route: ControlApiRouteDefinition;
35+  url: URL;
36+}
37+
38+export interface ControlApiRequestAuthHook {
39+  authorize(
40+    input: ControlApiRequestAuthInput
41+  ): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure>;
42+}
43+
44 export interface ControlApiRouteContext {
45   request: Request;
46   env: ControlApiEnv;
47@@ -124,6 +146,8 @@ export interface ControlApiErrorEnvelope {
48 }
49 
50 export interface ControlApiWorkerOptions {
51+  authHook?: ControlApiRequestAuthHook;
52+  now?: () => number;
53   tokenVerifier?: AuthTokenVerifier;
54   repository?: ControlPlaneRepository;
55   repositoryFactory?: (db: D1DatabaseLike) => ControlPlaneRepository;
M apps/control-api-worker/src/handlers.ts
+595, -15
  1@@ -1,13 +1,25 @@
  2 import { findControlApiAuthRule } from "@baa-conductor/auth";
  3-import type { JsonValue } from "@baa-conductor/db";
  4+import {
  5+  DEFAULT_AUTOMATION_MODE,
  6+  stringifyJson,
  7+  type AutomationMode,
  8+  type JsonObject,
  9+  type JsonValue
 10+} from "@baa-conductor/db";
 11 import type {
 12   ControlApiHandlerFailure,
 13+  ControlApiHandlerResult,
 14   ControlApiOwnershipResolverInput,
 15+  ControlApiRouteContext,
 16   ControlApiRouteDefinition,
 17   ControlApiRouteHandler,
 18   ControlApiRouteMethod
 19 } from "./contracts.js";
 20-import { CONTROL_API_ROUTE_SCHEMAS } from "./schemas.js";
 21+import {
 22+  CONTROL_API_ROUTE_SCHEMAS
 23+} from "./schemas.js";
 24+
 25+const DEFAULT_TASK_PRIORITY = 50;
 26 
 27 function requireAuthRule(method: ControlApiRouteMethod, pathPattern: string) {
 28   const authRule = findControlApiAuthRule(method, pathPattern);
 29@@ -19,16 +31,16 @@ function requireAuthRule(method: ControlApiRouteMethod, pathPattern: string) {
 30   return authRule;
 31 }
 32 
 33-function asJsonObject(value: JsonValue): Record<string, JsonValue> | null {
 34+function asJsonObject(value: JsonValue): JsonObject | null {
 35   if (value === null || Array.isArray(value) || typeof value !== "object") {
 36     return null;
 37   }
 38 
 39-  return value as Record<string, JsonValue>;
 40+  return value as JsonObject;
 41 }
 42 
 43-function readNonEmptyStringField(body: JsonValue, fieldName: string): string | undefined {
 44-  const object = asJsonObject(body);
 45+function readNonEmptyStringField(source: JsonValue | JsonObject, fieldName: string): string | undefined {
 46+  const object = asJsonObject(source);
 47   const value = object?.[fieldName];
 48 
 49   if (typeof value !== "string") {
 50@@ -39,6 +51,170 @@ function readNonEmptyStringField(body: JsonValue, fieldName: string): string | u
 51   return normalized.length > 0 ? normalized : undefined;
 52 }
 53 
 54+function readRequiredStringField(
 55+  context: ControlApiRouteContext,
 56+  body: JsonObject,
 57+  fieldName: string
 58+): string | ControlApiHandlerFailure {
 59+  const value = readNonEmptyStringField(body, fieldName);
 60+
 61+  if (value) {
 62+    return value;
 63+  }
 64+
 65+  return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a non-empty string.`, {
 66+    field: fieldName
 67+  });
 68+}
 69+
 70+function readOptionalStringField(
 71+  context: ControlApiRouteContext,
 72+  body: JsonObject,
 73+  fieldName: string
 74+): string | undefined | ControlApiHandlerFailure {
 75+  const rawValue = body[fieldName];
 76+
 77+  if (rawValue == null) {
 78+    return undefined;
 79+  }
 80+
 81+  if (typeof rawValue !== "string") {
 82+    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a string when provided.`, {
 83+      field: fieldName
 84+    });
 85+  }
 86+
 87+  const normalized = rawValue.trim();
 88+  return normalized.length > 0 ? normalized : undefined;
 89+}
 90+
 91+function readRequiredIntegerField(
 92+  context: ControlApiRouteContext,
 93+  body: JsonObject,
 94+  fieldName: string,
 95+  minimum: number = 0
 96+): number | ControlApiHandlerFailure {
 97+  const rawValue = body[fieldName];
 98+
 99+  if (typeof rawValue !== "number" || !Number.isInteger(rawValue) || rawValue < minimum) {
100+    return buildInvalidRequestFailure(
101+      context,
102+      `Field "${fieldName}" must be an integer greater than or equal to ${minimum}.`,
103+      {
104+        field: fieldName,
105+        minimum
106+      }
107+    );
108+  }
109+
110+  return rawValue;
111+}
112+
113+function readOptionalIntegerField(
114+  context: ControlApiRouteContext,
115+  body: JsonObject,
116+  fieldName: string,
117+  minimum: number = 0
118+): number | undefined | ControlApiHandlerFailure {
119+  const rawValue = body[fieldName];
120+
121+  if (rawValue == null) {
122+    return undefined;
123+  }
124+
125+  if (typeof rawValue !== "number" || !Number.isInteger(rawValue) || rawValue < minimum) {
126+    return buildInvalidRequestFailure(
127+      context,
128+      `Field "${fieldName}" must be an integer greater than or equal to ${minimum} when provided.`,
129+      {
130+        field: fieldName,
131+        minimum
132+      }
133+    );
134+  }
135+
136+  return rawValue;
137+}
138+
139+function readOptionalBooleanField(
140+  context: ControlApiRouteContext,
141+  body: JsonObject,
142+  fieldName: string
143+): boolean | undefined | ControlApiHandlerFailure {
144+  const rawValue = body[fieldName];
145+
146+  if (rawValue == null) {
147+    return undefined;
148+  }
149+
150+  if (typeof rawValue !== "boolean") {
151+    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a boolean when provided.`, {
152+      field: fieldName
153+    });
154+  }
155+
156+  return rawValue;
157+}
158+
159+function readOptionalJsonObjectField(
160+  context: ControlApiRouteContext,
161+  body: JsonObject,
162+  fieldName: string
163+): JsonObject | undefined | ControlApiHandlerFailure {
164+  const rawValue = body[fieldName];
165+
166+  if (rawValue == null) {
167+    return undefined;
168+  }
169+
170+  const object = asJsonObject(rawValue);
171+
172+  if (!object) {
173+    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a JSON object when provided.`, {
174+      field: fieldName
175+    });
176+  }
177+
178+  return object;
179+}
180+
181+function readOptionalStringArrayField(
182+  context: ControlApiRouteContext,
183+  body: JsonObject,
184+  fieldName: string
185+): string[] | undefined | ControlApiHandlerFailure {
186+  const rawValue = body[fieldName];
187+
188+  if (rawValue == null) {
189+    return undefined;
190+  }
191+
192+  if (!Array.isArray(rawValue)) {
193+    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be an array of strings when provided.`, {
194+      field: fieldName
195+    });
196+  }
197+
198+  const values: string[] = [];
199+
200+  for (const [index, value] of rawValue.entries()) {
201+    if (typeof value !== "string" || value.trim().length === 0) {
202+      return buildInvalidRequestFailure(
203+        context,
204+        `Field "${fieldName}[${index}]" must be a non-empty string.`,
205+        {
206+          field: fieldName,
207+          index
208+        }
209+      );
210+    }
211+
212+    values.push(value.trim());
213+  }
214+
215+  return values;
216+}
217+
218 function resolveControllerOwnership(
219   input: ControlApiOwnershipResolverInput
220 ): { controllerId: string } | undefined {
221@@ -53,7 +229,56 @@ function resolveWorkerOwnership(
222   return workerId ? { workerId } : undefined;
223 }
224 
225-function buildNotImplementedFailure(context: Parameters<ControlApiRouteHandler>[0]): ControlApiHandlerFailure {
226+function buildInvalidRequestFailure(
227+  context: ControlApiRouteContext,
228+  message: string,
229+  details?: JsonValue
230+): ControlApiHandlerFailure {
231+  const normalizedDetails = asJsonObject(details ?? null);
232+
233+  return {
234+    ok: false,
235+    status: 400,
236+    error: "invalid_request",
237+    message,
238+    details: {
239+      route_id: context.route.id,
240+      ...(normalizedDetails ?? {})
241+    }
242+  };
243+}
244+
245+function buildRepositoryNotConfiguredFailure(context: ControlApiRouteContext): ControlApiHandlerFailure {
246+  return {
247+    ok: false,
248+    status: 503,
249+    error: "repository_not_configured",
250+    message: `Route ${context.route.id} requires CONTROL_DB or an injected repository.`,
251+    details: {
252+      route_id: context.route.id
253+    }
254+  };
255+}
256+
257+function buildNotFoundFailure(
258+  context: ControlApiRouteContext,
259+  resourceType: string,
260+  resourceId: string
261+): ControlApiHandlerFailure {
262+  return {
263+    ok: false,
264+    status: 404,
265+    error: `${resourceType}_not_found`,
266+    message: `${resourceType} "${resourceId}" was not found.`,
267+    details: {
268+      route_id: context.route.id,
269+      resource_id: resourceId,
270+      resource_type: resourceType
271+    }
272+  };
273+}
274+
275+function buildNotImplementedFailure(context: ControlApiRouteContext): ControlApiHandlerFailure {
276   return {
277     ok: false,
278     status: 501,
279@@ -82,6 +307,353 @@ function createPlaceholderHandler(): ControlApiRouteHandler {
280   return async (context) => buildNotImplementedFailure(context);
281 }
282 
283+function buildAppliedAck(summary: string): ControlApiHandlerResult {
284+  return {
285+    ok: true,
286+    status: 200,
287+    data: {
288+      accepted: true,
289+      status: "applied",
290+      summary
291+    } as JsonObject
292+  };
293+}
294+
295+function requireBodyObject(
296+  context: ControlApiRouteContext,
297+  allowNull = false
298+): JsonObject | ControlApiHandlerFailure {
299+  if (allowNull && context.body === null) {
300+    return {};
301+  }
302+
303+  const body = asJsonObject(context.body);
304+
305+  if (!body) {
306+    return buildInvalidRequestFailure(context, "Request body must be a JSON object.", {
307+      route_id: context.route.id
308+    });
309+  }
310+
311+  return body;
312+}
313+
314+function requireRepository(
315+  context: ControlApiRouteContext
316+): NonNullable<ControlApiRouteContext["services"]["repository"]> | ControlApiHandlerFailure {
317+  return context.services.repository ?? buildRepositoryNotConfiguredFailure(context);
318+}
319+
320+function isHandlerFailure(value: unknown): value is ControlApiHandlerFailure {
321+  return value != null && typeof value === "object" && "ok" in value && (value as { ok?: unknown }).ok === false;
322+}
323+
324+function findHandlerFailure(...values: unknown[]): ControlApiHandlerFailure | null {
325+  for (const value of values) {
326+    if (isHandlerFailure(value)) {
327+      return value;
328+    }
329+  }
330+
331+  return null;
332+}
333+
334+async function handleControllerHeartbeat(
335+  context: ControlApiRouteContext
336+): Promise<ControlApiHandlerResult> {
337+  const repository = requireRepository(context);
338+
339+  if (isHandlerFailure(repository)) {
340+    return repository;
341+  }
342+
343+  const body = requireBodyObject(context);
344+
345+  if (isHandlerFailure(body)) {
346+    return body;
347+  }
348+
349+  const controllerId = readRequiredStringField(context, body, "controller_id");
350+  const host = readRequiredStringField(context, body, "host");
351+  const role = readRequiredStringField(context, body, "role");
352+  const priority = readRequiredIntegerField(context, body, "priority");
353+  const status = readRequiredStringField(context, body, "status");
354+  const version = readOptionalStringField(context, body, "version");
355+  const metadata = readOptionalJsonObjectField(context, body, "metadata");
356+
357+  const failure = findHandlerFailure(controllerId, host, role, priority, status, version, metadata);
358+
359+  if (failure) {
360+    return failure;
361+  }
362+
363+  const controllerIdValue = controllerId as string;
364+  const hostValue = host as string;
365+  const roleValue = role as string;
366+  const priorityValue = priority as number;
367+  const statusValue = status as string;
368+  const versionValue = version as string | undefined;
369+  const metadataValue = metadata as JsonObject | undefined;
370+
371+  await repository.heartbeatController({
372+    controllerId: controllerIdValue,
373+    heartbeatAt: context.services.now(),
374+    host: hostValue,
375+    metadataJson: stringifyJson(metadataValue),
376+    priority: priorityValue,
377+    role: roleValue,
378+    status: statusValue,
379+    version: versionValue ?? null
380+  });
381+
382+  return buildAppliedAck(`Controller heartbeat recorded for ${controllerIdValue}.`);
383+}
384+
385+async function handleLeaderAcquire(
386+  context: ControlApiRouteContext
387+): Promise<ControlApiHandlerResult> {
388+  const repository = requireRepository(context);
389+
390+  if (isHandlerFailure(repository)) {
391+    return repository;
392+  }
393+
394+  const body = requireBodyObject(context);
395+
396+  if (isHandlerFailure(body)) {
397+    return body;
398+  }
399+
400+  const controllerId = readRequiredStringField(context, body, "controller_id");
401+  const host = readRequiredStringField(context, body, "host");
402+  const ttlSec = readRequiredIntegerField(context, body, "ttl_sec", 1);
403+  const preferred = readOptionalBooleanField(context, body, "preferred");
404+
405+  const failure = findHandlerFailure(controllerId, host, ttlSec, preferred);
406+
407+  if (failure) {
408+    return failure;
409+  }
410+
411+  const controllerIdValue = controllerId as string;
412+  const hostValue = host as string;
413+  const ttlSecValue = ttlSec as number;
414+  const preferredValue = preferred as boolean | undefined;
415+
416+  const result = await repository.acquireLeaderLease({
417+    controllerId: controllerIdValue,
418+    host: hostValue,
419+    preferred: preferredValue ?? false,
420+    ttlSec: ttlSecValue
421+  });
422+
423+  return {
424+    ok: true,
425+    status: 200,
426+    data: {
427+      holder_id: result.holderId,
428+      is_leader: result.isLeader,
429+      lease_expires_at: result.leaseExpiresAt,
430+      term: result.term
431+    }
432+  };
433+}
434+
435+async function handleTaskCreate(
436+  context: ControlApiRouteContext
437+): Promise<ControlApiHandlerResult> {
438+  const repository = requireRepository(context);
439+
440+  if (isHandlerFailure(repository)) {
441+    return repository;
442+  }
443+
444+  const body = requireBodyObject(context);
445+
446+  if (isHandlerFailure(body)) {
447+    return body;
448+  }
449+
450+  const repo = readRequiredStringField(context, body, "repo");
451+  const taskType = readRequiredStringField(context, body, "task_type");
452+  const title = readRequiredStringField(context, body, "title");
453+  const goal = readRequiredStringField(context, body, "goal");
454+  const priority = readOptionalIntegerField(context, body, "priority");
455+  const constraints = readOptionalJsonObjectField(context, body, "constraints");
456+  const acceptance = readOptionalStringArrayField(context, body, "acceptance");
457+  const metadata = readOptionalJsonObjectField(context, body, "metadata");
458+
459+  const failure = findHandlerFailure(
460+    repo,
461+    taskType,
462+    title,
463+    goal,
464+    priority,
465+    constraints,
466+    acceptance,
467+    metadata
468+  );
469+
470+  if (failure) {
471+    return failure;
472+  }
473+
474+  const repoValue = repo as string;
475+  const taskTypeValue = taskType as string;
476+  const titleValue = title as string;
477+  const goalValue = goal as string;
478+  const priorityValue = priority as number | undefined;
479+  const constraintsValue = constraints as JsonObject | undefined;
480+  const acceptanceValue = acceptance as string[] | undefined;
481+  const metadataValue = metadata as JsonObject | undefined;
482+
483+  const now = context.services.now();
484+  const taskId = `task_${crypto.randomUUID()}`;
485+  const source = readNonEmptyStringField(metadataValue ?? {}, "requested_by") ?? "control_api";
486+  const targetHost = readNonEmptyStringField(constraintsValue ?? {}, "target_host") ?? null;
487+
488+  await repository.insertTask({
489+    acceptanceJson: stringifyJson(acceptanceValue),
490+    assignedControllerId: null,
491+    baseRef: null,
492+    branchName: null,
493+    constraintsJson: stringifyJson(constraintsValue),
494+    createdAt: now,
495+    currentStepIndex: 0,
496+    errorText: null,
497+    finishedAt: null,
498+    goal: goalValue,
499+    metadataJson: stringifyJson(metadataValue),
500+    plannerProvider: null,
501+    planningStrategy: null,
502+    priority: priorityValue ?? DEFAULT_TASK_PRIORITY,
503+    repo: repoValue,
504+    resultJson: null,
505+    resultSummary: null,
506+    source,
507+    startedAt: null,
508+    status: "queued",
509+    targetHost,
510+    taskId,
511+    taskType: taskTypeValue,
512+    title: titleValue,
513+    updatedAt: now
514+  });
515+
516+  return {
517+    ok: true,
518+    status: 201,
519+    data: {
520+      base_ref: null,
521+      branch_name: null,
522+      status: "queued",
523+      task_id: taskId
524+    }
525+  };
526+}
527+
528+function createSystemMutationHandler(mode: AutomationMode): ControlApiRouteHandler {
529+  return async (context) => {
530+    const repository = requireRepository(context);
531+
532+    if (isHandlerFailure(repository)) {
533+      return repository;
534+    }
535+
536+    const body = requireBodyObject(context, true);
537+
538+    if (isHandlerFailure(body)) {
539+      return body;
540+    }
541+
542+    const reason = readOptionalStringField(context, body, "reason");
543+    const requestedBy = readOptionalStringField(context, body, "requested_by");
544+
545+    const failure = findHandlerFailure(reason, requestedBy);
546+
547+    if (failure) {
548+      return failure;
549+    }
550+
551+    const reasonValue = reason as string | undefined;
552+    const requestedByValue = requestedBy as string | undefined;
553+
554+    await repository.setAutomationMode(mode, context.services.now());
555+
556+    const summarySuffix = [
557+      requestedByValue ? `requested by ${requestedByValue}` : null,
558+      reasonValue ? `reason: ${reasonValue}` : null
559+    ].filter((value) => value !== null);
560+
561+    return buildAppliedAck(
562+      summarySuffix.length > 0
563+        ? `Automation mode set to ${mode}; ${summarySuffix.join("; ")}.`
564+        : `Automation mode set to ${mode}.`
565+    );
566+  };
567+}
568+
569+async function handleSystemStateRead(
570+  context: ControlApiRouteContext
571+): Promise<ControlApiHandlerResult> {
572+  const repository = requireRepository(context);
573+
574+  if (isHandlerFailure(repository)) {
575+    return repository;
576+  }
577+
578+  const [automationState, lease] = await Promise.all([
579+    repository.getAutomationState(),
580+    repository.getCurrentLease()
581+  ]);
582+
583+  return {
584+    ok: true,
585+    status: 200,
586+    data: {
587+      holder_id: lease?.holderId ?? null,
588+      lease_expires_at: lease?.leaseExpiresAt ?? null,
589+      mode: automationState?.mode ?? DEFAULT_AUTOMATION_MODE,
590+      term: lease?.term ?? null
591+    }
592+  };
593+}
594+
595+async function handleTaskRead(
596+  context: ControlApiRouteContext
597+): Promise<ControlApiHandlerResult> {
598+  const repository = requireRepository(context);
599+
600+  if (isHandlerFailure(repository)) {
601+    return repository;
602+  }
603+
604+  const taskId = context.params.task_id;
605+
606+  if (!taskId) {
607+    return buildInvalidRequestFailure(context, "Route parameter \"task_id\" is required.", {
608+      field: "task_id"
609+    });
610+  }
611+
612+  const task = await repository.getTask(taskId);
613+
614+  if (!task) {
615+    return buildNotFoundFailure(context, "task", taskId);
616+  }
617+
618+  return {
619+    ok: true,
620+    status: 200,
621+    data: {
622+      current_step_index: task.currentStepIndex,
623+      status: task.status,
624+      task_id: task.taskId,
625+      title: task.title
626+    }
627+  };
628+}
629+
630 function defineRoute(
631   definition: Omit<ControlApiRouteDefinition, "authRule" | "handler"> & {
632     handler?: ControlApiRouteHandler;
633@@ -101,7 +673,8 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
634     pathPattern: "/v1/controllers/heartbeat",
635     summary: "controller 心跳",
636     schema: CONTROL_API_ROUTE_SCHEMAS["controllers.heartbeat"],
637-    ownershipResolver: resolveControllerOwnership
638+    ownershipResolver: resolveControllerOwnership,
639+    handler: handleControllerHeartbeat
640   }),
641   defineRoute({
642     id: "leader.acquire",
643@@ -109,14 +682,16 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
644     pathPattern: "/v1/leader/acquire",
645     summary: "获取或续租 leader lease",
646     schema: CONTROL_API_ROUTE_SCHEMAS["leader.acquire"],
647-    ownershipResolver: resolveControllerOwnership
648+    ownershipResolver: resolveControllerOwnership,
649+    handler: handleLeaderAcquire
650   }),
651   defineRoute({
652     id: "tasks.create",
653     method: "POST",
654     pathPattern: "/v1/tasks",
655     summary: "创建 task",
656-    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.create"]
657+    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.create"],
658+    handler: handleTaskCreate
659   }),
660   defineRoute({
661     id: "tasks.plan",
662@@ -171,35 +746,40 @@ export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
663     method: "POST",
664     pathPattern: "/v1/system/pause",
665     summary: "暂停自动化",
666-    schema: CONTROL_API_ROUTE_SCHEMAS["system.pause"]
667+    schema: CONTROL_API_ROUTE_SCHEMAS["system.pause"],
668+    handler: createSystemMutationHandler("paused")
669   }),
670   defineRoute({
671     id: "system.resume",
672     method: "POST",
673     pathPattern: "/v1/system/resume",
674     summary: "恢复自动化",
675-    schema: CONTROL_API_ROUTE_SCHEMAS["system.resume"]
676+    schema: CONTROL_API_ROUTE_SCHEMAS["system.resume"],
677+    handler: createSystemMutationHandler("running")
678   }),
679   defineRoute({
680     id: "system.drain",
681     method: "POST",
682     pathPattern: "/v1/system/drain",
683     summary: "drain 自动化",
684-    schema: CONTROL_API_ROUTE_SCHEMAS["system.drain"]
685+    schema: CONTROL_API_ROUTE_SCHEMAS["system.drain"],
686+    handler: createSystemMutationHandler("draining")
687   }),
688   defineRoute({
689     id: "system.state",
690     method: "GET",
691     pathPattern: "/v1/system/state",
692     summary: "读取系统状态",
693-    schema: CONTROL_API_ROUTE_SCHEMAS["system.state"]
694+    schema: CONTROL_API_ROUTE_SCHEMAS["system.state"],
695+    handler: handleSystemStateRead
696   }),
697   defineRoute({
698     id: "tasks.read",
699     method: "GET",
700     pathPattern: "/v1/tasks/:task_id",
701     summary: "读取 task 详情",
702-    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.read"]
703+    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.read"],
704+    handler: handleTaskRead
705   }),
706   defineRoute({
707     id: "tasks.logs.read",
M apps/control-api-worker/src/index.ts
+1, -0
1@@ -1,6 +1,7 @@
2 export * from "./contracts.js";
3 export * from "./handlers.js";
4 export * from "./router.js";
5+export * from "./runtime.js";
6 export * from "./schemas.js";
7 
8 import { createControlApiWorker } from "./router.js";
M apps/control-api-worker/src/router.ts
+10, -72
  1@@ -1,5 +1,4 @@
  2-import { authorizeControlApiRoute, extractBearerToken } from "@baa-conductor/auth";
  3-import { createD1ControlPlaneRepository, type JsonValue } from "@baa-conductor/db";
  4+import type { JsonValue } from "@baa-conductor/db";
  5 import type {
  6   ControlApiErrorEnvelope,
  7   ControlApiExecutionContext,
  8@@ -15,6 +14,7 @@ import type {
  9   ControlApiEnv
 10 } from "./contracts.js";
 11 import { CONTROL_API_ROUTES } from "./handlers.js";
 12+import { createControlApiServices } from "./runtime.js";
 13 
 14 const SUPPORTED_METHODS: ControlApiRouteMethod[] = ["GET", "POST"];
 15 
 16@@ -84,7 +84,7 @@ export async function handleControlApiRequest(
 17     return errorResponse(requestId, bodyResult);
 18   }
 19 
 20-  const services = resolveServices(env, options);
 21+  const services = createControlApiServices(env, options);
 22   const authorization = await resolveAuthorization(
 23     request,
 24     url,
 25@@ -113,29 +113,6 @@ export async function handleControlApiRequest(
 26   return routeResultToResponse(requestId, result);
 27 }
 28 
 29-function resolveServices(env: ControlApiEnv, options: ControlApiWorkerOptions): ControlApiServices {
 30-  if (options.repository) {
 31-    return {
 32-      repository: options.repository,
 33-      tokenVerifier: options.tokenVerifier
 34-    };
 35-  }
 36-
 37-  if (!env.CONTROL_DB) {
 38-    return {
 39-      repository: null,
 40-      tokenVerifier: options.tokenVerifier
 41-    };
 42-  }
 43-
 44-  const repositoryFactory = options.repositoryFactory ?? createD1ControlPlaneRepository;
 45-
 46-  return {
 47-    repository: repositoryFactory(env.CONTROL_DB),
 48-    tokenVerifier: options.tokenVerifier
 49-  };
 50-}
 51-
 52 function resolveRequestId(request: Request, options: ControlApiWorkerOptions): string {
 53   const headerValue = request.headers.get("x-request-id")?.trim();
 54 
 55@@ -198,7 +175,7 @@ async function resolveAuthorization(
 56 ): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure> {
 57   const rule = matchedRoute.route.authRule;
 58 
 59-  if (!services.tokenVerifier) {
 60+  if (!services.authHook) {
 61     return {
 62       mode: "skipped",
 63       rule,
 64@@ -206,52 +183,13 @@ async function resolveAuthorization(
 65     };
 66   }
 67 
 68-  const tokenResult = extractBearerToken(request.headers.get("authorization") ?? undefined);
 69-
 70-  if (!tokenResult.ok) {
 71-    return {
 72-      ok: false,
 73-      status: 401,
 74-      error: tokenResult.reason,
 75-      message: "Authorization header must use Bearer token syntax for Control API requests."
 76-    };
 77-  }
 78-
 79-  const verification = await services.tokenVerifier.verifyBearerToken(tokenResult.token);
 80-
 81-  if (!verification.ok) {
 82-    return {
 83-      ok: false,
 84-      status: verification.statusCode,
 85-      error: verification.reason,
 86-      message: `Bearer token verification failed: ${verification.reason}.`
 87-    };
 88-  }
 89-
 90-  const authorization = authorizeControlApiRoute({
 91-    method: matchedRoute.route.method,
 92-    path: url.pathname,
 93-    principal: verification.principal,
 94-    resource: matchedRoute.route.ownershipResolver?.({
 95-      params: matchedRoute.params,
 96-      body
 97-    })
 98+  return services.authHook.authorize({
 99+    body,
100+    params: matchedRoute.params,
101+    request,
102+    route: matchedRoute.route,
103+    url
104   });
105-
106-  if (!authorization.ok) {
107-    return {
108-      ok: false,
109-      status: authorization.statusCode,
110-      error: authorization.reason,
111-      message: `Authenticated principal is not allowed to access ${matchedRoute.route.method} ${matchedRoute.route.pathPattern}.`
112-    };
113-  }
114-
115-  return {
116-    mode: "verified",
117-    rule: authorization.matchedRule ?? rule,
118-    principal: verification.principal
119-  };
120 }
121 
122 function isHandlerFailure(
A apps/control-api-worker/src/runtime.ts
+277, -0
  1@@ -0,0 +1,277 @@
  2+import {
  3+  authorizeControlApiRoute,
  4+  extractBearerToken,
  5+  type AuthPrincipal,
  6+  type AuthResourceOwnership,
  7+  type AuthTokenVerifier,
  8+  type AuthVerificationResult,
  9+  DEFAULT_AUTH_AUDIENCE
 10+} from "@baa-conductor/auth";
 11+import { createD1ControlPlaneRepository } from "@baa-conductor/db";
 12+import type {
 13+  ControlApiEnv,
 14+  ControlApiHandlerFailure,
 15+  ControlApiRequestAuthHook,
 16+  ControlApiRouteAuthorization,
 17+  ControlApiServices,
 18+  ControlApiWorkerOptions
 19+} from "./contracts.js";
 20+
 21+const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
 22+
 23+export function createControlApiServices(
 24+  env: ControlApiEnv,
 25+  options: ControlApiWorkerOptions
 26+): ControlApiServices {
 27+  return {
 28+    authHook: options.authHook ?? createControlApiAuthHook(env, options.tokenVerifier),
 29+    now: options.now ?? (() => Math.floor(Date.now() / 1000)),
 30+    repository: resolveRepository(env, options)
 31+  };
 32+}
 33+
 34+function resolveRepository(
 35+  env: ControlApiEnv,
 36+  options: ControlApiWorkerOptions
 37+): ControlApiServices["repository"] {
 38+  if (options.repository) {
 39+    return options.repository;
 40+  }
 41+
 42+  if (!env.CONTROL_DB) {
 43+    return null;
 44+  }
 45+
 46+  const repositoryFactory = options.repositoryFactory ?? createD1ControlPlaneRepository;
 47+  return repositoryFactory(env.CONTROL_DB);
 48+}
 49+
 50+export function createControlApiAuthHook(
 51+  env: ControlApiEnv,
 52+  tokenVerifier?: AuthTokenVerifier
 53+): ControlApiRequestAuthHook | null {
 54+  const hasEnvTokens = hasConfiguredEnvTokens(env);
 55+  const authRequired = parseBooleanEnv(env.CONTROL_API_AUTH_REQUIRED) ?? false;
 56+
 57+  if (!tokenVerifier && !hasEnvTokens && !authRequired) {
 58+    return null;
 59+  }
 60+
 61+  return {
 62+    async authorize(input): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure> {
 63+      if (!tokenVerifier && !hasEnvTokens) {
 64+        return {
 65+          ok: false,
 66+          status: 503,
 67+          error: "auth_not_configured",
 68+          message: "Control API auth is required, but no token verifier or runtime tokens were configured."
 69+        };
 70+      }
 71+
 72+      const tokenResult = extractBearerToken(input.request.headers.get("authorization") ?? undefined);
 73+
 74+      if (!tokenResult.ok) {
 75+        return {
 76+          ok: false,
 77+          status: 401,
 78+          error: tokenResult.reason,
 79+          message: "Authorization header must use Bearer token syntax for Control API requests."
 80+        };
 81+      }
 82+
 83+      const resource = input.route.ownershipResolver?.({
 84+        params: input.params,
 85+        body: input.body
 86+      });
 87+
 88+      const verification = await verifyBearerToken(tokenResult.token, input.route.authRule.action, resource, env, tokenVerifier);
 89+
 90+      if (!verification.ok) {
 91+        return {
 92+          ok: false,
 93+          status: verification.statusCode,
 94+          error: verification.reason,
 95+          message: `Bearer token verification failed: ${verification.reason}.`
 96+        };
 97+      }
 98+
 99+      const authorization = authorizeControlApiRoute({
100+        method: input.route.method,
101+        path: input.url.pathname,
102+        principal: verification.principal,
103+        resource
104+      });
105+
106+      if (!authorization.ok) {
107+        return {
108+          ok: false,
109+          status: authorization.statusCode,
110+          error: authorization.reason,
111+          message: `Authenticated principal is not allowed to access ${input.route.method} ${input.route.pathPattern}.`
112+        };
113+      }
114+
115+      return {
116+        mode: "verified",
117+        rule: authorization.matchedRule ?? input.route.authRule,
118+        principal: verification.principal
119+      };
120+    }
121+  };
122+}
123+
124+async function verifyBearerToken(
125+  token: string,
126+  action: string,
127+  resource: AuthResourceOwnership | undefined,
128+  env: ControlApiEnv,
129+  tokenVerifier?: AuthTokenVerifier
130+): Promise<AuthVerificationResult> {
131+  if (tokenVerifier) {
132+    const result = await tokenVerifier.verifyBearerToken(token);
133+
134+    if (result.ok || result.reason !== "unknown_token") {
135+      return result;
136+    }
137+  }
138+
139+  return verifyEnvToken(token, action, resource, env);
140+}
141+
142+function verifyEnvToken(
143+  token: string,
144+  action: string,
145+  resource: AuthResourceOwnership | undefined,
146+  env: ControlApiEnv
147+): AuthVerificationResult {
148+  if (token === env.CONTROL_API_BROWSER_ADMIN_TOKEN) {
149+    return {
150+      ok: true,
151+      principal: buildStaticPrincipal("browser_admin")
152+    };
153+  }
154+
155+  if (token === env.CONTROL_API_READONLY_TOKEN) {
156+    return {
157+      ok: true,
158+      principal: buildStaticPrincipal("readonly")
159+    };
160+  }
161+
162+  if (token === env.CONTROL_API_OPS_ADMIN_TOKEN) {
163+    return {
164+      ok: true,
165+      principal: buildStaticPrincipal("ops_admin")
166+    };
167+  }
168+
169+  if (token === env.CONTROL_API_CONTROLLER_TOKEN) {
170+    return {
171+      ok: true,
172+      principal: buildServicePrincipal("controller", resource)
173+    };
174+  }
175+
176+  if (token === env.CONTROL_API_WORKER_TOKEN) {
177+    return {
178+      ok: true,
179+      principal: buildServicePrincipal("worker", resource)
180+    };
181+  }
182+
183+  if (token === env.BAA_SHARED_TOKEN) {
184+    const role = resolveServiceRole(action);
185+
186+    if (role) {
187+      return {
188+        ok: true,
189+        principal: buildServicePrincipal(role, resource)
190+      };
191+    }
192+  }
193+
194+  return {
195+    ok: false,
196+    reason: "unknown_token",
197+    statusCode: 401
198+  };
199+}
200+
201+function hasConfiguredEnvTokens(env: ControlApiEnv): boolean {
202+  return [
203+    env.BAA_SHARED_TOKEN,
204+    env.CONTROL_API_BROWSER_ADMIN_TOKEN,
205+    env.CONTROL_API_CONTROLLER_TOKEN,
206+    env.CONTROL_API_OPS_ADMIN_TOKEN,
207+    env.CONTROL_API_READONLY_TOKEN,
208+    env.CONTROL_API_WORKER_TOKEN
209+  ].some((value) => typeof value === "string" && value.trim().length > 0);
210+}
211+
212+function parseBooleanEnv(value: string | undefined): boolean | undefined {
213+  if (value == null) {
214+    return undefined;
215+  }
216+
217+  return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
218+}
219+
220+function resolveServiceRole(action: string): "controller" | "worker" | null {
221+  if (
222+    action === "controllers.heartbeat" ||
223+    action === "leader.acquire" ||
224+    action === "tasks.claim" ||
225+    action === "tasks.plan"
226+  ) {
227+    return "controller";
228+  }
229+
230+  if (
231+    action === "steps.heartbeat" ||
232+    action === "steps.checkpoint" ||
233+    action === "steps.complete" ||
234+    action === "steps.fail"
235+  ) {
236+    return "worker";
237+  }
238+
239+  return null;
240+}
241+
242+function buildStaticPrincipal(role: "browser_admin" | "ops_admin" | "readonly"): AuthPrincipal {
243+  return {
244+    audience: DEFAULT_AUTH_AUDIENCE,
245+    role,
246+    sessionId: role,
247+    subject: role,
248+    tokenKind: role === "ops_admin" ? "ops_session" : "browser_session"
249+  };
250+}
251+
252+function buildServicePrincipal(
253+  role: "controller" | "worker",
254+  resource: AuthResourceOwnership | undefined
255+): AuthPrincipal {
256+  if (role === "controller") {
257+    const controllerId = resource?.controllerId;
258+
259+    return {
260+      audience: DEFAULT_AUTH_AUDIENCE,
261+      controllerId,
262+      nodeId: controllerId,
263+      role,
264+      subject: controllerId ?? "controller",
265+      tokenKind: "service_hmac"
266+    };
267+  }
268+
269+  const workerId = resource?.workerId;
270+
271+  return {
272+    audience: DEFAULT_AUTH_AUDIENCE,
273+    role,
274+    subject: workerId ?? "worker",
275+    tokenKind: "service_hmac",
276+    workerId
277+  };
278+}
M apps/control-api-worker/src/schemas.ts
+1, -1
1@@ -119,7 +119,7 @@ export interface SystemMutationRequest {
2 
3 export interface ControlApiAckResponse {
4   accepted: boolean;
5-  status: "placeholder" | "queued";
6+  status: "placeholder" | "queued" | "applied";
7   summary: string;
8 }
9 
M coordination/tasks/T-014-control-api-runtime.md
+25, -9
 1@@ -1,16 +1,16 @@
 2 ---
 3 task_id: T-014
 4 title: Control API 运行时接线
 5-status: todo
 6+status: review
 7 branch: feat/T-014-control-api-runtime
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@c5e007b
11 depends_on:
12   - T-003
13   - T-012
14 write_scope:
15   - apps/control-api-worker/**
16-updated_at: 2026-03-21
17+updated_at: 2026-03-22
18 ---
19 
20 # T-014 Control API 运行时接线
21@@ -59,25 +59,41 @@ updated_at: 2026-03-21
22 
23 ## files_changed
24 
25-- 待填写
26+- `apps/control-api-worker/src/contracts.ts`
27+- `apps/control-api-worker/src/handlers.ts`
28+- `apps/control-api-worker/src/index.ts`
29+- `apps/control-api-worker/src/router.ts`
30+- `apps/control-api-worker/src/runtime.ts`
31+- `apps/control-api-worker/src/schemas.ts`
32+- `coordination/tasks/T-014-control-api-runtime.md`
33 
34 ## commands_run
35 
36-- 待填写
37+- `git worktree add /Users/george/code/baa-conductor-T014 -b feat/T-014-control-api-runtime c5e007b082772d085a030217691f6b88da9b3ee4`
38+- `npx --yes pnpm install`
39+- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
40+- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
41 
42 ## result
43 
44-- 待填写
45+- 明确了 `ControlApiEnv` 与运行时服务结构,新增 env 驱动的 auth/repository 装配层,并支持显式注入 `authHook`、`tokenVerifier`、`repository` 与 `now`。
46+- 把 auth hook 真正接入请求执行流;当配置了 runtime token 或外部 verifier 时,路由会执行 Bearer 提取、principal 解析和 `authorizeControlApiRoute(...)` 授权。
47+- 落地了可安全运行的 handler:`controllers.heartbeat`、`leader.acquire`、`tasks.create`、`system.pause` / `resume` / `drain`、`system.state`、`tasks.read` 已接到真实 repository 调用;其余未完成路由仍保持显式 `501`。
48+- 为已接线路由补了最小 request 校验和 `repository_not_configured` / `invalid_request` / `*_not_found` 失败路径,使运行时错误更明确。
49 
50 ## risks
51 
52-- 待填写
53+- `BAA_SHARED_TOKEN` 模式仍是最小实现:controller / worker 的身份会从当前请求资源推断,尚未做到签名 claim 或强身份绑定。
54+- `tasks.plan`、`tasks.claim`、step 回写、task logs、run detail 仍是占位实现;完整 durable 调度链路仍依赖后续任务继续接入。
55+- 如果没有配置 runtime token / verifier 且未显式打开 `CONTROL_API_AUTH_REQUIRED`,auth 仍会退化为跳过模式;生产部署时应显式配置鉴权输入。
56 
57 ## next_handoff
58 
59-- 待填写
60+- `T-015` / conductor 运行时可以直接复用新的 `leader.acquire`、`controllers.heartbeat` 路由和 env auth wiring。
61+- 后续任务可沿当前 `runtime.ts` 的装配点继续接 `tasks.plan`、`tasks.claim`、`steps.heartbeat`、`steps.checkpoint`、`steps.complete`、`steps.fail` 的真实持久化逻辑。
62+- `T-017` 可直接消费 `GET /v1/system/state` 与 `GET /v1/tasks/:task_id`;如需日志与 run 详情,需要补本地 query/repository 读取路径。
63 
64 ## notes
65 
66 - `2026-03-21`: 创建第三波任务卡
67-
68+- `2026-03-22`: 完成 control-api 运行时接线并通过目标包 `typecheck` / `build`