- commit
- 0135759
- parent
- c5e007b
- author
- im_wower
- date
- 2026-03-22 00:15:29 +0800 CST
feat: wire control api runtime
7 files changed,
+934,
-98
+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;
+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",
+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";
+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(
+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+}
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
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`