- 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
+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+}
+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+}
+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;
+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+}
+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>;
+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-
+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` 切出,任务卡已同步该基线。