- commit
- 535f75c
- parent
- 7138a3e
- author
- im_wower
- date
- 2026-03-22 00:39:18 +0800 CST
Merge branch 'integration/third-wave-20260322'
31 files changed,
+2761,
-253
+5,
-1
1@@ -2,8 +2,12 @@
2 "name": "@baa-conductor/conductor-daemon",
3 "private": true,
4 "type": "module",
5+ "main": "dist/index.js",
6 "scripts": {
7- "build": "pnpm exec tsc --noEmit -p tsconfig.json",
8+ "build": "pnpm exec tsc -p tsconfig.json",
9+ "dev": "node --experimental-strip-types src/index.ts",
10+ "start": "node dist/index.js",
11+ "test": "node --test --experimental-strip-types src/index.test.js",
12 "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
13 }
14 }
+152,
-1
1@@ -1,7 +1,12 @@
2 import assert from "node:assert/strict";
3 import test from "node:test";
4
5-import { ConductorDaemon } from "./index.ts";
6+import {
7+ ConductorDaemon,
8+ ConductorRuntime,
9+ createFetchControlApiClient,
10+ parseConductorCliRequest
11+} from "./index.ts";
12
13 function createLeaseResult({
14 holderId,
15@@ -176,3 +181,149 @@ test("repeated renew failures degrade the daemon after the configured threshold"
16 assert.equal(daemon.getStatusSnapshot().leaseState, "degraded");
17 assert.equal(daemon.getStatusSnapshot().consecutiveRenewFailures, 2);
18 });
19+
20+test("createFetchControlApiClient unwraps control-api envelopes and sends bearer auth", async () => {
21+ const observedRequests = [];
22+ const client = createFetchControlApiClient(
23+ "https://control.example.test/",
24+ async (url, init) => {
25+ observedRequests.push({
26+ url,
27+ init
28+ });
29+
30+ return new Response(
31+ JSON.stringify({
32+ ok: true,
33+ request_id: "req-1",
34+ data: {
35+ holder_id: "mini-main",
36+ holder_host: "mini",
37+ term: 3,
38+ lease_expires_at: 130,
39+ renewed_at: 100,
40+ is_leader: true,
41+ operation: "renew",
42+ lease: {
43+ lease_name: "global",
44+ holder_id: "mini-main",
45+ holder_host: "mini",
46+ term: 3,
47+ lease_expires_at: 130,
48+ renewed_at: 100,
49+ preferred_holder_id: "mini-main",
50+ metadata_json: null
51+ }
52+ }
53+ }),
54+ {
55+ status: 200,
56+ headers: {
57+ "content-type": "application/json"
58+ }
59+ }
60+ );
61+ },
62+ {
63+ bearerToken: "secret-token"
64+ }
65+ );
66+
67+ const result = await client.acquireLeaderLease({
68+ controllerId: "mini-main",
69+ host: "mini",
70+ ttlSec: 30,
71+ preferred: true,
72+ now: 100
73+ });
74+
75+ assert.equal(observedRequests.length, 1);
76+ assert.equal(String(observedRequests[0].url), "https://control.example.test/v1/leader/acquire");
77+ assert.equal(
78+ new Headers(observedRequests[0].init.headers).get("authorization"),
79+ "Bearer secret-token"
80+ );
81+ assert.deepEqual(JSON.parse(String(observedRequests[0].init.body)), {
82+ controller_id: "mini-main",
83+ host: "mini",
84+ ttl_sec: 30,
85+ preferred: true,
86+ now: 100
87+ });
88+ assert.equal(result.holderId, "mini-main");
89+ assert.equal(result.holderHost, "mini");
90+ assert.equal(result.term, 3);
91+ assert.equal(result.operation, "renew");
92+ assert.equal(result.isLeader, true);
93+});
94+
95+test("parseConductorCliRequest merges launchd env defaults with CLI overrides", () => {
96+ const request = parseConductorCliRequest(["start", "--role", "standby", "--run-once"], {
97+ BAA_NODE_ID: "mini-main",
98+ BAA_CONDUCTOR_HOST: "mini",
99+ BAA_CONDUCTOR_ROLE: "primary",
100+ BAA_CONTROL_API_BASE: "https://control.example.test/",
101+ BAA_CONDUCTOR_LOCAL_API: "http://127.0.0.1:4317/",
102+ BAA_SHARED_TOKEN: "replace-me",
103+ BAA_RUNS_DIR: "/tmp/runs"
104+ });
105+
106+ assert.equal(request.action, "start");
107+
108+ if (request.action !== "start") {
109+ throw new Error("expected start action");
110+ }
111+
112+ assert.equal(request.runOnce, true);
113+ assert.equal(request.config.role, "standby");
114+ assert.equal(request.config.nodeId, "mini-main");
115+ assert.equal(request.config.controlApiBase, "https://control.example.test");
116+ assert.equal(request.config.localApiBase, "http://127.0.0.1:4317/");
117+ assert.equal(request.config.paths.runsDir, "/tmp/runs");
118+});
119+
120+test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status surfaces", async () => {
121+ const runtime = new ConductorRuntime(
122+ {
123+ nodeId: "mini-main",
124+ host: "mini",
125+ role: "primary",
126+ controlApiBase: "https://control.example.test",
127+ localApiBase: "http://127.0.0.1:4317",
128+ sharedToken: "replace-me",
129+ paths: {
130+ runsDir: "/tmp/runs"
131+ }
132+ },
133+ {
134+ autoStartLoops: false,
135+ client: {
136+ async acquireLeaderLease() {
137+ return createLeaseResult({
138+ holderId: "mini-main",
139+ term: 2,
140+ leaseExpiresAt: 130,
141+ renewedAt: 100,
142+ isLeader: true,
143+ operation: "acquire"
144+ });
145+ },
146+ async sendControllerHeartbeat() {}
147+ },
148+ now: () => 100
149+ }
150+ );
151+
152+ assert.equal(runtime.getRuntimeSnapshot().runtime.started, false);
153+
154+ const startedSnapshot = await runtime.start();
155+ assert.equal(startedSnapshot.runtime.started, true);
156+ assert.equal(startedSnapshot.daemon.leaseState, "leader");
157+ assert.equal(startedSnapshot.loops.heartbeat, false);
158+ assert.equal(startedSnapshot.loops.lease, false);
159+ assert.equal(startedSnapshot.controlApi.usesPlaceholderToken, true);
160+ assert.match(startedSnapshot.warnings.join("\n"), /replace-me/);
161+
162+ const stoppedSnapshot = runtime.stop();
163+ assert.equal(stoppedSnapshot.runtime.started, false);
164+});
+1140,
-29
1@@ -3,12 +3,22 @@ export type LeaseState = "leader" | "standby" | "degraded";
2 export type SchedulerDecision = "scheduled" | "skipped_not_leader";
3 export type TimerHandle = ReturnType<typeof globalThis.setInterval>;
4 export type LeaderLeaseOperation = "acquire" | "renew";
5+export type ConductorCliAction = "checklist" | "config" | "help" | "start";
6
7 const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
8 const DEFAULT_LEASE_RENEW_INTERVAL_MS = 5_000;
9 const DEFAULT_LEASE_TTL_SEC = 30;
10 const DEFAULT_RENEW_FAILURE_THRESHOLD = 2;
11
12+const STARTUP_CHECKLIST: StartupChecklistItem[] = [
13+ { key: "register-controller", description: "注册 controller 并写入初始 heartbeat" },
14+ { key: "acquire-lease", description: "尝试获取或续租 leader lease" },
15+ { key: "load-runs", description: "扫描本地未完成 runs" },
16+ { key: "reconcile-runs", description: "对账本地过期 runs 与控制平面状态" },
17+ { key: "start-heartbeat-loop", description: "启动 controller heartbeat loop" },
18+ { key: "start-scheduler", description: "仅 leader 进入 scheduler loop,standby 拒绝调度" }
19+];
20+
21 export interface ControllerHeartbeatInput {
22 controllerId: string;
23 host: string;
24@@ -66,6 +76,33 @@ export interface ConductorConfig {
25 startedAt?: number;
26 }
27
28+export interface ConductorRuntimePaths {
29+ logsDir: string | null;
30+ runsDir: string | null;
31+ stateDir: string | null;
32+ tmpDir: string | null;
33+ worktreesDir: string | null;
34+}
35+
36+export interface ConductorRuntimeConfig extends ConductorConfig {
37+ localApiBase?: string | null;
38+ paths?: Partial<ConductorRuntimePaths>;
39+ sharedToken?: string | null;
40+}
41+
42+export interface ResolvedConductorRuntimeConfig extends ConductorConfig {
43+ heartbeatIntervalMs: number;
44+ leaseRenewIntervalMs: number;
45+ leaseTtlSec: number;
46+ localApiBase: string | null;
47+ paths: ConductorRuntimePaths;
48+ preferred: boolean;
49+ priority: number;
50+ renewFailureThreshold: number;
51+ sharedToken: string | null;
52+ version: string | null;
53+}
54+
55 export interface StartupChecklistItem {
56 key: string;
57 description: string;
58@@ -87,6 +124,29 @@ export interface ConductorStatusSnapshot {
59 lastError: string | null;
60 }
61
62+export interface ConductorRuntimeSnapshot {
63+ daemon: ConductorStatusSnapshot;
64+ identity: string;
65+ loops: {
66+ heartbeat: boolean;
67+ lease: boolean;
68+ };
69+ paths: ConductorRuntimePaths;
70+ controlApi: {
71+ baseUrl: string;
72+ localApiBase: string | null;
73+ hasSharedToken: boolean;
74+ usesPlaceholderToken: boolean;
75+ };
76+ runtime: {
77+ pid: number | null;
78+ started: boolean;
79+ startedAt: number;
80+ };
81+ startupChecklist: StartupChecklistItem[];
82+ warnings: string[];
83+}
84+
85 export interface SchedulerContext {
86 controllerId: string;
87 host: string;
88@@ -98,6 +158,12 @@ export interface ConductorControlApiClient {
89 sendControllerHeartbeat(input: ControllerHeartbeatInput): Promise<void>;
90 }
91
92+export interface ConductorControlApiClientOptions {
93+ bearerToken?: string | null;
94+ defaultHeaders?: Record<string, string>;
95+ fetchImpl?: typeof fetch;
96+}
97+
98 export interface ConductorDaemonHooks {
99 loadLocalRuns?: () => Promise<void>;
100 onLeaseStateChange?: (snapshot: ConductorStatusSnapshot) => Promise<void> | void;
101@@ -108,12 +174,61 @@ export interface ConductorDaemonOptions {
102 autoStartLoops?: boolean;
103 clearIntervalImpl?: (handle: TimerHandle) => void;
104 client?: ConductorControlApiClient;
105+ controlApiBearerToken?: string | null;
106 fetchImpl?: typeof fetch;
107 hooks?: ConductorDaemonHooks;
108 now?: () => number;
109 setIntervalImpl?: (handler: () => void, intervalMs: number) => TimerHandle;
110 }
111
112+export interface ConductorRuntimeOptions extends ConductorDaemonOptions {}
113+
114+export type ConductorEnvironment = Record<string, string | undefined>;
115+
116+export interface ConductorTextWriter {
117+ write(chunk: string): unknown;
118+}
119+
120+type ConductorOutputWriter = ConductorTextWriter | typeof console;
121+
122+export interface ConductorProcessLike {
123+ argv: string[];
124+ env: ConductorEnvironment;
125+ exitCode?: number;
126+ off?(event: string, listener: () => void): unknown;
127+ on?(event: string, listener: () => void): unknown;
128+ pid?: number;
129+}
130+
131+export type ConductorCliRequest =
132+ | {
133+ action: "help";
134+ }
135+ | {
136+ action: "checklist";
137+ printJson: boolean;
138+ }
139+ | {
140+ action: "config";
141+ config: ResolvedConductorRuntimeConfig;
142+ printJson: boolean;
143+ }
144+ | {
145+ action: "start";
146+ config: ResolvedConductorRuntimeConfig;
147+ printJson: boolean;
148+ runOnce: boolean;
149+ };
150+
151+export interface RunConductorCliOptions {
152+ argv?: readonly string[];
153+ env?: ConductorEnvironment;
154+ fetchImpl?: typeof fetch;
155+ processLike?: ConductorProcessLike;
156+ stderr?: ConductorTextWriter;
157+ stdout?: ConductorTextWriter;
158+}
159+
160 interface ConductorRuntimeState {
161 consecutiveRenewFailures: number;
162 currentLeaderId: string | null;
163@@ -125,10 +240,67 @@ interface ConductorRuntimeState {
164 leaseState: LeaseState;
165 }
166
167+interface ControlApiSuccessEnvelope<T> {
168+ ok: true;
169+ request_id: string;
170+ data: T;
171+}
172+
173+interface ControlApiErrorEnvelope {
174+ ok: false;
175+ request_id: string;
176+ error: string;
177+ message: string;
178+ details?: unknown;
179+}
180+
181+interface JsonRequestOptions {
182+ headers?: Record<string, string>;
183+}
184+
185+interface CliValueOverrides {
186+ controlApiBase?: string;
187+ heartbeatIntervalMs?: string;
188+ host?: string;
189+ leaseRenewIntervalMs?: string;
190+ leaseTtlSec?: string;
191+ localApiBase?: string;
192+ logsDir?: string;
193+ nodeId?: string;
194+ preferred?: boolean;
195+ priority?: string;
196+ renewFailureThreshold?: string;
197+ role?: string;
198+ runOnce: boolean;
199+ runsDir?: string;
200+ sharedToken?: string;
201+ stateDir?: string;
202+ tmpDir?: string;
203+ version?: string;
204+ worktreesDir?: string;
205+}
206+
207+function getDefaultStartupChecklist(): StartupChecklistItem[] {
208+ return STARTUP_CHECKLIST.map((item) => ({ ...item }));
209+}
210+
211+function defaultNowUnixSeconds(): number {
212+ return Math.floor(Date.now() / 1000);
213+}
214+
215 function normalizeBaseUrl(baseUrl: string): string {
216 return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
217 }
218
219+function normalizeOptionalString(value: string | null | undefined): string | null {
220+ if (value == null) {
221+ return null;
222+ }
223+
224+ const normalized = value.trim();
225+ return normalized === "" ? null : normalized;
226+}
227+
228 function toErrorMessage(error: unknown): string {
229 if (error instanceof Error) {
230 return error.message;
231@@ -137,43 +309,292 @@ function toErrorMessage(error: unknown): string {
232 return String(error);
233 }
234
235+function isControlApiSuccessEnvelope<T>(value: unknown): value is ControlApiSuccessEnvelope<T> {
236+ if (value === null || typeof value !== "object") {
237+ return false;
238+ }
239+
240+ const record = value as Record<string, unknown>;
241+ return record.ok === true && "data" in record;
242+}
243+
244+function isControlApiErrorEnvelope(value: unknown): value is ControlApiErrorEnvelope {
245+ if (value === null || typeof value !== "object") {
246+ return false;
247+ }
248+
249+ const record = value as Record<string, unknown>;
250+ return record.ok === false && typeof record.error === "string" && typeof record.message === "string";
251+}
252+
253+function asRecord(value: unknown): Record<string, unknown> | null {
254+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
255+ return null;
256+ }
257+
258+ return value as Record<string, unknown>;
259+}
260+
261+function compactRecord(input: Record<string, unknown>): Record<string, unknown> {
262+ const result: Record<string, unknown> = {};
263+
264+ for (const [key, value] of Object.entries(input)) {
265+ if (value !== undefined) {
266+ result[key] = value;
267+ }
268+ }
269+
270+ return result;
271+}
272+
273+function readFirstString(record: Record<string, unknown>, keys: readonly string[]): string | undefined {
274+ for (const key of keys) {
275+ const value = record[key];
276+
277+ if (typeof value === "string" && value.trim() !== "") {
278+ return value;
279+ }
280+ }
281+
282+ return undefined;
283+}
284+
285+function readFirstNullableString(record: Record<string, unknown>, keys: readonly string[]): string | null | undefined {
286+ for (const key of keys) {
287+ const value = record[key];
288+
289+ if (value === null) {
290+ return null;
291+ }
292+
293+ if (typeof value === "string") {
294+ return value;
295+ }
296+ }
297+
298+ return undefined;
299+}
300+
301+function readFirstNumber(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
302+ for (const key of keys) {
303+ const value = record[key];
304+
305+ if (typeof value === "number" && Number.isFinite(value)) {
306+ return value;
307+ }
308+ }
309+
310+ return undefined;
311+}
312+
313+function readFirstBoolean(record: Record<string, unknown>, keys: readonly string[]): boolean | undefined {
314+ for (const key of keys) {
315+ const value = record[key];
316+
317+ if (typeof value === "boolean") {
318+ return value;
319+ }
320+ }
321+
322+ return undefined;
323+}
324+
325+function parseResponseJson(text: string, path: string): unknown {
326+ try {
327+ return JSON.parse(text) as unknown;
328+ } catch {
329+ throw new Error(`Control API returned invalid JSON for ${path}.`);
330+ }
331+}
332+
333+function buildControlApiError(
334+ path: string,
335+ status: number,
336+ statusText: string,
337+ parsedBody: unknown,
338+ rawBody: string
339+): Error {
340+ if (isControlApiErrorEnvelope(parsedBody)) {
341+ return new Error(`Control API ${status} ${path}: ${parsedBody.error}: ${parsedBody.message}`);
342+ }
343+
344+ if (rawBody !== "") {
345+ return new Error(`Control API ${status} ${path}: ${rawBody}`);
346+ }
347+
348+ return new Error(`Control API ${status} ${path}: ${statusText}`);
349+}
350+
351 async function postJson<T>(
352 fetchImpl: typeof fetch,
353 baseUrl: string,
354 path: string,
355- body: unknown
356+ body: unknown,
357+ options: JsonRequestOptions = {}
358 ): Promise<T> {
359+ const headers = new Headers(options.headers);
360+ headers.set("accept", "application/json");
361+ headers.set("content-type", "application/json");
362+
363 const response = await fetchImpl(`${normalizeBaseUrl(baseUrl)}${path}`, {
364 method: "POST",
365- headers: {
366- "content-type": "application/json"
367- },
368+ headers,
369 body: JSON.stringify(body)
370 });
371 const text = await response.text();
372+ const parsedBody = text === "" ? undefined : parseResponseJson(text, path);
373
374 if (!response.ok) {
375- const detail = text === "" ? response.statusText : text;
376- throw new Error(`Control API ${response.status} ${path}: ${detail}`);
377+ throw buildControlApiError(path, response.status, response.statusText, parsedBody, text);
378 }
379
380- if (text === "") {
381+ if (parsedBody === undefined) {
382 return undefined as T;
383 }
384
385- return JSON.parse(text) as T;
386+ if (isControlApiSuccessEnvelope<T>(parsedBody)) {
387+ return parsedBody.data;
388+ }
389+
390+ if (isControlApiErrorEnvelope(parsedBody)) {
391+ throw new Error(`Control API ${path}: ${parsedBody.error}: ${parsedBody.message}`);
392+ }
393+
394+ return parsedBody as T;
395+}
396+
397+function normalizeLeaseRecord(
398+ value: unknown,
399+ fallback: Pick<LeaderLeaseRecord, "holderHost" | "holderId" | "leaseExpiresAt" | "renewedAt" | "term">
400+): LeaderLeaseRecord {
401+ const record = asRecord(value) ?? {};
402+
403+ return {
404+ leaseName: readFirstString(record, ["leaseName", "lease_name"]) ?? "global",
405+ holderId: readFirstString(record, ["holderId", "holder_id"]) ?? fallback.holderId,
406+ holderHost: readFirstString(record, ["holderHost", "holder_host"]) ?? fallback.holderHost,
407+ term: readFirstNumber(record, ["term"]) ?? fallback.term,
408+ leaseExpiresAt:
409+ readFirstNumber(record, ["leaseExpiresAt", "lease_expires_at"]) ?? fallback.leaseExpiresAt,
410+ renewedAt: readFirstNumber(record, ["renewedAt", "renewed_at"]) ?? fallback.renewedAt,
411+ preferredHolderId:
412+ readFirstNullableString(record, ["preferredHolderId", "preferred_holder_id"]) ?? null,
413+ metadataJson: readFirstNullableString(record, ["metadataJson", "metadata_json"]) ?? null
414+ };
415+}
416+
417+function normalizeAcquireLeaderLeaseResult(
418+ value: unknown,
419+ input: LeaderLeaseAcquireInput
420+): LeaderLeaseAcquireResult {
421+ const record = asRecord(value);
422+
423+ if (!record) {
424+ throw new Error("Control API /v1/leader/acquire returned a non-object payload.");
425+ }
426+
427+ const holderId = readFirstString(record, ["holderId", "holder_id"]);
428+ const term = readFirstNumber(record, ["term"]);
429+ const leaseExpiresAt = readFirstNumber(record, ["leaseExpiresAt", "lease_expires_at"]);
430+
431+ if (!holderId || term == null || leaseExpiresAt == null) {
432+ throw new Error("Control API /v1/leader/acquire response is missing lease identity fields.");
433+ }
434+
435+ const holderHost =
436+ readFirstString(record, ["holderHost", "holder_host"]) ??
437+ (holderId === input.controllerId ? input.host : "unknown");
438+ const renewedAt =
439+ readFirstNumber(record, ["renewedAt", "renewed_at"]) ?? input.now ?? defaultNowUnixSeconds();
440+ const isLeader =
441+ readFirstBoolean(record, ["isLeader", "is_leader"]) ?? holderId === input.controllerId;
442+ const rawOperation = readFirstString(record, ["operation"]);
443+ const operation: LeaderLeaseOperation = rawOperation === "renew" ? "renew" : "acquire";
444+
445+ return {
446+ holderId,
447+ holderHost,
448+ term,
449+ leaseExpiresAt,
450+ renewedAt,
451+ isLeader,
452+ operation,
453+ lease: normalizeLeaseRecord(record.lease, {
454+ holderHost,
455+ holderId,
456+ leaseExpiresAt,
457+ renewedAt,
458+ term
459+ })
460+ };
461+}
462+
463+function resolveFetchClientOptions(
464+ fetchOrOptions: typeof fetch | ConductorControlApiClientOptions | undefined,
465+ maybeOptions: ConductorControlApiClientOptions
466+): { fetchImpl: typeof fetch; options: ConductorControlApiClientOptions } {
467+ if (typeof fetchOrOptions === "function") {
468+ return {
469+ fetchImpl: fetchOrOptions,
470+ options: maybeOptions
471+ };
472+ }
473+
474+ return {
475+ fetchImpl: fetchOrOptions?.fetchImpl ?? globalThis.fetch,
476+ options: fetchOrOptions ?? {}
477+ };
478 }
479
480 export function createFetchControlApiClient(
481 baseUrl: string,
482- fetchImpl: typeof fetch = globalThis.fetch
483+ fetchOrOptions: typeof fetch | ConductorControlApiClientOptions = globalThis.fetch,
484+ maybeOptions: ConductorControlApiClientOptions = {}
485 ): ConductorControlApiClient {
486+ const { fetchImpl, options } = resolveFetchClientOptions(fetchOrOptions, maybeOptions);
487+ const requestHeaders = { ...(options.defaultHeaders ?? {}) };
488+
489+ if (options.bearerToken) {
490+ requestHeaders.authorization = `Bearer ${options.bearerToken}`;
491+ }
492+
493 return {
494 async acquireLeaderLease(input: LeaderLeaseAcquireInput): Promise<LeaderLeaseAcquireResult> {
495- return postJson<LeaderLeaseAcquireResult>(fetchImpl, baseUrl, "/v1/leader/acquire", input);
496+ const payload = compactRecord({
497+ controller_id: input.controllerId,
498+ host: input.host,
499+ ttl_sec: input.ttlSec,
500+ preferred: input.preferred ?? false,
501+ metadata_json: input.metadataJson ?? undefined,
502+ now: input.now
503+ });
504+ const response = await postJson<unknown>(
505+ fetchImpl,
506+ baseUrl,
507+ "/v1/leader/acquire",
508+ payload,
509+ {
510+ headers: requestHeaders
511+ }
512+ );
513+
514+ return normalizeAcquireLeaderLeaseResult(response, input);
515 },
516 async sendControllerHeartbeat(input: ControllerHeartbeatInput): Promise<void> {
517- await postJson(fetchImpl, baseUrl, "/v1/controllers/heartbeat", input);
518+ const payload = compactRecord({
519+ controller_id: input.controllerId,
520+ host: input.host,
521+ role: input.role,
522+ priority: input.priority,
523+ status: input.status,
524+ version: input.version ?? undefined,
525+ heartbeat_at: input.heartbeatAt,
526+ started_at: input.startedAt
527+ });
528+
529+ await postJson(fetchImpl, baseUrl, "/v1/controllers/heartbeat", payload, {
530+ headers: requestHeaders
531+ });
532 }
533 };
534 }
535@@ -200,38 +621,39 @@ export class ConductorDaemon {
536 leaseState: "standby"
537 };
538
539- constructor(
540- config: ConductorConfig,
541- options: ConductorDaemonOptions = {}
542- ) {
543+ constructor(config: ConductorConfig, options: ConductorDaemonOptions = {}) {
544 this.config = config;
545 this.autoStartLoops = options.autoStartLoops ?? true;
546 this.clearIntervalImpl =
547 options.clearIntervalImpl ?? ((handle) => globalThis.clearInterval(handle));
548 this.client =
549- options.client ?? createFetchControlApiClient(config.controlApiBase, options.fetchImpl);
550+ options.client ??
551+ createFetchControlApiClient(config.controlApiBase, {
552+ bearerToken: options.controlApiBearerToken,
553+ fetchImpl: options.fetchImpl
554+ });
555 this.hooks = options.hooks;
556- this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
557+ this.now = options.now ?? defaultNowUnixSeconds;
558 this.setIntervalImpl =
559 options.setIntervalImpl ?? ((handler, intervalMs) => globalThis.setInterval(handler, intervalMs));
560 this.startedAt = config.startedAt ?? this.now();
561 }
562
563 getStartupChecklist(): StartupChecklistItem[] {
564- return [
565- { key: "register-controller", description: "注册 controller 并写入初始 heartbeat" },
566- { key: "start-heartbeat-loop", description: "启动 controller heartbeat loop" },
567- { key: "acquire-lease", description: "尝试获取或续租 leader lease" },
568- { key: "load-runs", description: "扫描本地未完成 runs" },
569- { key: "reconcile-runs", description: "对账本地过期 runs 与控制平面状态" },
570- { key: "start-scheduler", description: "仅 leader 进入 scheduler loop,standby 拒绝调度" }
571- ];
572+ return getDefaultStartupChecklist();
573 }
574
575 describeIdentity(): string {
576 return `${this.config.nodeId}@${this.config.host}(${this.config.role})`;
577 }
578
579+ getLoopStatus(): { heartbeat: boolean; lease: boolean } {
580+ return {
581+ heartbeat: this.heartbeatTimer != null,
582+ lease: this.leaseTimer != null
583+ };
584+ }
585+
586 getStatusSnapshot(now: number = this.now()): ConductorStatusSnapshot {
587 return {
588 nodeId: this.config.nodeId,
589@@ -275,10 +697,6 @@ export class ConductorDaemon {
590 await this.transitionTo("degraded");
591 }
592
593- if (this.autoStartLoops) {
594- this.startLoops();
595- }
596-
597 try {
598 await this.runLeaseCycle();
599 } catch {
600@@ -288,6 +706,10 @@ export class ConductorDaemon {
601 await this.hooks?.loadLocalRuns?.();
602 await this.hooks?.reconcileLocalRuns?.();
603
604+ if (this.autoStartLoops) {
605+ this.startLoops();
606+ }
607+
608 return this.state.leaseState;
609 }
610
611@@ -439,3 +861,692 @@ export class ConductorDaemon {
612 await this.hooks?.onLeaseStateChange?.(this.getStatusSnapshot());
613 }
614 }
615+
616+function createDefaultNodeId(host: string, role: ConductorRole): string {
617+ return `${host}-${role === "primary" ? "main" : "standby"}`;
618+}
619+
620+function parseConductorRole(name: string, value: string | null): ConductorRole {
621+ if (value === "primary" || value === "standby") {
622+ return value;
623+ }
624+
625+ throw new Error(`${name} must be either "primary" or "standby".`);
626+}
627+
628+function parseBooleanValue(name: string, value: string | boolean | null | undefined): boolean | undefined {
629+ if (typeof value === "boolean") {
630+ return value;
631+ }
632+
633+ if (value == null) {
634+ return undefined;
635+ }
636+
637+ switch (value.trim().toLowerCase()) {
638+ case "1":
639+ case "on":
640+ case "true":
641+ case "yes":
642+ return true;
643+ case "0":
644+ case "false":
645+ case "no":
646+ case "off":
647+ return false;
648+ default:
649+ throw new Error(`${name} must be a boolean flag.`);
650+ }
651+}
652+
653+function parseIntegerValue(
654+ name: string,
655+ value: string | null | undefined,
656+ options: { minimum?: number } = {}
657+): number | undefined {
658+ if (value == null) {
659+ return undefined;
660+ }
661+
662+ const normalized = value.trim();
663+
664+ if (!/^-?\d+$/u.test(normalized)) {
665+ throw new Error(`${name} must be an integer.`);
666+ }
667+
668+ const parsed = Number(normalized);
669+
670+ if (!Number.isSafeInteger(parsed)) {
671+ throw new Error(`${name} is outside the supported integer range.`);
672+ }
673+
674+ if (options.minimum != null && parsed < options.minimum) {
675+ throw new Error(`${name} must be >= ${options.minimum}.`);
676+ }
677+
678+ return parsed;
679+}
680+
681+function resolvePathConfig(paths?: Partial<ConductorRuntimePaths>): ConductorRuntimePaths {
682+ return {
683+ logsDir: normalizeOptionalString(paths?.logsDir),
684+ runsDir: normalizeOptionalString(paths?.runsDir),
685+ stateDir: normalizeOptionalString(paths?.stateDir),
686+ tmpDir: normalizeOptionalString(paths?.tmpDir),
687+ worktreesDir: normalizeOptionalString(paths?.worktreesDir)
688+ };
689+}
690+
691+export function resolveConductorRuntimeConfig(
692+ config: ConductorRuntimeConfig
693+): ResolvedConductorRuntimeConfig {
694+ const nodeId = normalizeOptionalString(config.nodeId);
695+ const host = normalizeOptionalString(config.host);
696+ const controlApiBase = normalizeOptionalString(config.controlApiBase);
697+
698+ if (!nodeId) {
699+ throw new Error("Conductor config requires a non-empty nodeId.");
700+ }
701+
702+ if (!host) {
703+ throw new Error("Conductor config requires a non-empty host.");
704+ }
705+
706+ if (!controlApiBase) {
707+ throw new Error("Conductor config requires a non-empty controlApiBase.");
708+ }
709+
710+ const heartbeatIntervalMs = config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
711+ const leaseRenewIntervalMs = config.leaseRenewIntervalMs ?? DEFAULT_LEASE_RENEW_INTERVAL_MS;
712+ const leaseTtlSec = config.leaseTtlSec ?? DEFAULT_LEASE_TTL_SEC;
713+ const renewFailureThreshold = config.renewFailureThreshold ?? DEFAULT_RENEW_FAILURE_THRESHOLD;
714+ const priority = config.priority ?? (config.role === "primary" ? 100 : 50);
715+
716+ if (heartbeatIntervalMs <= 0) {
717+ throw new Error("Conductor heartbeatIntervalMs must be > 0.");
718+ }
719+
720+ if (leaseRenewIntervalMs <= 0) {
721+ throw new Error("Conductor leaseRenewIntervalMs must be > 0.");
722+ }
723+
724+ if (leaseTtlSec <= 0) {
725+ throw new Error("Conductor leaseTtlSec must be > 0.");
726+ }
727+
728+ if (renewFailureThreshold <= 0) {
729+ throw new Error("Conductor renewFailureThreshold must be > 0.");
730+ }
731+
732+ return {
733+ ...config,
734+ nodeId,
735+ host,
736+ role: parseConductorRole("Conductor role", config.role),
737+ controlApiBase: normalizeBaseUrl(controlApiBase),
738+ heartbeatIntervalMs,
739+ leaseRenewIntervalMs,
740+ leaseTtlSec,
741+ localApiBase: normalizeOptionalString(config.localApiBase),
742+ paths: resolvePathConfig(config.paths),
743+ preferred: config.preferred ?? config.role === "primary",
744+ priority,
745+ renewFailureThreshold,
746+ sharedToken: normalizeOptionalString(config.sharedToken),
747+ version: normalizeOptionalString(config.version)
748+ };
749+}
750+
751+function readOptionValue(argv: string[], option: string, index: number): string {
752+ const value = argv[index + 1];
753+
754+ if (!value || value.startsWith("--")) {
755+ throw new Error(`${option} requires a value.`);
756+ }
757+
758+ return value;
759+}
760+
761+function isCliAction(value: string): value is ConductorCliAction {
762+ return value === "checklist" || value === "config" || value === "help" || value === "start";
763+}
764+
765+function resolveRuntimeConfigFromSources(
766+ env: ConductorEnvironment,
767+ overrides: CliValueOverrides
768+): ResolvedConductorRuntimeConfig {
769+ const role = parseConductorRole(
770+ "Conductor role",
771+ normalizeOptionalString(overrides.role ?? env.BAA_CONDUCTOR_ROLE) ?? "primary"
772+ );
773+ const host = normalizeOptionalString(overrides.host ?? env.BAA_CONDUCTOR_HOST) ?? "localhost";
774+ const nodeId =
775+ normalizeOptionalString(overrides.nodeId ?? env.BAA_NODE_ID) ?? createDefaultNodeId(host, role);
776+ const controlApiBase = normalizeOptionalString(overrides.controlApiBase ?? env.BAA_CONTROL_API_BASE);
777+
778+ if (!controlApiBase) {
779+ throw new Error("Missing control API base URL. Use --control-api-base or BAA_CONTROL_API_BASE.");
780+ }
781+
782+ return resolveConductorRuntimeConfig({
783+ nodeId,
784+ host,
785+ role,
786+ controlApiBase,
787+ priority: parseIntegerValue("Conductor priority", overrides.priority ?? env.BAA_CONDUCTOR_PRIORITY, {
788+ minimum: 0
789+ }),
790+ version: normalizeOptionalString(overrides.version ?? env.BAA_CONDUCTOR_VERSION),
791+ preferred:
792+ parseBooleanValue(
793+ "Conductor preferred flag",
794+ overrides.preferred ?? normalizeOptionalString(env.BAA_CONDUCTOR_PREFERRED)
795+ ) ?? role === "primary",
796+ heartbeatIntervalMs: parseIntegerValue(
797+ "Conductor heartbeat interval",
798+ overrides.heartbeatIntervalMs ?? env.BAA_CONDUCTOR_HEARTBEAT_INTERVAL_MS,
799+ { minimum: 1 }
800+ ),
801+ leaseRenewIntervalMs: parseIntegerValue(
802+ "Conductor lease renew interval",
803+ overrides.leaseRenewIntervalMs ?? env.BAA_CONDUCTOR_LEASE_RENEW_INTERVAL_MS,
804+ { minimum: 1 }
805+ ),
806+ leaseTtlSec: parseIntegerValue(
807+ "Conductor lease ttl",
808+ overrides.leaseTtlSec ?? env.BAA_CONDUCTOR_LEASE_TTL_SEC,
809+ { minimum: 1 }
810+ ),
811+ renewFailureThreshold: parseIntegerValue(
812+ "Conductor renew failure threshold",
813+ overrides.renewFailureThreshold ?? env.BAA_CONDUCTOR_RENEW_FAILURE_THRESHOLD,
814+ { minimum: 1 }
815+ ),
816+ localApiBase: normalizeOptionalString(overrides.localApiBase ?? env.BAA_CONDUCTOR_LOCAL_API),
817+ sharedToken: normalizeOptionalString(overrides.sharedToken ?? env.BAA_SHARED_TOKEN),
818+ paths: {
819+ logsDir: normalizeOptionalString(overrides.logsDir ?? env.BAA_LOGS_DIR),
820+ runsDir: normalizeOptionalString(overrides.runsDir ?? env.BAA_RUNS_DIR),
821+ stateDir: normalizeOptionalString(overrides.stateDir ?? env.BAA_STATE_DIR),
822+ tmpDir: normalizeOptionalString(overrides.tmpDir ?? env.BAA_TMP_DIR),
823+ worktreesDir: normalizeOptionalString(overrides.worktreesDir ?? env.BAA_WORKTREES_DIR)
824+ }
825+ });
826+}
827+
828+export function parseConductorCliRequest(
829+ argv: readonly string[],
830+ env: ConductorEnvironment = {}
831+): ConductorCliRequest {
832+ const tokens = [...argv];
833+ let action: ConductorCliAction = "start";
834+
835+ if (tokens[0] && !tokens[0].startsWith("--")) {
836+ const maybeAction = tokens.shift();
837+
838+ if (!maybeAction || !isCliAction(maybeAction)) {
839+ throw new Error(`Unknown conductor command: ${maybeAction ?? "<empty>"}.`);
840+ }
841+
842+ action = maybeAction;
843+ }
844+
845+ let printJson = false;
846+ let showHelp = action === "help";
847+ const overrides: CliValueOverrides = {
848+ runOnce: false
849+ };
850+
851+ for (let index = 0; index < tokens.length; index += 1) {
852+ const token = tokens[index];
853+
854+ switch (token) {
855+ case "--help":
856+ showHelp = true;
857+ break;
858+ case "--json":
859+ printJson = true;
860+ break;
861+ case "--run-once":
862+ overrides.runOnce = true;
863+ break;
864+ case "--preferred":
865+ overrides.preferred = true;
866+ break;
867+ case "--no-preferred":
868+ overrides.preferred = false;
869+ break;
870+ case "--node-id":
871+ overrides.nodeId = readOptionValue(tokens, token, index);
872+ index += 1;
873+ break;
874+ case "--host":
875+ overrides.host = readOptionValue(tokens, token, index);
876+ index += 1;
877+ break;
878+ case "--role":
879+ overrides.role = readOptionValue(tokens, token, index);
880+ index += 1;
881+ break;
882+ case "--control-api-base":
883+ overrides.controlApiBase = readOptionValue(tokens, token, index);
884+ index += 1;
885+ break;
886+ case "--local-api":
887+ overrides.localApiBase = readOptionValue(tokens, token, index);
888+ index += 1;
889+ break;
890+ case "--shared-token":
891+ overrides.sharedToken = readOptionValue(tokens, token, index);
892+ index += 1;
893+ break;
894+ case "--priority":
895+ overrides.priority = readOptionValue(tokens, token, index);
896+ index += 1;
897+ break;
898+ case "--version":
899+ overrides.version = readOptionValue(tokens, token, index);
900+ index += 1;
901+ break;
902+ case "--heartbeat-interval-ms":
903+ overrides.heartbeatIntervalMs = readOptionValue(tokens, token, index);
904+ index += 1;
905+ break;
906+ case "--lease-renew-interval-ms":
907+ overrides.leaseRenewIntervalMs = readOptionValue(tokens, token, index);
908+ index += 1;
909+ break;
910+ case "--lease-ttl-sec":
911+ overrides.leaseTtlSec = readOptionValue(tokens, token, index);
912+ index += 1;
913+ break;
914+ case "--renew-failure-threshold":
915+ overrides.renewFailureThreshold = readOptionValue(tokens, token, index);
916+ index += 1;
917+ break;
918+ case "--runs-dir":
919+ overrides.runsDir = readOptionValue(tokens, token, index);
920+ index += 1;
921+ break;
922+ case "--worktrees-dir":
923+ overrides.worktreesDir = readOptionValue(tokens, token, index);
924+ index += 1;
925+ break;
926+ case "--logs-dir":
927+ overrides.logsDir = readOptionValue(tokens, token, index);
928+ index += 1;
929+ break;
930+ case "--tmp-dir":
931+ overrides.tmpDir = readOptionValue(tokens, token, index);
932+ index += 1;
933+ break;
934+ case "--state-dir":
935+ overrides.stateDir = readOptionValue(tokens, token, index);
936+ index += 1;
937+ break;
938+ default:
939+ throw new Error(`Unknown conductor option: ${token}.`);
940+ }
941+ }
942+
943+ if (showHelp) {
944+ return { action: "help" };
945+ }
946+
947+ if (action === "checklist") {
948+ return {
949+ action,
950+ printJson
951+ };
952+ }
953+
954+ const config = resolveRuntimeConfigFromSources(env, overrides);
955+
956+ if (action === "config") {
957+ return {
958+ action,
959+ config,
960+ printJson
961+ };
962+ }
963+
964+ return {
965+ action: "start",
966+ config,
967+ printJson,
968+ runOnce: overrides.runOnce
969+ };
970+}
971+
972+function usesPlaceholderToken(token: string | null): boolean {
973+ return token === "replace-me";
974+}
975+
976+function buildRuntimeWarnings(config: ResolvedConductorRuntimeConfig): string[] {
977+ const warnings: string[] = [];
978+
979+ if (config.sharedToken == null) {
980+ warnings.push("BAA_SHARED_TOKEN is not configured; authenticated Control API requests may fail.");
981+ } else if (usesPlaceholderToken(config.sharedToken)) {
982+ warnings.push("BAA_SHARED_TOKEN is still set to replace-me; replace it before production rollout.");
983+ }
984+
985+ if (config.localApiBase == null) {
986+ warnings.push("BAA_CONDUCTOR_LOCAL_API is not configured; only the in-process snapshot interface is available.");
987+ }
988+
989+ if (config.leaseRenewIntervalMs >= config.leaseTtlSec * 1_000) {
990+ warnings.push("lease renew interval is >= lease TTL; leader renewals may race with lease expiry.");
991+ }
992+
993+ return warnings;
994+}
995+
996+function getProcessLike(): ConductorProcessLike | undefined {
997+ return (globalThis as { process?: ConductorProcessLike }).process;
998+}
999+
1000+function writeLine(writer: ConductorOutputWriter, line: string): void {
1001+ if ("write" in writer) {
1002+ writer.write(`${line}\n`);
1003+ return;
1004+ }
1005+
1006+ writer.log(line);
1007+}
1008+
1009+function formatChecklistText(checklist: StartupChecklistItem[]): string {
1010+ return checklist.map((item, index) => `${index + 1}. ${item.key}: ${item.description}`).join("\n");
1011+}
1012+
1013+function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
1014+ return [
1015+ `identity: ${config.nodeId}@${config.host}(${config.role})`,
1016+ `control_api_base: ${config.controlApiBase}`,
1017+ `local_api_base: ${config.localApiBase ?? "not-configured"}`,
1018+ `priority: ${config.priority}`,
1019+ `preferred: ${String(config.preferred)}`,
1020+ `heartbeat_interval_ms: ${config.heartbeatIntervalMs}`,
1021+ `lease_renew_interval_ms: ${config.leaseRenewIntervalMs}`,
1022+ `lease_ttl_sec: ${config.leaseTtlSec}`,
1023+ `renew_failure_threshold: ${config.renewFailureThreshold}`,
1024+ `shared_token_configured: ${String(config.sharedToken != null)}`,
1025+ `runs_dir: ${config.paths.runsDir ?? "not-configured"}`,
1026+ `worktrees_dir: ${config.paths.worktreesDir ?? "not-configured"}`,
1027+ `logs_dir: ${config.paths.logsDir ?? "not-configured"}`,
1028+ `tmp_dir: ${config.paths.tmpDir ?? "not-configured"}`,
1029+ `state_dir: ${config.paths.stateDir ?? "not-configured"}`
1030+ ].join("\n");
1031+}
1032+
1033+function formatRuntimeSummary(snapshot: ConductorRuntimeSnapshot): string {
1034+ return [
1035+ `identity=${snapshot.identity}`,
1036+ `lease=${snapshot.daemon.leaseState}`,
1037+ `leader=${snapshot.daemon.currentLeaderId ?? "none"}`,
1038+ `term=${snapshot.daemon.currentTerm ?? "none"}`,
1039+ `scheduler=${snapshot.daemon.schedulerEnabled ? "enabled" : "disabled"}`,
1040+ `heartbeat_loop=${snapshot.loops.heartbeat ? "running" : "stopped"}`,
1041+ `lease_loop=${snapshot.loops.lease ? "running" : "stopped"}`
1042+ ].join(" ");
1043+}
1044+
1045+function getUsageText(): string {
1046+ return [
1047+ "Usage:",
1048+ " node apps/conductor-daemon/dist/index.js [start] [options]",
1049+ " node --experimental-strip-types apps/conductor-daemon/src/index.ts [start] [options]",
1050+ " node .../index.js config [options]",
1051+ " node .../index.js checklist [--json]",
1052+ " node .../index.js help",
1053+ "",
1054+ "Options:",
1055+ " --node-id <id>",
1056+ " --host <host>",
1057+ " --role <primary|standby>",
1058+ " --control-api-base <url>",
1059+ " --local-api <url>",
1060+ " --shared-token <token>",
1061+ " --priority <integer>",
1062+ " --version <string>",
1063+ " --preferred | --no-preferred",
1064+ " --heartbeat-interval-ms <integer>",
1065+ " --lease-renew-interval-ms <integer>",
1066+ " --lease-ttl-sec <integer>",
1067+ " --renew-failure-threshold <integer>",
1068+ " --runs-dir <path>",
1069+ " --worktrees-dir <path>",
1070+ " --logs-dir <path>",
1071+ " --tmp-dir <path>",
1072+ " --state-dir <path>",
1073+ " --run-once",
1074+ " --json",
1075+ " --help",
1076+ "",
1077+ "Environment:",
1078+ " BAA_NODE_ID",
1079+ " BAA_CONDUCTOR_HOST",
1080+ " BAA_CONDUCTOR_ROLE",
1081+ " BAA_CONTROL_API_BASE",
1082+ " BAA_CONDUCTOR_LOCAL_API",
1083+ " BAA_SHARED_TOKEN",
1084+ " BAA_CONDUCTOR_PRIORITY",
1085+ " BAA_CONDUCTOR_VERSION",
1086+ " BAA_CONDUCTOR_PREFERRED",
1087+ " BAA_CONDUCTOR_HEARTBEAT_INTERVAL_MS",
1088+ " BAA_CONDUCTOR_LEASE_RENEW_INTERVAL_MS",
1089+ " BAA_CONDUCTOR_LEASE_TTL_SEC",
1090+ " BAA_CONDUCTOR_RENEW_FAILURE_THRESHOLD",
1091+ " BAA_RUNS_DIR",
1092+ " BAA_WORKTREES_DIR",
1093+ " BAA_LOGS_DIR",
1094+ " BAA_TMP_DIR",
1095+ " BAA_STATE_DIR"
1096+ ].join("\n");
1097+}
1098+
1099+export class ConductorRuntime {
1100+ private readonly config: ResolvedConductorRuntimeConfig;
1101+ private readonly daemon: ConductorDaemon;
1102+ private started = false;
1103+
1104+ constructor(config: ConductorRuntimeConfig, options: ConductorRuntimeOptions = {}) {
1105+ const now = options.now ?? defaultNowUnixSeconds;
1106+ const startedAt = config.startedAt ?? now();
1107+
1108+ this.config = resolveConductorRuntimeConfig({
1109+ ...config,
1110+ startedAt
1111+ });
1112+ this.daemon = new ConductorDaemon(this.config, {
1113+ ...options,
1114+ controlApiBearerToken: options.controlApiBearerToken ?? this.config.sharedToken,
1115+ now
1116+ });
1117+ }
1118+
1119+ async start(): Promise<ConductorRuntimeSnapshot> {
1120+ if (!this.started) {
1121+ await this.daemon.start();
1122+ this.started = true;
1123+ }
1124+
1125+ return this.getRuntimeSnapshot();
1126+ }
1127+
1128+ stop(): ConductorRuntimeSnapshot {
1129+ this.daemon.stop();
1130+ this.started = false;
1131+ return this.getRuntimeSnapshot();
1132+ }
1133+
1134+ getRuntimeSnapshot(now: number = defaultNowUnixSeconds()): ConductorRuntimeSnapshot {
1135+ return {
1136+ daemon: this.daemon.getStatusSnapshot(now),
1137+ identity: this.daemon.describeIdentity(),
1138+ loops: this.daemon.getLoopStatus(),
1139+ paths: { ...this.config.paths },
1140+ controlApi: {
1141+ baseUrl: this.config.controlApiBase,
1142+ localApiBase: this.config.localApiBase,
1143+ hasSharedToken: this.config.sharedToken != null,
1144+ usesPlaceholderToken: usesPlaceholderToken(this.config.sharedToken)
1145+ },
1146+ runtime: {
1147+ pid: getProcessLike()?.pid ?? null,
1148+ started: this.started,
1149+ startedAt: this.config.startedAt ?? now
1150+ },
1151+ startupChecklist: this.daemon.getStartupChecklist(),
1152+ warnings: buildRuntimeWarnings(this.config)
1153+ };
1154+ }
1155+}
1156+
1157+async function waitForShutdownSignal(processLike: ConductorProcessLike | undefined): Promise<string | null> {
1158+ const subscribe = processLike?.on;
1159+
1160+ if (!subscribe || !processLike) {
1161+ return null;
1162+ }
1163+
1164+ return new Promise((resolve) => {
1165+ const signals = ["SIGINT", "SIGTERM"] as const;
1166+ const listeners: Partial<Record<(typeof signals)[number], () => void>> = {};
1167+ const cleanup = () => {
1168+ if (!processLike.off) {
1169+ return;
1170+ }
1171+
1172+ for (const signal of signals) {
1173+ const listener = listeners[signal];
1174+
1175+ if (listener) {
1176+ processLike.off(signal, listener);
1177+ }
1178+ }
1179+ };
1180+
1181+ for (const signal of signals) {
1182+ const listener = () => {
1183+ cleanup();
1184+ resolve(signal);
1185+ };
1186+ listeners[signal] = listener;
1187+ subscribe.call(processLike, signal, listener);
1188+ }
1189+ });
1190+}
1191+
1192+export async function runConductorCli(options: RunConductorCliOptions = {}): Promise<number> {
1193+ const processLike = options.processLike ?? getProcessLike();
1194+ const stdout = options.stdout ?? console;
1195+ const stderr = options.stderr ?? console;
1196+ const argv = options.argv ?? processLike?.argv?.slice(2) ?? [];
1197+ const env = options.env ?? processLike?.env ?? {};
1198+ const request = parseConductorCliRequest(argv, env);
1199+
1200+ if (request.action === "help") {
1201+ writeLine(stdout, getUsageText());
1202+ return 0;
1203+ }
1204+
1205+ if (request.action === "checklist") {
1206+ const checklist = getDefaultStartupChecklist();
1207+
1208+ if (request.printJson) {
1209+ writeLine(stdout, JSON.stringify(checklist, null, 2));
1210+ } else {
1211+ writeLine(stdout, formatChecklistText(checklist));
1212+ }
1213+
1214+ return 0;
1215+ }
1216+
1217+ if (request.action === "config") {
1218+ if (request.printJson) {
1219+ writeLine(stdout, JSON.stringify(request.config, null, 2));
1220+ } else {
1221+ writeLine(stdout, formatConfigText(request.config));
1222+ }
1223+
1224+ for (const warning of buildRuntimeWarnings(request.config)) {
1225+ writeLine(stderr, `warning: ${warning}`);
1226+ }
1227+
1228+ return 0;
1229+ }
1230+
1231+ const runtime = new ConductorRuntime(request.config, {
1232+ autoStartLoops: !request.runOnce,
1233+ fetchImpl: options.fetchImpl
1234+ });
1235+ const snapshot = await runtime.start();
1236+
1237+ if (request.printJson || request.runOnce) {
1238+ writeLine(stdout, JSON.stringify(snapshot, null, 2));
1239+ } else {
1240+ writeLine(stdout, formatRuntimeSummary(snapshot));
1241+ }
1242+
1243+ for (const warning of snapshot.warnings) {
1244+ writeLine(stderr, `warning: ${warning}`);
1245+ }
1246+
1247+ if (request.runOnce) {
1248+ runtime.stop();
1249+ return 0;
1250+ }
1251+
1252+ const signal = await waitForShutdownSignal(processLike);
1253+ runtime.stop();
1254+
1255+ if (!request.printJson) {
1256+ writeLine(stdout, `conductor stopped${signal ? ` after ${signal}` : ""}`);
1257+ }
1258+
1259+ return 0;
1260+}
1261+
1262+function normalizeFilePath(path: string): string {
1263+ return path.replace(/\\/gu, "/");
1264+}
1265+
1266+function fileUrlToPath(fileUrl: string): string {
1267+ if (!fileUrl.startsWith("file://")) {
1268+ return fileUrl;
1269+ }
1270+
1271+ const decoded = decodeURIComponent(fileUrl.slice("file://".length));
1272+ return decoded.startsWith("/") && /^[A-Za-z]:/u.test(decoded.slice(1)) ? decoded.slice(1) : decoded;
1273+}
1274+
1275+function isMainModule(metaUrl: string, argv: readonly string[]): boolean {
1276+ const entryPoint = argv[1];
1277+
1278+ if (!entryPoint) {
1279+ return false;
1280+ }
1281+
1282+ return normalizeFilePath(entryPoint) === normalizeFilePath(fileUrlToPath(metaUrl));
1283+}
1284+
1285+if (isMainModule(import.meta.url, getProcessLike()?.argv ?? [])) {
1286+ void runConductorCli().then(
1287+ (exitCode) => {
1288+ const processLike = getProcessLike();
1289+
1290+ if (processLike) {
1291+ processLike.exitCode = exitCode;
1292+ }
1293+ },
1294+ (error: unknown) => {
1295+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
1296+ const processLike = getProcessLike();
1297+
1298+ if (processLike) {
1299+ processLike.exitCode = 1;
1300+ }
1301+ }
1302+ );
1303+}
+2,
-1
1@@ -2,8 +2,9 @@
2 "name": "@baa-conductor/control-api-worker",
3 "private": true,
4 "type": "module",
5+ "main": "dist/index.js",
6 "scripts": {
7- "build": "pnpm exec tsc --noEmit -p tsconfig.json",
8+ "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/control-api-worker/dist BAA_DIST_ENTRY=apps/control-api-worker/src/index.js BAA_IMPORT_ALIASES='@baa-conductor/auth=../../../packages/auth/src/index.js;@baa-conductor/db=../../../packages/db/src/index.js' BAA_FIX_RELATIVE_EXTENSIONS=true BAA_EXPORT_DEFAULT=true pnpm -C ../.. run build:runtime-postprocess",
9 "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
10 }
11 }
+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
+6,
-1
1@@ -2,8 +2,13 @@
2 "name": "@baa-conductor/status-api",
3 "private": true,
4 "type": "module",
5+ "main": "dist/index.js",
6+ "exports": {
7+ ".": "./dist/index.js",
8+ "./runtime": "./dist/apps/status-api/src/runtime.js"
9+ },
10 "scripts": {
11- "build": "pnpm exec tsc --noEmit -p tsconfig.json",
12+ "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/status-api/dist BAA_DIST_ENTRY=apps/status-api/src/index.js BAA_FIX_RELATIVE_EXTENSIONS=true pnpm -C ../.. run build:runtime-postprocess",
13 "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
14 }
15 }
+1,
-0
1@@ -26,6 +26,7 @@ export interface StatusApiRoute {
2 path: string;
3 summary: string;
4 contentType: "application/json" | "text/html" | "text/plain";
5+ aliases?: string[];
6 }
7
8 export interface StatusApiRequest {
+1,
-0
1@@ -1,4 +1,5 @@
2 export * from "./contracts.js";
3 export * from "./data-source.js";
4 export * from "./render.js";
5+export * from "./runtime.js";
6 export * from "./service.js";
+51,
-0
1@@ -0,0 +1,51 @@
2+import { StaticStatusSnapshotLoader } from "./data-source.js";
3+import type { StatusApiHandler, StatusApiRequest, StatusApiResponse, StatusApiRoute, StatusSnapshotLoader } from "./contracts.js";
4+import { createStatusApiHandler } from "./service.js";
5+
6+export interface StatusApiRuntime extends StatusApiHandler {
7+ fetch(request: Request): Promise<Response>;
8+}
9+
10+export interface StatusApiRuntimeOptions {
11+ snapshotLoader?: StatusSnapshotLoader;
12+}
13+
14+export function createStatusApiRuntime(options: StatusApiRuntimeOptions = {}): StatusApiRuntime {
15+ const handler = createStatusApiHandler(options.snapshotLoader ?? new StaticStatusSnapshotLoader());
16+
17+ return {
18+ routes: handler.routes,
19+ handle: handler.handle,
20+ fetch: async (request) => toFetchResponse(await handler.handle(toStatusApiRequest(request)))
21+ };
22+}
23+
24+export function createStatusApiFetchHandler(snapshotLoader: StatusSnapshotLoader): (request: Request) => Promise<Response> {
25+ const handler = createStatusApiHandler(snapshotLoader);
26+
27+ return async (request) => toFetchResponse(await handler.handle(toStatusApiRequest(request)));
28+}
29+
30+export function toStatusApiRequest(request: Pick<Request, "method" | "url">): StatusApiRequest {
31+ return {
32+ method: request.method,
33+ path: request.url
34+ };
35+}
36+
37+export function toFetchResponse(response: StatusApiResponse): Response {
38+ return new Response(response.body, {
39+ status: response.status,
40+ headers: new Headers(response.headers)
41+ });
42+}
43+
44+export function describeStatusApiRuntimeSurface(runtime: Pick<StatusApiRuntime, "routes">): string[] {
45+ return runtime.routes.map(describeStatusApiRoute);
46+}
47+
48+function describeStatusApiRoute(route: StatusApiRoute): string {
49+ const paths = [route.path, ...(route.aliases ?? [])].join(", ");
50+
51+ return `${route.method} ${paths} (${route.contentType})`;
52+}
+89,
-32
1@@ -22,15 +22,53 @@ const TEXT_HEADERS = {
2 "cache-control": "no-store"
3 } as const;
4
5-export const STATUS_API_ROUTES: StatusApiRoute[] = [
6- { method: "GET", path: "/healthz", summary: "状态服务健康检查", contentType: "text/plain" },
7- { method: "GET", path: "/v1/status", summary: "读取全局自动化状态快照", contentType: "application/json" },
8- { method: "GET", path: "/v1/status/ui", summary: "读取最小 HTML 状态面板", contentType: "text/html" },
9- { method: "GET", path: "/", summary: "最小状态面板首页", contentType: "text/html" }
10+type StatusApiRouteId = "healthz" | "status" | "ui";
11+
12+type StatusApiRouteDefinition = StatusApiRoute & {
13+ id: StatusApiRouteId;
14+};
15+
16+const STATUS_API_ROUTE_DEFINITIONS: ReadonlyArray<StatusApiRouteDefinition> = [
17+ {
18+ id: "healthz",
19+ method: "GET",
20+ path: "/healthz",
21+ summary: "状态服务健康检查",
22+ contentType: "text/plain"
23+ },
24+ {
25+ id: "status",
26+ method: "GET",
27+ path: "/v1/status",
28+ summary: "读取全局自动化状态快照",
29+ contentType: "application/json"
30+ },
31+ {
32+ id: "ui",
33+ method: "GET",
34+ path: "/v1/status/ui",
35+ aliases: ["/", "/ui"],
36+ summary: "读取最小 HTML 状态面板",
37+ contentType: "text/html"
38+ }
39 ];
40
41+const STATUS_API_ROUTE_LOOKUP = createStatusApiRouteLookup();
42+
43+export const STATUS_API_ROUTES: StatusApiRoute[] = STATUS_API_ROUTE_DEFINITIONS.map((route) => ({
44+ method: route.method,
45+ path: route.path,
46+ summary: route.summary,
47+ contentType: route.contentType,
48+ ...(route.aliases == null ? {} : { aliases: [...route.aliases] })
49+}));
50+
51 export function describeStatusApiSurface(): string[] {
52- return STATUS_API_ROUTES.map((route) => `${route.method} ${route.path} - ${route.summary}`);
53+ return STATUS_API_ROUTE_DEFINITIONS.map((route) => {
54+ const paths = [route.path, ...(route.aliases ?? [])].join(", ");
55+
56+ return `${route.method} ${paths} - ${route.summary}`;
57+ });
58 }
59
60 export function createStatusApiHandler(snapshotLoader: StatusSnapshotLoader): StatusApiHandler {
61@@ -59,36 +97,37 @@ export async function handleStatusApiRequest(
62 );
63 }
64
65- if (path === "/healthz") {
66- return {
67- status: 200,
68- headers: { ...TEXT_HEADERS },
69- body: "ok"
70- };
71- }
72-
73- const snapshot = await snapshotLoader.loadSnapshot();
74+ const route = resolveStatusApiRoute(path);
75
76- if (path === "/" || path === "/ui" || path === "/v1/status/ui") {
77- return {
78- status: 200,
79- headers: { ...HTML_HEADERS },
80- body: renderStatusPage(snapshot)
81- };
82- }
83-
84- if (path === "/v1/status") {
85- return jsonResponse(200, {
86- ok: true,
87- data: snapshot
88+ if (route == null) {
89+ return jsonResponse(404, {
90+ ok: false,
91+ error: "not_found",
92+ message: `No status route matches "${path}".`
93 });
94 }
95
96- return jsonResponse(404, {
97- ok: false,
98- error: "not_found",
99- message: `No status route matches "${path}".`
100- });
101+ switch (route.id) {
102+ case "healthz":
103+ return {
104+ status: 200,
105+ headers: { ...TEXT_HEADERS },
106+ body: "ok"
107+ };
108+
109+ case "status":
110+ return jsonResponse(200, {
111+ ok: true,
112+ data: await snapshotLoader.loadSnapshot()
113+ });
114+
115+ case "ui":
116+ return {
117+ status: 200,
118+ headers: { ...HTML_HEADERS },
119+ body: renderStatusPage(await snapshotLoader.loadSnapshot())
120+ };
121+ }
122 }
123
124 function jsonResponse(
125@@ -113,3 +152,21 @@ function normalizePath(value: string): string {
126
127 return normalized === "" ? "/" : normalized;
128 }
129+
130+function createStatusApiRouteLookup(): Map<string, StatusApiRouteDefinition> {
131+ const lookup = new Map<string, StatusApiRouteDefinition>();
132+
133+ for (const route of STATUS_API_ROUTE_DEFINITIONS) {
134+ lookup.set(route.path, route);
135+
136+ for (const alias of route.aliases ?? []) {
137+ lookup.set(alias, route);
138+ }
139+ }
140+
141+ return lookup;
142+}
143+
144+function resolveStatusApiRoute(path: string): StatusApiRouteDefinition | null {
145+ return STATUS_API_ROUTE_LOOKUP.get(path) ?? null;
146+}
+1,
-0
1@@ -1,6 +1,7 @@
2 {
3 "extends": "../../tsconfig.base.json",
4 "compilerOptions": {
5+ "lib": ["ES2022", "DOM"],
6 "rootDir": "../..",
7 "outDir": "dist"
8 },
+2,
-1
1@@ -2,8 +2,9 @@
2 "name": "@baa-conductor/worker-runner",
3 "private": true,
4 "type": "module",
5+ "main": "dist/index.js",
6 "scripts": {
7- "build": "pnpm exec tsc --noEmit -p tsconfig.json",
8+ "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/worker-runner/dist BAA_DIST_ENTRY=apps/worker-runner/src/index.js BAA_IMPORT_ALIASES='@baa-conductor/logging=../../../packages/logging/src/index.js;@baa-conductor/checkpointing=../../../packages/checkpointing/src/index.js' BAA_FIX_RELATIVE_EXTENSIONS=true pnpm -C ../.. run build:runtime-postprocess",
9 "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
10 }
11 }
+114,
-50
1@@ -1,16 +1,26 @@
2 import {
3+ persistCheckpointRecord,
4+ type CheckpointRecord
5+} from "@baa-conductor/checkpointing";
6+import {
7+ appendLifecycleEntry,
8 appendStreamChunk,
9+ appendStreamEntry,
10 createLocalRunLogSession,
11 createLocalRunPaths,
12 createRunMetadata,
13 createRunStateSnapshot,
14+ initializeLocalRunFiles,
15 recordLifecycleEvent,
16 summarizeLocalRunLogSession,
17 updateRunState,
18+ writeRunState,
19 type LogLevel,
20- type StructuredData,
21 type RunStatePatch,
22 type RunStatus,
23+ type StreamLogChannel,
24+ type StructuredData,
25+ type WorkerLifecycleEventInput,
26 type WorkerLifecycleEventType
27 } from "@baa-conductor/logging";
28 import type {
29@@ -38,6 +48,61 @@ function synchronizeRunState(run: PreparedStepRun, patch: RunStatePatch): void {
30 });
31 }
32
33+async function persistRunState(run: PreparedStepRun): Promise<void> {
34+ await writeRunState(run.logPaths, run.state);
35+}
36+
37+async function synchronizeRunStatePersisted(
38+ run: PreparedStepRun,
39+ patch: RunStatePatch
40+): Promise<void> {
41+ synchronizeRunState(run, patch);
42+ await persistRunState(run);
43+}
44+
45+async function persistCheckpointRecordIfPresent(
46+ run: PreparedStepRun,
47+ record?: CheckpointRecord
48+): Promise<void> {
49+ if (record === undefined) {
50+ return;
51+ }
52+
53+ await persistCheckpointRecord(record);
54+ await synchronizeRunStatePersisted(run, {
55+ updatedAt: record.createdAt
56+ });
57+}
58+
59+async function recordLifecycleEventPersisted(
60+ run: PreparedStepRun,
61+ input: WorkerLifecycleEventInput,
62+ statePatch: RunStatePatch = {}
63+): Promise<void> {
64+ const entry = recordLifecycleEvent(run.logSession, input);
65+
66+ await appendLifecycleEntry(run.logSession, entry);
67+ await synchronizeRunStatePersisted(run, {
68+ ...statePatch,
69+ updatedAt: statePatch.updatedAt ?? entry.createdAt
70+ });
71+}
72+
73+async function appendStreamChunkPersisted(
74+ run: PreparedStepRun,
75+ channel: StreamLogChannel,
76+ text: string
77+): Promise<void> {
78+ const entry = appendStreamChunk(run.logSession, channel, {
79+ text
80+ });
81+
82+ await appendStreamEntry(run.logSession, entry);
83+ await synchronizeRunStatePersisted(run, {
84+ updatedAt: entry.createdAt
85+ });
86+}
87+
88 function createDefaultArtifacts(run: PreparedStepRun): StepArtifact[] {
89 return [
90 {
91@@ -145,7 +210,7 @@ function resolveExitCode(execution: WorkerExecutionOutcome): number {
92 return execution.ok ? 0 : 1;
93 }
94
95-export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
96+export async function prepareStepRun(request: StepExecutionRequest): Promise<PreparedStepRun> {
97 const startedAt = request.createdAt ?? new Date().toISOString();
98 const checkpointConfig = resolveCheckpointConfig(request.checkpoint);
99 const logPaths = createLocalRunPaths({
100@@ -190,7 +255,10 @@ export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
101 checkpointManager
102 };
103
104- recordLifecycleEvent(run.logSession, {
105+ await initializeLocalRunFiles(run.logPaths, run.metadata, run.state);
106+ await persistCheckpointRecordIfPresent(run, preparationCheckpoint);
107+
108+ await recordLifecycleEventPersisted(run, {
109 type: "run_prepared",
110 level: "info",
111 createdAt: startedAt,
112@@ -249,7 +317,7 @@ export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
113 };
114 }
115
116- recordLifecycleEvent(run.logSession, {
117+ await recordLifecycleEventPersisted(run, {
118 type: "checkpoint_slot_reserved",
119 level: "info",
120 message: `Initialized checkpoint manager at sequence ${checkpoint.lastCheckpointSeq}.`,
121@@ -257,10 +325,6 @@ export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
122 });
123 }
124
125- synchronizeRunState(run, {
126- updatedAt: startedAt
127- });
128-
129 return run;
130 }
131
132@@ -284,27 +348,29 @@ export async function runStep(
133 executor: WorkerExecutor = createPlaceholderWorkerExecutor()
134 ): Promise<StepExecutionResult> {
135 const startedAt = request.createdAt ?? new Date().toISOString();
136- const run = prepareStepRun({
137+ const run = await prepareStepRun({
138 ...request,
139 createdAt: startedAt
140 });
141 const executionStartedAt = new Date().toISOString();
142
143- recordLifecycleEvent(run.logSession, {
144- type: "worker_started",
145- level: "info",
146- createdAt: executionStartedAt,
147- message: `Worker runner opened execution scope for ${request.workerKind} step ${request.stepId}.`,
148- data: {
149- stepKind: request.stepKind,
150- timeoutSec: request.timeoutSec,
151- worktreePath: request.runtime.worktreePath
152+ await recordLifecycleEventPersisted(
153+ run,
154+ {
155+ type: "worker_started",
156+ level: "info",
157+ createdAt: executionStartedAt,
158+ message: `Worker runner opened execution scope for ${request.workerKind} step ${request.stepId}.`,
159+ data: {
160+ stepKind: request.stepKind,
161+ timeoutSec: request.timeoutSec,
162+ worktreePath: request.runtime.worktreePath
163+ }
164+ },
165+ {
166+ status: "running"
167 }
168- });
169- synchronizeRunState(run, {
170- status: "running",
171- updatedAt: executionStartedAt
172- });
173+ );
174
175 const execution = await executor.execute(run);
176 const blocked = execution.blocked ?? execution.outcome === "blocked";
177@@ -312,19 +378,15 @@ export async function runStep(
178 const exitCode = resolveExitCode(execution);
179
180 for (const line of execution.stdout ?? []) {
181- appendStreamChunk(run.logSession, "stdout", {
182- text: line
183- });
184+ await appendStreamChunkPersisted(run, "stdout", line);
185 }
186
187 for (const line of execution.stderr ?? []) {
188- appendStreamChunk(run.logSession, "stderr", {
189- text: line
190- });
191+ await appendStreamChunkPersisted(run, "stderr", line);
192 }
193
194 if (execution.outcome === "prepared") {
195- recordLifecycleEvent(run.logSession, {
196+ await recordLifecycleEventPersisted(run, {
197 type: "worker_execution_deferred",
198 level: "info",
199 message: `Real ${request.workerKind} execution is intentionally deferred while checkpoint capture remains active.`,
200@@ -335,7 +397,7 @@ export async function runStep(
201 });
202 }
203
204- recordLifecycleEvent(run.logSession, {
205+ await recordLifecycleEventPersisted(run, {
206 type: "worker_exited",
207 level: exitCode === 0 ? "info" : "error",
208 message: `Worker runner closed execution scope with outcome ${execution.outcome}.`,
209@@ -347,34 +409,36 @@ export async function runStep(
210
211 const finishedAt = new Date().toISOString();
212
213- recordLifecycleEvent(run.logSession, {
214- type: mapOutcomeToTerminalEvent(execution.outcome),
215- level: mapOutcomeToLevel(execution),
216- createdAt: finishedAt,
217- message: execution.summary,
218- data: {
219- ok: execution.ok,
220- blocked,
221- needsHuman,
222+ await recordLifecycleEventPersisted(
223+ run,
224+ {
225+ type: mapOutcomeToTerminalEvent(execution.outcome),
226+ level: mapOutcomeToLevel(execution),
227+ createdAt: finishedAt,
228+ message: execution.summary,
229+ data: {
230+ ok: execution.ok,
231+ blocked,
232+ needsHuman,
233+ exitCode
234+ }
235+ },
236+ {
237+ status: mapOutcomeToRunStatus(execution.outcome),
238+ finishedAt,
239+ summary: execution.summary,
240 exitCode
241 }
242- });
243+ );
244
245- emitLogTailCheckpoint(
246+ const logTailCheckpoint = emitLogTailCheckpoint(
247 run.checkpointManager,
248 run.checkpoint,
249 run.logSession,
250 `Captured combined log tail after ${execution.outcome} for ${request.stepId}.`,
251 request.checkpoint?.logTailLines
252 );
253-
254- synchronizeRunState(run, {
255- status: mapOutcomeToRunStatus(execution.outcome),
256- updatedAt: finishedAt,
257- finishedAt,
258- summary: execution.summary,
259- exitCode
260- });
261+ await persistCheckpointRecordIfPresent(run, logTailCheckpoint);
262
263 const logSummary = summarizeLocalRunLogSession(run.logSession);
264 const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt));
1@@ -1,10 +1,10 @@
2 ---
3 task_id: T-013
4 title: Build 与 dist 产物
5-status: todo
6+status: review
7 branch: feat/T-013-build-runtime
8 repo: /Users/george/code/baa-conductor
9-base_ref: main
10+base_ref: main@c5e007b
11 depends_on:
12 - T-011
13 write_scope:
14@@ -19,7 +19,7 @@ write_scope:
15 - apps/worker-runner/package.json
16 - apps/worker-runner/tsconfig.json
17 - docs/runtime/**
18-updated_at: 2026-03-21
19+updated_at: 2026-03-22T00:09:20+0800
20 ---
21
22 # T-013 Build 与 dist 产物
23@@ -69,25 +69,45 @@ updated_at: 2026-03-21
24
25 ## files_changed
26
27-- 待填写
28+- `coordination/tasks/T-013-build-runtime.md`
29+- `package.json`
30+- `apps/conductor-daemon/package.json`
31+- `apps/control-api-worker/package.json`
32+- `apps/status-api/package.json`
33+- `apps/worker-runner/package.json`
34+- `docs/runtime/README.md`
35+- `docs/runtime/launchd.md`
36
37 ## commands_run
38
39-- 待填写
40+- `git worktree add /Users/george/code/baa-conductor-T013 -b feat/T-013-build-runtime c5e007b082772d085a030217691f6b88da9b3ee4`
41+- `npx --yes pnpm install`
42+- `npx --yes pnpm -r build`
43+- `node --input-type=module -e "await import('./apps/conductor-daemon/dist/index.js'); console.log('ok conductor-daemon');"`
44+- `node --input-type=module -e "await import('./apps/control-api-worker/dist/index.js'); console.log('ok control-api-worker');"`
45+- `node --input-type=module -e "await import('./apps/status-api/dist/index.js'); console.log('ok status-api');"`
46+- `node --input-type=module -e "await import('./apps/worker-runner/dist/index.js'); console.log('ok worker-runner');"`
47
48 ## result
49
50-- 待填写
51+- 四个 app 的 `build` 已从 `tsc --noEmit` 改成真实 emit,其中 `conductor-daemon` 直接输出 `dist/index.js`
52+- 为 `control-api-worker`、`status-api`、`worker-runner` 增加了统一 postbuild 处理:固定根入口到 `dist/index.js`,并在需要时改写编译后 import specifier
53+- `control-api-worker` 与 `worker-runner` 的编译产物已改写为引用同一 `dist/` 下同步生成的 package JS 文件,`worker-runner` 产物里的相对 import 也已补齐 `.js` 扩展
54+- `npx --yes pnpm -r build` 通过,四个 `apps/*/dist/index.js` 均存在,且可被 `node` 直接 `import(...)`
55+- `docs/runtime` 已更新为“先 build 再 launchd bootstrap”的当前约定,不再声明仓库缺少 `dist/index.js`
56
57 ## risks
58
59-- 待填写
60+- 目前 package 自身仍然只做 typecheck,没有形成独立发布级 `dist/`;`control-api-worker` 与 `worker-runner` 依赖 app build 阶段的产物改写
61+- 根 `package.json` 中的 postbuild helper 目前以内联脚本维护,后续如果构建规则继续变复杂,最好拆成独立脚本文件
62+- 本任务解决的是“产物路径稳定且可加载”,不是“服务运行时逻辑全部接好”;`T-014`、`T-015`、`T-017` 仍需补真正启动流程
63
64 ## next_handoff
65
66-- 待填写
67+- `T-014` 可以直接把 `apps/control-api-worker/dist/index.js` 当作稳定入口做运行时接线,不必再解决 bare workspace import 问题
68+- `T-015` 可以基于 `apps/conductor-daemon/dist/index.js` 继续补 CLI/daemon 启动逻辑,并复用文档里“先 build 再 bootstrap”的流程
69+- `T-017` 可以直接围绕 `apps/status-api/dist/index.js` 挂 HTTP 启动入口;若后续需要 package 级独立 dist,可另拆构建任务
70
71 ## notes
72
73 - `2026-03-21`: 创建第三波任务卡
74-
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`
1@@ -1,15 +1,15 @@
2 ---
3 task_id: T-015
4 title: Conductor 运行时接线
5-status: todo
6+status: review
7 branch: feat/T-015-conductor-runtime
8 repo: /Users/george/code/baa-conductor
9-base_ref: main
10+base_ref: main@c5e007b
11 depends_on:
12 - T-004
13 write_scope:
14 - apps/conductor-daemon/**
15-updated_at: 2026-03-21
16+updated_at: 2026-03-22
17 ---
18
19 # T-015 Conductor 运行时接线
20@@ -55,25 +55,38 @@ updated_at: 2026-03-21
21
22 ## files_changed
23
24-- 待填写
25+- `apps/conductor-daemon/package.json`
26+- `apps/conductor-daemon/src/index.ts`
27+- `apps/conductor-daemon/src/index.test.js`
28
29 ## commands_run
30
31-- 待填写
32+- `npx --yes pnpm install`
33+- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
34+- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
35+- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
36+- `node apps/conductor-daemon/dist/index.js start --node-id mini-main --host mini --role primary --control-api-base http://127.0.0.1:9 --run-once --json`
37+- `git diff --check`
38
39 ## result
40
41-- 待填写
42+- `apps/conductor-daemon` 现在有可执行的 CLI/runtime 入口,支持 `start`、`config`、`checklist`,并能从 env/CLI 解析节点身份、control-api 地址、共享 token 与本地目录配置。
43+- control-api client 已按第三波 contract 补上 Bearer 认证、统一 envelope 解包,以及 `snake_case` 请求/响应兼容,能对接 `/v1/controllers/heartbeat` 和 `/v1/leader/acquire`。
44+- runtime 侧新增最小快照接口 `ConductorRuntime.getRuntimeSnapshot()`,并把启动流程整理为:初始 heartbeat、初始 lease cycle、本地 runs hook、后台 loop 启动;同时补了 CLI/config/client/runtime 相关测试。
45+- `conductor-daemon` 自身 `build` 脚本现在会产出 `dist/index.js`,验证过构建后的入口可以直接启动并在 control-api 不可达时稳定落到 `degraded` 快照。
46
47 ## risks
48
49-- 待填写
50+- `apps/control-api-worker` 当前主线仍以占位 handler 为主;虽然 client 侧协议已对齐,但真实 heartbeat / lease 持久化效果仍依赖 `T-014` 把 handler 接到 repository。
51+- 最小 runtime 接口当前是进程内快照与 CLI 输出,还没有单独的 loopback HTTP 服务;如果后续需要让 status-api 或 launchd 直接探活,可能还要在 `apps/conductor-daemon/**` 内补本地只读端点。
52+- `conductor-daemon` 的 app 级 build 现已可产出 dist,但根级统一构建链路仍需 `T-013` 做最终收口,避免后续在脚本层重复调整。
53
54 ## next_handoff
55
56-- 待填写
57+- `T-014` 可以直接用当前 client 期望的 Bearer + envelope + `snake_case` 约定实现真实 handler,并把 `/v1/controllers/heartbeat`、`/v1/leader/acquire` 返回值收敛到已测试的兼容形状。
58+- 后续 scheduler / status 相关任务可以直接复用 `ConductorRuntime` 与 `getRuntimeSnapshot()` 作为启动态、主备态和配置态的最小读取接口。
59+- `T-013` 如需统一根级 build/launchd 行为,应保留 `apps/conductor-daemon/dist/index.js` 作为稳定入口路径。
60
61 ## notes
62
63 - `2026-03-21`: 创建第三波任务卡
64-
1@@ -1,10 +1,10 @@
2 ---
3 task_id: T-016
4 title: Worker 本地持久化
5-status: todo
6+status: review
7 branch: feat/T-016-worker-persistence
8 repo: /Users/george/code/baa-conductor
9-base_ref: main
10+base_ref: main@c5e007b
11 depends_on:
12 - T-005
13 - T-006
14@@ -12,7 +12,7 @@ write_scope:
15 - apps/worker-runner/**
16 - packages/checkpointing/**
17 - packages/logging/**
18-updated_at: 2026-03-21
19+updated_at: 2026-03-22
20 ---
21
22 # T-016 Worker 本地持久化
23@@ -60,25 +60,39 @@ updated_at: 2026-03-21
24
25 ## files_changed
26
27-- 待填写
28+- `apps/worker-runner/src/runner.ts`
29+- `packages/checkpointing/src/index.ts`
30+- `packages/checkpointing/src/node-shims.ts`
31+- `packages/logging/src/index.ts`
32+- `packages/logging/src/node-shims.ts`
33+- `packages/logging/src/persistence.ts`
34+- `coordination/tasks/T-016-worker-persistence.md`
35
36 ## commands_run
37
38-- 待填写
39+- `npx --yes pnpm install`
40+- `npx --yes pnpm --filter @baa-conductor/logging typecheck`
41+- `npx --yes pnpm --filter @baa-conductor/checkpointing typecheck`
42+- `npx --yes pnpm --filter @baa-conductor/worker-runner typecheck`
43+- `npx --yes pnpm exec tsc -p apps/worker-runner/tsconfig.json --outDir <tmp> --module commonjs`
44+- `DIST_ROOT=<tmp> RUNTIME_ROOT=<tmp> node <<'EOF' ... EOF`
45
46 ## result
47
48-- 待填写
49+- `worker-runner` 现在会创建本地 run 目录,并在初始化时写入 `meta.json`、`state.json`、空的 `worker.log` / `stdout.log` / `stderr.log`。
50+- 生命周期事件、stdout/stderr chunk、checkpoint 写入后都会同步刷新本地 `state.json`,不再只停留在内存态。
51+- `summary` 与 `log_tail` checkpoint 会真实落到 `checkpoints/`,并已通过临时 CommonJS 编译验证生成 `0001-summary.json`、`0002-log-tail.txt`。
52
53 ## risks
54
55-- 待填写
56+- 真实 Codex 子进程尚未接入,当前只验证了 placeholder/custom executor 路径下的本地持久化。
57+- `git_diff`、`test_output` 等更大 checkpoint 负载还没有实际生成端,当前仅补齐了文件写入基础设施。
58
59 ## next_handoff
60
61-- 待填写
62+- 将真实 worker 执行器接到 `appendStreamChunkPersisted` / `recordLifecycleEventPersisted`,保持 stdout/stderr 与状态文件持续落盘。
63+- 在后续 checkpoint 任务里直接复用 `persistCheckpointRecord` 扩展 `git_diff`、`test_output` 的实际产出。
64
65 ## notes
66
67 - `2026-03-21`: 创建第三波任务卡
68-
1@@ -1,15 +1,15 @@
2 ---
3 task_id: T-017
4 title: Status API 运行时入口
5-status: todo
6+status: review
7 branch: feat/T-017-status-runtime
8 repo: /Users/george/code/baa-conductor
9-base_ref: main
10+base_ref: main@c5e007b
11 depends_on:
12 - T-010
13 write_scope:
14 - apps/status-api/**
15-updated_at: 2026-03-21
16+updated_at: 2026-03-22
17 ---
18
19 # T-017 Status API 运行时入口
20@@ -55,25 +55,39 @@ updated_at: 2026-03-21
21
22 ## files_changed
23
24-- 待填写
25+- `apps/status-api/package.json`
26+- `apps/status-api/tsconfig.json`
27+- `apps/status-api/src/contracts.ts`
28+- `apps/status-api/src/index.ts`
29+- `apps/status-api/src/runtime.ts`
30+- `apps/status-api/src/service.ts`
31+- `coordination/tasks/T-017-status-runtime.md`
32
33 ## commands_run
34
35-- 待填写
36+- `npx --yes pnpm install`
37+- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
38+- `npx --yes pnpm --filter @baa-conductor/status-api build`
39+- `node --input-type=module -e "import { createStatusApiRuntime } from './apps/status-api/dist/apps/status-api/src/index.js'; ..."`
40
41 ## result
42
43-- 待填写
44+- 已将 status-api 从包内 handler 扩展为可挂载的 fetch 运行时入口,新增 `createStatusApiRuntime()` 与 `createStatusApiFetchHandler()`,可直接对接标准 `Request`/`Response`。
45+- 已整理对外路由面,明确以 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui` 为 canonical surface,并把 `/`、`/ui` 保留为 UI 别名。
46+- 已将 `build` 改为真实 `tsc` 发射,确认生成 `apps/status-api/dist/**` 产物,并用构建后的运行时代码冒烟验证三条 GET 路由。
47
48 ## risks
49
50-- 待填写
51+- 默认运行时仍使用 `StaticStatusSnapshotLoader`;真正接入 D1 或本地控制平面数据库仍需由后续整合步骤注入 `D1StatusSnapshotLoader`。
52+- 当前 dist 入口路径受 `rootDir: ../..` 影响为 `dist/apps/status-api/src/*.js`;如果后续统一构建任务收敛到平铺的 `dist/index.js`,需要同步调整 `package.json` 的 `main`/`exports`。
53+- fetch 运行时假设宿主环境提供标准 Fetch API;若后续必须在更旧的 Node 版本运行,需要额外补 polyfill 或改为 node server adapter。
54
55 ## next_handoff
56
57-- 待填写
58+- 在 status-api 的宿主进程中注入真实 `D1StatusSnapshotLoader`,把 `createStatusApiRuntime()` 挂到本地 HTTP server、launchd 进程或上层 router。
59+- 若仓库后续统一 dist 布局,顺手把 `@baa-conductor/status-api` 的导出路径更新到新的 build 产物位置。
60
61 ## notes
62
63 - `2026-03-21`: 创建第三波任务卡
64-
65+- `2026-03-22`: 从 `main@c5e007b` 建立独立 worktree,补齐 status-api fetch 运行时入口与 canonical route surface,完成验证并进入 review。
+4,
-1
1@@ -2,7 +2,9 @@
2
3 本目录定义 `mini` 与 `mac` 上的本地 runtime 约定:目录布局、环境变量和 `launchd` 安装方式。
4
5-当前仓库里的 app 仍是骨架实现,`pnpm build` 只做 TypeScript 校验,不生成 `apps/*/dist/index.js`。因此 `ops/launchd/*.plist` 现在是部署模板,先把路径和环境约定固定下来,等真实产物接上后继续沿用同一套安装步骤。
6+当前仓库已经把 app 级 `build` 从单纯 typecheck 推进到真实 emit。执行 `npx --yes pnpm -r build` 后,`apps/*/dist/index.js` 会生成,`ops/launchd/*.plist` 里的入口路径也因此固定下来。
7+
8+这仍然不代表所有长期服务都已经完成运行时接线。当前阶段解决的是“产物路径存在且一致”,而不是“所有 daemon/worker 逻辑都已可直接上线”。
9
10 ## 内容
11
12@@ -22,3 +24,4 @@
13 1. 先按 [`layout.md`](./layout.md) 初始化 runtime 根目录。
14 2. 再按 [`environment.md`](./environment.md) 准备共享变量和节点变量,特别是 `BAA_SHARED_TOKEN`。
15 3. 最后按 [`launchd.md`](./launchd.md) 复制、调整并加载 plist。
16+4. 每次准备加载或重载本地服务前,先在 repo 根目录执行一次 `npx --yes pnpm -r build`,确认目标 app 的 `dist/index.js` 已更新。
+9,
-1
1@@ -150,4 +150,12 @@ tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-conduct
2 tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-worker-runner.err.log
3 ```
4
5-当前仓库还没有真实的 `dist/index.js` 产物,因此真正执行 `bootstrap` 后,服务进程仍可能因为入口文件不存在而启动失败。这不影响本任务交付的部署约定;后续只要服务构建产物落到约定路径,安装方式不需要再改。
6+当前仓库已经能为 app 生成基础 `dist/index.js` 产物,因此 launchd 不再依赖“未来某天才会出现的入口文件”。在执行 `launchctl bootstrap` 之前,先在 repo 根目录跑一次:
7+
8+```bash
9+npx --yes pnpm -r build
10+```
11+
12+这样可以确保 plist 指向的 `apps/*/dist/index.js` 已刷新到最新代码。
13+
14+需要注意的是,第三波后续任务还会继续补各服务的真正运行时接线,所以“入口文件存在”不等于“业务逻辑已经全部落地”。这里解决的是部署路径与构建产物的一致性。
+1,
-1
1@@ -4,6 +4,7 @@
2 "packageManager": "pnpm@10.6.0",
3 "scripts": {
4 "build": "pnpm -r build",
5+ "build:runtime-postprocess": "node --input-type=module -e \"import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const distDir = process.env.BAA_DIST_DIR; const entry = process.env.BAA_DIST_ENTRY; if (!distDir || !entry) { throw new Error('BAA_DIST_DIR and BAA_DIST_ENTRY are required.'); } const aliasPairs = (process.env.BAA_IMPORT_ALIASES ?? '').split(';').filter(Boolean).map((pair) => { const separatorIndex = pair.indexOf('='); if (separatorIndex <= 0) { throw new Error('Invalid alias pair: ' + pair); } return [pair.slice(0, separatorIndex), pair.slice(separatorIndex + 1)]; }); const fixRelativeExtensions = process.env.BAA_FIX_RELATIVE_EXTENSIONS === 'true'; const jsFiles = []; const collect = (directory) => { for (const entryName of readdirSync(directory)) { const entryPath = join(directory, entryName); const stats = statSync(entryPath); if (stats.isDirectory()) { collect(entryPath); continue; } if (entryPath.endsWith('.js')) { jsFiles.push(entryPath); } } }; collect(distDir); for (const filePath of jsFiles) { let source = readFileSync(filePath, 'utf8'); for (const [from, to] of aliasPairs) { source = source.replaceAll('\\\"' + from + '\\\"', '\\\"' + to + '\\\"').replaceAll(\\\"'\\\" + from + \\\"'\\\", \\\"'\\\" + to + \\\"'\\\"); } if (fixRelativeExtensions) { source = source.replace(/((?:from|import)\\s*[\\\"'])(\\.\\.?\\/[^\\\"'()]+?)([\\\"'])/g, (match, prefix, specifier, suffix) => /\\.[cm]?[jt]sx?$/.test(specifier) || specifier.endsWith('.json') ? match : prefix + specifier + '.js' + suffix); } writeFileSync(filePath, source); } mkdirSync(distDir, { recursive: true }); const entrySpecifier = './' + entry; const shimLines = ['export * from ' + JSON.stringify(entrySpecifier) + ';']; if (process.env.BAA_EXPORT_DEFAULT === 'true') { shimLines.push('export { default } from ' + JSON.stringify(entrySpecifier) + ';'); } writeFileSync(join(distDir, 'index.js'), shimLines.join('\\n') + '\\n');\"",
6 "typecheck": "pnpm -r typecheck",
7 "lint": "echo 'TODO: add linting'",
8 "test": "echo 'TODO: add tests'",
9@@ -13,4 +14,3 @@
10 "typescript": "^5.8.2"
11 }
12 }
13-
+23,
-0
1@@ -1,3 +1,5 @@
2+import { mkdir, writeFile } from "node:fs/promises";
3+
4 export type CheckpointType = "summary" | "git_diff" | "log_tail" | "test_output";
5 export type CheckpointFileFormat = "json" | "text" | "patch";
6 export type CheckpointReplayStrategy = "none" | "git_apply_binary";
7@@ -268,6 +270,16 @@ function renderTextContent(contentText?: string): string {
8 return contentText.endsWith("\n") ? contentText : `${contentText}\n`;
9 }
10
11+function getParentDirectory(filePath: string): string {
12+ const lastSlashIndex = filePath.lastIndexOf("/");
13+
14+ if (lastSlashIndex <= 0) {
15+ return ".";
16+ }
17+
18+ return filePath.slice(0, lastSlashIndex);
19+}
20+
21 export function createCheckpointFilename(
22 seq: number,
23 checkpointTypeOrSuffix: CheckpointType | string
24@@ -382,6 +394,17 @@ export function renderCheckpointFile(record: CheckpointRecord): RenderedCheckpoi
25 };
26 }
27
28+export async function persistCheckpointRecord(
29+ record: CheckpointRecord
30+): Promise<RenderedCheckpointFile> {
31+ const rendered = renderCheckpointFile(record);
32+
33+ await mkdir(getParentDirectory(rendered.storagePath), { recursive: true });
34+ await writeFile(rendered.storagePath, rendered.content, "utf8");
35+
36+ return rendered;
37+}
38+
39 export function createCheckpointManager(input: CreateCheckpointManagerInput): CheckpointManager {
40 const state = createCheckpointManagerState(input);
41
+23,
-0
1@@ -0,0 +1,23 @@
2+declare module "node:fs/promises" {
3+ export interface FileOperationOptions {
4+ encoding?: string;
5+ mode?: number;
6+ flag?: string;
7+ }
8+
9+ export interface MakeDirectoryOptions {
10+ recursive?: boolean;
11+ mode?: number;
12+ }
13+
14+ export function mkdir(
15+ path: string,
16+ options?: MakeDirectoryOptions
17+ ): Promise<string | undefined>;
18+
19+ export function writeFile(
20+ path: string,
21+ data: string,
22+ options?: FileOperationOptions | string
23+ ): Promise<void>;
24+}
+1,
-0
1@@ -1,4 +1,5 @@
2 export * from "./contracts";
3 export * from "./paths";
4+export * from "./persistence";
5 export * from "./session";
6 export * from "./state";
+29,
-0
1@@ -0,0 +1,29 @@
2+declare module "node:fs/promises" {
3+ export interface FileOperationOptions {
4+ encoding?: string;
5+ mode?: number;
6+ flag?: string;
7+ }
8+
9+ export interface MakeDirectoryOptions {
10+ recursive?: boolean;
11+ mode?: number;
12+ }
13+
14+ export function mkdir(
15+ path: string,
16+ options?: MakeDirectoryOptions
17+ ): Promise<string | undefined>;
18+
19+ export function writeFile(
20+ path: string,
21+ data: string,
22+ options?: FileOperationOptions | string
23+ ): Promise<void>;
24+
25+ export function appendFile(
26+ path: string,
27+ data: string,
28+ options?: FileOperationOptions | string
29+ ): Promise<void>;
30+}
+76,
-0
1@@ -0,0 +1,76 @@
2+import { appendFile, mkdir, writeFile } from "node:fs/promises";
3+import type {
4+ LocalRunLogSession,
5+ LocalRunPaths,
6+ RunMetadata,
7+ RunStateSnapshot,
8+ StreamChunkLogEntry,
9+ WorkerLifecycleLogEntry
10+} from "./contracts";
11+
12+function renderJsonFile(value: unknown): string {
13+ return `${JSON.stringify(value, null, 2)}\n`;
14+}
15+
16+function ensureTrailingNewline(value: string): string {
17+ if (value === "" || value.endsWith("\n")) {
18+ return value;
19+ }
20+
21+ return `${value}\n`;
22+}
23+
24+export async function ensureLocalRunLayout(paths: LocalRunPaths): Promise<void> {
25+ await Promise.all([
26+ mkdir(paths.taskRunsDir, { recursive: true }),
27+ mkdir(paths.runDir, { recursive: true }),
28+ mkdir(paths.checkpointsDir, { recursive: true }),
29+ mkdir(paths.artifactsDir, { recursive: true })
30+ ]);
31+}
32+
33+export async function writeRunMetadata(
34+ paths: LocalRunPaths,
35+ metadata: RunMetadata
36+): Promise<void> {
37+ await writeFile(paths.metaPath, renderJsonFile(metadata), "utf8");
38+}
39+
40+export async function writeRunState(
41+ paths: LocalRunPaths,
42+ state: RunStateSnapshot
43+): Promise<void> {
44+ await writeFile(paths.statePath, renderJsonFile(state), "utf8");
45+}
46+
47+export async function initializeLocalRunFiles(
48+ paths: LocalRunPaths,
49+ metadata: RunMetadata,
50+ state: RunStateSnapshot
51+): Promise<void> {
52+ await ensureLocalRunLayout(paths);
53+ await Promise.all([
54+ writeRunMetadata(paths, metadata),
55+ writeRunState(paths, state),
56+ writeFile(paths.workerLogPath, "", "utf8"),
57+ writeFile(paths.stdoutLogPath, "", "utf8"),
58+ writeFile(paths.stderrLogPath, "", "utf8")
59+ ]);
60+}
61+
62+export async function appendLifecycleEntry(
63+ session: LocalRunLogSession,
64+ entry: WorkerLifecycleLogEntry
65+): Promise<void> {
66+ await appendFile(session.worker.filePath, ensureTrailingNewline(entry.renderedLine), "utf8");
67+}
68+
69+export async function appendStreamEntry(
70+ session: LocalRunLogSession,
71+ entry: StreamChunkLogEntry
72+): Promise<void> {
73+ const filePath =
74+ entry.channel === "stdout" ? session.stdout.filePath : session.stderr.filePath;
75+
76+ await appendFile(filePath, ensureTrailingNewline(entry.text), "utf8");
77+}