- commit
- ac0514a
- parent
- 9b1a54c
- author
- im_wower
- date
- 2026-03-22 17:35:27 +0800 CST
feat(conductor-daemon): serve local control API
15 files changed,
+2312,
-238
+15,
-3
1@@ -34,6 +34,7 @@ packages/
2 checkpointing/
3 db/
4 git-tools/
5+ host-ops/
6 logging/
7 planner/
8 schemas/
9@@ -70,14 +71,15 @@ docs/
10
11 | 面 | 地址 | 定位 | 说明 |
12 | --- | --- | --- | --- |
13-| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已提供 `healthz` / `readyz` / `rolez` / `v1/runtime`,后续业务接口继续并到这里 |
14+| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs` 和 `pause/resume/drain` |
15 | public host | `https://conductor.makefile.so` | 唯一公网域名 | 由 VPS Nginx 回源到 `100.71.210.78:4317` |
16 | local status view | `http://100.71.210.78:4318` | 本地只读观察面 | 迁移期保留,不是主控制面 |
17
18 legacy 兼容说明:
19
20 - `https://control-api.makefile.so` 只用于迁移期间兜底和识别残留依赖
21-- Cloudflare Worker / D1 以及 `apps/control-api-worker` 在 cutover 完成后进入删旧范围
22+- 业务查询和系统控制写入已经不再依赖 Cloudflare Worker / D1 真相源
23+- `apps/control-api-worker` 在 cutover 完成后进入删旧范围
24
25 ## 迁移顺序
26
27@@ -95,4 +97,14 @@ legacy 兼容说明:
28 ## 当前已知 gap
29
30 - `status-api`、launchd 模板和部分运行脚本仍引用 `BAA_CONTROL_API_BASE`
31-- `conductor.makefile.so` 当前只直通 `4317` 的既有接口,业务 API 还需要后续任务并入口
32+- `status-api` 还需要完全切到 `conductor-daemon local-api /v1/system/state`
33+
34+## 本机能力层
35+
36+[`packages/host-ops`](./packages/host-ops) 现在提供本机能力层基础包,包含:
37+
38+- `exec`
39+- `files/read`
40+- `files/write`
41+
42+这个包只负责本地 Node 能力、输入输出合同和结构化错误;当前还没有接到 `conductor-daemon` 或公开 HTTP 路由,后续 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 会基于它继续接。
+7,
-4
1@@ -3,11 +3,14 @@
2 "private": true,
3 "type": "module",
4 "main": "dist/index.js",
5+ "dependencies": {
6+ "@baa-conductor/db": "workspace:*"
7+ },
8 "scripts": {
9- "build": "pnpm exec tsc -p tsconfig.json",
10- "dev": "node --experimental-strip-types src/index.ts",
11+ "build": "pnpm -C ../.. -F @baa-conductor/db build && pnpm exec tsc -p tsconfig.json",
12+ "dev": "pnpm run build && node dist/index.js",
13 "start": "node dist/index.js",
14- "test": "node --test --experimental-strip-types src/index.test.js",
15- "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
16+ "test": "pnpm run build && node --test src/index.test.js",
17+ "typecheck": "pnpm -C ../.. -F @baa-conductor/db build && pnpm exec tsc --noEmit -p tsconfig.json"
18 }
19 }
+52,
-0
1@@ -0,0 +1,52 @@
2+export interface ConductorHttpRequest {
3+ body?: string | null;
4+ headers?: Record<string, string | undefined>;
5+ method: string;
6+ path: string;
7+}
8+
9+export interface ConductorHttpResponse {
10+ body: string;
11+ headers: Record<string, string>;
12+ status: number;
13+}
14+
15+export const JSON_RESPONSE_HEADERS = {
16+ "cache-control": "no-store",
17+ "content-type": "application/json; charset=utf-8"
18+} as const;
19+
20+export const TEXT_RESPONSE_HEADERS = {
21+ "cache-control": "no-store",
22+ "content-type": "text/plain; charset=utf-8"
23+} as const;
24+
25+export function jsonResponse(
26+ status: number,
27+ payload: unknown,
28+ extraHeaders: Record<string, string> = {}
29+): ConductorHttpResponse {
30+ return {
31+ status,
32+ headers: {
33+ ...JSON_RESPONSE_HEADERS,
34+ ...extraHeaders
35+ },
36+ body: `${JSON.stringify(payload, null, 2)}\n`
37+ };
38+}
39+
40+export function textResponse(
41+ status: number,
42+ body: string,
43+ extraHeaders: Record<string, string> = {}
44+): ConductorHttpResponse {
45+ return {
46+ status,
47+ headers: {
48+ ...TEXT_RESPONSE_HEADERS,
49+ ...extraHeaders
50+ },
51+ body: `${body}\n`
52+ };
53+}
+384,
-46
1@@ -1,13 +1,17 @@
2 import assert from "node:assert/strict";
3+import { mkdtempSync, rmSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6 import test from "node:test";
7
8+import { ConductorLocalControlPlane } from "../dist/local-control-plane.js";
9 import {
10 ConductorDaemon,
11 ConductorRuntime,
12 createFetchControlApiClient,
13 handleConductorHttpRequest,
14 parseConductorCliRequest
15-} from "./index.ts";
16+} from "../dist/index.js";
17
18 function createLeaseResult({
19 holderId,
20@@ -39,6 +43,178 @@ function createLeaseResult({
21 };
22 }
23
24+async function createLocalApiFixture() {
25+ const controlPlane = new ConductorLocalControlPlane({
26+ databasePath: ":memory:"
27+ });
28+ await controlPlane.initialize();
29+
30+ const repository = controlPlane.repository;
31+ const now = 100;
32+ const lease = await repository.acquireLeaderLease({
33+ controllerId: "mini-main",
34+ host: "mini",
35+ preferred: true,
36+ ttlSec: 30,
37+ now
38+ });
39+
40+ await repository.heartbeatController({
41+ controllerId: "mini-main",
42+ heartbeatAt: now,
43+ host: "mini",
44+ priority: 100,
45+ role: "primary",
46+ startedAt: now,
47+ status: "alive",
48+ version: "1.2.3"
49+ });
50+
51+ await repository.upsertWorker({
52+ workerId: "worker-shell-1",
53+ controllerId: "mini-main",
54+ host: "mini",
55+ workerType: "shell",
56+ status: "idle",
57+ maxParallelism: 1,
58+ currentLoad: 0,
59+ lastHeartbeatAt: now,
60+ capabilitiesJson: JSON.stringify({
61+ kinds: ["shell"]
62+ }),
63+ metadataJson: null
64+ });
65+
66+ await repository.insertTask({
67+ acceptanceJson: JSON.stringify(["returns local data"]),
68+ assignedControllerId: "mini-main",
69+ baseRef: "main",
70+ branchName: "feat/demo-task",
71+ constraintsJson: JSON.stringify({
72+ target_host: "mini"
73+ }),
74+ createdAt: now,
75+ currentStepIndex: 0,
76+ errorText: null,
77+ finishedAt: null,
78+ goal: "Validate local API reads",
79+ metadataJson: JSON.stringify({
80+ requested_by: "test"
81+ }),
82+ plannerProvider: "manual",
83+ planningStrategy: "single_step",
84+ priority: 50,
85+ repo: "/Users/george/code/baa-conductor",
86+ resultJson: null,
87+ resultSummary: null,
88+ source: "local_test",
89+ startedAt: now,
90+ status: "running",
91+ targetHost: "mini",
92+ taskId: "task_demo",
93+ taskType: "shell",
94+ title: "Demo task",
95+ updatedAt: now
96+ });
97+
98+ await repository.insertTaskStep({
99+ stepId: "step_demo",
100+ taskId: "task_demo",
101+ stepIndex: 0,
102+ stepName: "Run local smoke",
103+ stepKind: "shell",
104+ status: "running",
105+ assignedWorkerId: "worker-shell-1",
106+ assignedControllerId: "mini-main",
107+ timeoutSec: 300,
108+ retryLimit: 0,
109+ retryCount: 0,
110+ leaseExpiresAt: now + 30,
111+ inputJson: JSON.stringify({
112+ cmd: "echo hi"
113+ }),
114+ outputJson: null,
115+ summary: null,
116+ errorText: null,
117+ createdAt: now,
118+ updatedAt: now,
119+ startedAt: now,
120+ finishedAt: null
121+ });
122+
123+ await repository.insertTaskRun({
124+ runId: "run_demo",
125+ taskId: "task_demo",
126+ stepId: "step_demo",
127+ workerId: "worker-shell-1",
128+ controllerId: "mini-main",
129+ host: "mini",
130+ pid: 4321,
131+ status: "running",
132+ leaseExpiresAt: now + 30,
133+ heartbeatAt: now,
134+ logDir: "/tmp/demo-run",
135+ stdoutPath: "/tmp/demo-run/stdout.log",
136+ stderrPath: null,
137+ workerLogPath: "/tmp/demo-run/worker.log",
138+ checkpointSeq: 1,
139+ exitCode: null,
140+ resultJson: null,
141+ errorText: null,
142+ createdAt: now,
143+ startedAt: now,
144+ finishedAt: null
145+ });
146+
147+ await repository.appendTaskLog({
148+ taskId: "task_demo",
149+ stepId: "step_demo",
150+ runId: "run_demo",
151+ seq: 1,
152+ stream: "stdout",
153+ level: "info",
154+ message: "hello from local api",
155+ createdAt: now
156+ });
157+
158+ const snapshot = {
159+ controlApi: {
160+ baseUrl: "https://control.example.test",
161+ hasSharedToken: false,
162+ localApiBase: "http://127.0.0.1:4317",
163+ usesPlaceholderToken: false
164+ },
165+ daemon: {
166+ currentLeaderId: lease.holderId,
167+ currentTerm: lease.term,
168+ host: "mini",
169+ lastError: null,
170+ leaseExpiresAt: lease.leaseExpiresAt,
171+ leaseState: "leader",
172+ nodeId: "mini-main",
173+ role: "primary",
174+ schedulerEnabled: true
175+ },
176+ identity: "mini-main@mini(primary)",
177+ runtime: {
178+ pid: 123,
179+ started: true,
180+ startedAt: now
181+ },
182+ warnings: []
183+ };
184+
185+ return {
186+ controlPlane,
187+ repository,
188+ snapshot
189+ };
190+}
191+
192+function parseJsonBody(response) {
193+ return JSON.parse(response.body);
194+}
195+
196 test("start enters leader state and allows scheduler work only for the lease holder", async () => {
197 const heartbeatRequests = [];
198 const leaseRequests = [];
199@@ -333,7 +509,7 @@ test("parseConductorCliRequest rejects unlisted or non-Tailscale local API hosts
200 );
201 });
202
203-test("handleConductorHttpRequest keeps degraded runtimes observable but not ready", () => {
204+test("handleConductorHttpRequest keeps degraded runtimes observable but not ready", async () => {
205 const snapshot = {
206 daemon: {
207 nodeId: "mini-main",
208@@ -351,17 +527,6 @@ test("handleConductorHttpRequest keeps degraded runtimes observable but not read
209 lastError: "lease endpoint timeout"
210 },
211 identity: "mini-main@mini(primary)",
212- loops: {
213- heartbeat: false,
214- lease: false
215- },
216- paths: {
217- logsDir: null,
218- runsDir: null,
219- stateDir: null,
220- tmpDir: null,
221- worktreesDir: null
222- },
223 controlApi: {
224 baseUrl: "https://control.example.test",
225 localApiBase: "http://127.0.0.1:4317",
226@@ -377,28 +542,181 @@ test("handleConductorHttpRequest keeps degraded runtimes observable but not read
227 warnings: []
228 };
229
230- const readyResponse = handleConductorHttpRequest(
231+ const readyResponse = await handleConductorHttpRequest(
232 {
233 method: "GET",
234 path: "/readyz"
235 },
236- () => snapshot
237+ {
238+ repository: null,
239+ snapshotLoader: () => snapshot
240+ }
241 );
242 assert.equal(readyResponse.status, 503);
243 assert.equal(readyResponse.body, "not_ready\n");
244
245- const roleResponse = handleConductorHttpRequest(
246+ const roleResponse = await handleConductorHttpRequest(
247 {
248 method: "GET",
249 path: "/rolez"
250 },
251- () => snapshot
252+ {
253+ repository: null,
254+ snapshotLoader: () => snapshot
255+ }
256 );
257 assert.equal(roleResponse.status, 200);
258 assert.equal(roleResponse.body, "standby\n");
259 });
260
261-test("ConductorRuntime serves health and runtime probes over the local HTTP endpoint", async () => {
262+test("handleConductorHttpRequest serves the migrated local business endpoints from the local repository", async () => {
263+ const { repository, snapshot } = await createLocalApiFixture();
264+
265+ const describeResponse = await handleConductorHttpRequest(
266+ {
267+ method: "GET",
268+ path: "/describe"
269+ },
270+ {
271+ repository,
272+ snapshotLoader: () => snapshot,
273+ version: "1.2.3"
274+ }
275+ );
276+ assert.equal(describeResponse.status, 200);
277+ const describePayload = parseJsonBody(describeResponse);
278+ assert.equal(describePayload.ok, true);
279+ assert.equal(describePayload.data.name, "baa-conductor-daemon");
280+ assert.equal(describePayload.data.system.mode, "running");
281+
282+ const healthResponse = await handleConductorHttpRequest(
283+ {
284+ method: "GET",
285+ path: "/health"
286+ },
287+ {
288+ repository,
289+ snapshotLoader: () => snapshot,
290+ version: "1.2.3"
291+ }
292+ );
293+ assert.equal(healthResponse.status, 200);
294+ assert.equal(parseJsonBody(healthResponse).data.status, "ok");
295+
296+ const controllersResponse = await handleConductorHttpRequest(
297+ {
298+ method: "GET",
299+ path: "/v1/controllers?limit=5"
300+ },
301+ {
302+ repository,
303+ snapshotLoader: () => snapshot
304+ }
305+ );
306+ const controllersPayload = parseJsonBody(controllersResponse);
307+ assert.equal(controllersPayload.data.count, 1);
308+ assert.equal(controllersPayload.data.controllers[0].controller_id, "mini-main");
309+ assert.equal(controllersPayload.data.controllers[0].is_leader, true);
310+
311+ const tasksResponse = await handleConductorHttpRequest(
312+ {
313+ method: "GET",
314+ path: "/v1/tasks?status=running&limit=5"
315+ },
316+ {
317+ repository,
318+ snapshotLoader: () => snapshot
319+ }
320+ );
321+ const tasksPayload = parseJsonBody(tasksResponse);
322+ assert.equal(tasksPayload.data.count, 1);
323+ assert.equal(tasksPayload.data.tasks[0].task_id, "task_demo");
324+
325+ const taskResponse = await handleConductorHttpRequest(
326+ {
327+ method: "GET",
328+ path: "/v1/tasks/task_demo"
329+ },
330+ {
331+ repository,
332+ snapshotLoader: () => snapshot
333+ }
334+ );
335+ assert.equal(parseJsonBody(taskResponse).data.task_id, "task_demo");
336+
337+ const taskLogsResponse = await handleConductorHttpRequest(
338+ {
339+ method: "GET",
340+ path: "/v1/tasks/task_demo/logs?limit=10"
341+ },
342+ {
343+ repository,
344+ snapshotLoader: () => snapshot
345+ }
346+ );
347+ const taskLogsPayload = parseJsonBody(taskLogsResponse);
348+ assert.equal(taskLogsPayload.data.task_id, "task_demo");
349+ assert.equal(taskLogsPayload.data.entries.length, 1);
350+ assert.equal(taskLogsPayload.data.entries[0].message, "hello from local api");
351+
352+ const runsResponse = await handleConductorHttpRequest(
353+ {
354+ method: "GET",
355+ path: "/v1/runs?limit=5"
356+ },
357+ {
358+ repository,
359+ snapshotLoader: () => snapshot
360+ }
361+ );
362+ const runsPayload = parseJsonBody(runsResponse);
363+ assert.equal(runsPayload.data.count, 1);
364+ assert.equal(runsPayload.data.runs[0].run_id, "run_demo");
365+
366+ const runResponse = await handleConductorHttpRequest(
367+ {
368+ method: "GET",
369+ path: "/v1/runs/run_demo"
370+ },
371+ {
372+ repository,
373+ snapshotLoader: () => snapshot
374+ }
375+ );
376+ assert.equal(parseJsonBody(runResponse).data.run_id, "run_demo");
377+
378+ const pauseResponse = await handleConductorHttpRequest(
379+ {
380+ body: JSON.stringify({
381+ reason: "human_clicked_pause",
382+ requested_by: "test"
383+ }),
384+ method: "POST",
385+ path: "/v1/system/pause"
386+ },
387+ {
388+ repository,
389+ snapshotLoader: () => snapshot
390+ }
391+ );
392+ assert.equal(pauseResponse.status, 200);
393+ assert.equal((await repository.getAutomationState())?.mode, "paused");
394+
395+ const systemStateResponse = await handleConductorHttpRequest(
396+ {
397+ method: "GET",
398+ path: "/v1/system/state"
399+ },
400+ {
401+ repository,
402+ snapshotLoader: () => snapshot
403+ }
404+ );
405+ assert.equal(parseJsonBody(systemStateResponse).data.mode, "paused");
406+});
407+
408+test("ConductorRuntime serves health and migrated local API endpoints over HTTP", async () => {
409+ const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-"));
410 const runtime = new ConductorRuntime(
411 {
412 nodeId: "mini-main",
413@@ -408,24 +726,12 @@ test("ConductorRuntime serves health and runtime probes over the local HTTP endp
414 localApiBase: "http://127.0.0.1:0",
415 sharedToken: "replace-me",
416 paths: {
417- runsDir: "/tmp/runs"
418+ runsDir: "/tmp/runs",
419+ stateDir
420 }
421 },
422 {
423 autoStartLoops: false,
424- client: {
425- async acquireLeaderLease() {
426- return createLeaseResult({
427- holderId: "mini-main",
428- term: 2,
429- leaseExpiresAt: 130,
430- renewedAt: 100,
431- isLeader: true,
432- operation: "acquire"
433- });
434- },
435- async sendControllerHeartbeat() {}
436- },
437 now: () => 100
438 }
439 );
440@@ -440,6 +746,12 @@ test("ConductorRuntime serves health and runtime probes over the local HTTP endp
441 assert.equal(healthResponse.status, 200);
442 assert.equal(await healthResponse.text(), "ok\n");
443
444+ const apiHealthResponse = await fetch(`${baseUrl}/health`);
445+ assert.equal(apiHealthResponse.status, 200);
446+ const apiHealthPayload = await apiHealthResponse.json();
447+ assert.equal(apiHealthPayload.ok, true);
448+ assert.equal(apiHealthPayload.data.status, "ok");
449+
450 const readyResponse = await fetch(`${baseUrl}/readyz`);
451 assert.equal(readyResponse.status, 200);
452 assert.equal(await readyResponse.text(), "ready\n");
453@@ -456,11 +768,45 @@ test("ConductorRuntime serves health and runtime probes over the local HTTP endp
454 assert.equal(payload.data.controlApi.localApiBase, baseUrl);
455 assert.equal(payload.data.runtime.started, true);
456
457+ const systemStateResponse = await fetch(`${baseUrl}/v1/system/state`);
458+ assert.equal(systemStateResponse.status, 200);
459+ const systemStatePayload = await systemStateResponse.json();
460+ assert.equal(systemStatePayload.ok, true);
461+ assert.equal(systemStatePayload.data.holder_id, "mini-main");
462+ assert.equal(systemStatePayload.data.mode, "running");
463+
464+ const pauseResponse = await fetch(`${baseUrl}/v1/system/pause`, {
465+ method: "POST",
466+ headers: {
467+ "content-type": "application/json"
468+ },
469+ body: JSON.stringify({
470+ requested_by: "integration_test",
471+ reason: "pause_for_verification"
472+ })
473+ });
474+ assert.equal(pauseResponse.status, 200);
475+
476+ const pausedStateResponse = await fetch(`${baseUrl}/v1/system/state`);
477+ const pausedStatePayload = await pausedStateResponse.json();
478+ assert.equal(pausedStatePayload.data.mode, "paused");
479+
480+ const describeResponse = await fetch(`${baseUrl}/describe`);
481+ assert.equal(describeResponse.status, 200);
482+ const describePayload = await describeResponse.json();
483+ assert.equal(describePayload.ok, true);
484+ assert.equal(describePayload.data.name, "baa-conductor-daemon");
485+
486 const stoppedSnapshot = await runtime.stop();
487 assert.equal(stoppedSnapshot.runtime.started, false);
488+ rmSync(stateDir, {
489+ force: true,
490+ recursive: true
491+ });
492 });
493
494 test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status surfaces", async () => {
495+ const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-snapshot-"));
496 const runtime = new ConductorRuntime(
497 {
498 nodeId: "mini-main",
499@@ -470,24 +816,12 @@ test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status sur
500 localApiBase: "http://127.0.0.1:0",
501 sharedToken: "replace-me",
502 paths: {
503- runsDir: "/tmp/runs"
504+ runsDir: "/tmp/runs",
505+ stateDir
506 }
507 },
508 {
509 autoStartLoops: false,
510- client: {
511- async acquireLeaderLease() {
512- return createLeaseResult({
513- holderId: "mini-main",
514- term: 2,
515- leaseExpiresAt: 130,
516- renewedAt: 100,
517- isLeader: true,
518- operation: "acquire"
519- });
520- },
521- async sendControllerHeartbeat() {}
522- },
523 now: () => 100
524 }
525 );
526@@ -505,4 +839,8 @@ test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status sur
527
528 const stoppedSnapshot = await runtime.stop();
529 assert.equal(stoppedSnapshot.runtime.started, false);
530+ rmSync(stateDir, {
531+ force: true,
532+ recursive: true
533+ });
534 });
+105,
-113
1@@ -5,6 +5,17 @@ import {
2 type ServerResponse
3 } from "node:http";
4 import type { AddressInfo } from "node:net";
5+import type { ControlPlaneRepository } from "../../../packages/db/dist/index.js";
6+
7+import {
8+ type ConductorHttpRequest,
9+ type ConductorHttpResponse
10+} from "./http-types.js";
11+import { handleConductorHttpRequest as handleConductorLocalHttpRequest } from "./local-api.js";
12+import { ConductorLocalControlPlane } from "./local-control-plane.js";
13+
14+export type { ConductorHttpRequest, ConductorHttpResponse } from "./http-types.js";
15+export { handleConductorHttpRequest } from "./local-api.js";
16
17 export type ConductorRole = "primary" | "standby";
18 export type ConductorLeadershipRole = "leader" | "standby";
19@@ -18,14 +29,6 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
20 const DEFAULT_LEASE_RENEW_INTERVAL_MS = 5_000;
21 const DEFAULT_LEASE_TTL_SEC = 30;
22 const DEFAULT_RENEW_FAILURE_THRESHOLD = 2;
23-const JSON_RESPONSE_HEADERS = {
24- "cache-control": "no-store",
25- "content-type": "application/json; charset=utf-8"
26-} as const;
27-const TEXT_RESPONSE_HEADERS = {
28- "cache-control": "no-store",
29- "content-type": "text/plain; charset=utf-8"
30-} as const;
31
32 const STARTUP_CHECKLIST: StartupChecklistItem[] = [
33 { key: "register-controller", description: "注册 controller 并写入初始 heartbeat" },
34@@ -166,17 +169,6 @@ export interface ConductorRuntimeSnapshot {
35 warnings: string[];
36 }
37
38-export interface ConductorHttpRequest {
39- method: string;
40- path: string;
41-}
42-
43-export interface ConductorHttpResponse {
44- status: number;
45- headers: Record<string, string>;
46- body: string;
47-}
48-
49 export interface SchedulerContext {
50 controllerId: string;
51 host: string;
52@@ -503,92 +495,6 @@ function resolveLocalApiListenConfig(localApiBase: string): LocalApiListenConfig
53 };
54 }
55
56-function jsonResponse(
57- status: number,
58- payload: unknown,
59- extraHeaders: Record<string, string> = {}
60-): ConductorHttpResponse {
61- return {
62- status,
63- headers: {
64- ...JSON_RESPONSE_HEADERS,
65- ...extraHeaders
66- },
67- body: `${JSON.stringify(payload, null, 2)}\n`
68- };
69-}
70-
71-function textResponse(
72- status: number,
73- body: string,
74- extraHeaders: Record<string, string> = {}
75-): ConductorHttpResponse {
76- return {
77- status,
78- headers: {
79- ...TEXT_RESPONSE_HEADERS,
80- ...extraHeaders
81- },
82- body: `${body}\n`
83- };
84-}
85-
86-function resolveLeadershipRole(snapshot: ConductorRuntimeSnapshot): ConductorLeadershipRole {
87- return snapshot.daemon.leaseState === "leader" ? "leader" : "standby";
88-}
89-
90-function isRuntimeReady(snapshot: ConductorRuntimeSnapshot): boolean {
91- return snapshot.runtime.started && snapshot.daemon.leaseState !== "degraded";
92-}
93-
94-export function handleConductorHttpRequest(
95- request: ConductorHttpRequest,
96- snapshotLoader: () => ConductorRuntimeSnapshot
97-): ConductorHttpResponse {
98- const method = request.method.toUpperCase();
99-
100- if (method !== "GET") {
101- return jsonResponse(
102- 405,
103- {
104- ok: false,
105- error: "method_not_allowed",
106- message: "Conductor local API is read-only and only accepts GET requests."
107- },
108- { Allow: "GET" }
109- );
110- }
111-
112- const path = normalizePath(request.path);
113-
114- switch (path) {
115- case "/healthz":
116- return textResponse(200, "ok");
117-
118- case "/readyz": {
119- const snapshot = snapshotLoader();
120- const ready = isRuntimeReady(snapshot);
121- return textResponse(ready ? 200 : 503, ready ? "ready" : "not_ready");
122- }
123-
124- case "/rolez":
125- return textResponse(200, resolveLeadershipRole(snapshotLoader()));
126-
127- case "/v1/runtime":
128- return jsonResponse(200, {
129- ok: true,
130- data: snapshotLoader()
131- });
132-
133- default:
134- return jsonResponse(404, {
135- ok: false,
136- error: "not_found",
137- message: `No conductor route matches "${path}".`
138- });
139- }
140-}
141-
142 function writeHttpResponse(
143 response: ServerResponse<IncomingMessage>,
144 payload: ConductorHttpResponse
145@@ -602,15 +508,44 @@ function writeHttpResponse(
146 response.end(payload.body);
147 }
148
149+async function readIncomingRequestBody(request: IncomingMessage): Promise<string | null> {
150+ if (request.method == null || request.method.toUpperCase() === "GET") {
151+ return null;
152+ }
153+
154+ return new Promise((resolve, reject) => {
155+ let body = "";
156+ request.setEncoding?.("utf8");
157+ request.on?.("data", (chunk) => {
158+ body += typeof chunk === "string" ? chunk : String(chunk);
159+ });
160+ request.on?.("end", () => {
161+ resolve(body === "" ? null : body);
162+ });
163+ request.on?.("error", (error) => {
164+ reject(error);
165+ });
166+ });
167+}
168+
169 class ConductorLocalHttpServer {
170 private readonly localApiBase: string;
171+ private readonly repository: ControlPlaneRepository;
172 private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
173+ private readonly version: string | null;
174 private resolvedBaseUrl: string;
175 private server: Server | null = null;
176
177- constructor(localApiBase: string, snapshotLoader: () => ConductorRuntimeSnapshot) {
178+ constructor(
179+ localApiBase: string,
180+ repository: ControlPlaneRepository,
181+ snapshotLoader: () => ConductorRuntimeSnapshot,
182+ version: string | null
183+ ) {
184 this.localApiBase = localApiBase;
185+ this.repository = repository;
186 this.snapshotLoader = snapshotLoader;
187+ this.version = version;
188 this.resolvedBaseUrl = localApiBase;
189 }
190
191@@ -625,16 +560,37 @@ class ConductorLocalHttpServer {
192
193 const listenConfig = resolveLocalApiListenConfig(this.localApiBase);
194 const server = createServer((request, response) => {
195- writeHttpResponse(
196- response,
197- handleConductorHttpRequest(
198+ void (async () => {
199+ const payload = await handleConductorLocalHttpRequest(
200 {
201+ body: await readIncomingRequestBody(request),
202 method: request.method ?? "GET",
203 path: request.url ?? "/"
204 },
205- this.snapshotLoader
206- )
207- );
208+ {
209+ repository: this.repository,
210+ snapshotLoader: this.snapshotLoader,
211+ version: this.version
212+ }
213+ );
214+
215+ writeHttpResponse(response, payload);
216+ })().catch((error: unknown) => {
217+ response.statusCode = 500;
218+ response.setHeader("cache-control", "no-store");
219+ response.setHeader("content-type", "application/json; charset=utf-8");
220+ response.end(
221+ `${JSON.stringify(
222+ {
223+ ok: false,
224+ error: "internal_error",
225+ message: toErrorMessage(error)
226+ },
227+ null,
228+ 2
229+ )}\n`
230+ );
231+ });
232 });
233
234 await new Promise<void>((resolve, reject) => {
235@@ -987,6 +943,25 @@ export function createFetchControlApiClient(
236 };
237 }
238
239+export function createRepositoryControlApiClient(
240+ repository: Pick<ControlPlaneRepository, "acquireLeaderLease" | "heartbeatController">,
241+ now: () => number = defaultNowUnixSeconds
242+): ConductorControlApiClient {
243+ return {
244+ async acquireLeaderLease(input: LeaderLeaseAcquireInput): Promise<LeaderLeaseAcquireResult> {
245+ return repository.acquireLeaderLease(input);
246+ },
247+ async sendControllerHeartbeat(input: ControllerHeartbeatInput): Promise<void> {
248+ await repository.heartbeatController({
249+ ...input,
250+ heartbeatAt: input.heartbeatAt ?? now(),
251+ startedAt: input.startedAt ?? null,
252+ version: input.version ?? null
253+ });
254+ }
255+ };
256+}
257+
258 export class ConductorDaemon {
259 private readonly autoStartLoops: boolean;
260 private readonly clearIntervalImpl: (handle: TimerHandle) => void;
261@@ -1741,7 +1716,9 @@ function getUsageText(): string {
262 export class ConductorRuntime {
263 private readonly config: ResolvedConductorRuntimeConfig;
264 private readonly daemon: ConductorDaemon;
265+ private readonly localControlPlane: ConductorLocalControlPlane;
266 private readonly localApiServer: ConductorLocalHttpServer | null;
267+ private localControlPlaneInitialized = false;
268 private readonly now: () => number;
269 private started = false;
270
271@@ -1753,19 +1730,34 @@ export class ConductorRuntime {
272 ...config,
273 startedAt
274 });
275+ this.localControlPlane = new ConductorLocalControlPlane({
276+ stateDir: this.config.paths.stateDir
277+ });
278 this.daemon = new ConductorDaemon(this.config, {
279 ...options,
280+ client:
281+ options.client ?? createRepositoryControlApiClient(this.localControlPlane.repository, this.now),
282 controlApiBearerToken: options.controlApiBearerToken ?? this.config.sharedToken,
283 now: this.now
284 });
285 this.localApiServer =
286 this.config.localApiBase == null
287 ? null
288- : new ConductorLocalHttpServer(this.config.localApiBase, () => this.getRuntimeSnapshot());
289+ : new ConductorLocalHttpServer(
290+ this.config.localApiBase,
291+ this.localControlPlane.repository,
292+ () => this.getRuntimeSnapshot(),
293+ this.config.version
294+ );
295 }
296
297 async start(): Promise<ConductorRuntimeSnapshot> {
298 if (!this.started) {
299+ if (!this.localControlPlaneInitialized) {
300+ await this.localControlPlane.initialize();
301+ this.localControlPlaneInitialized = true;
302+ }
303+
304 await this.daemon.start();
305 this.started = true;
306
+1073,
-0
1@@ -0,0 +1,1073 @@
2+import {
3+ AUTOMATION_STATE_KEY,
4+ DEFAULT_AUTOMATION_MODE,
5+ TASK_STATUS_VALUES,
6+ parseJsonText,
7+ type AutomationMode,
8+ type ControlPlaneRepository,
9+ type ControllerRecord,
10+ type JsonObject,
11+ type JsonValue,
12+ type TaskLogRecord,
13+ type TaskRecord,
14+ type TaskRunRecord,
15+ type TaskStatus
16+} from "../../../packages/db/dist/index.js";
17+
18+import {
19+ jsonResponse,
20+ textResponse,
21+ type ConductorHttpRequest,
22+ type ConductorHttpResponse
23+} from "./http-types.js";
24+
25+const DEFAULT_LIST_LIMIT = 20;
26+const DEFAULT_LOG_LIMIT = 200;
27+const MAX_LIST_LIMIT = 100;
28+const MAX_LOG_LIMIT = 500;
29+const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
30+
31+type LocalApiRouteMethod = "GET" | "POST";
32+type LocalApiRouteKind = "probe" | "read" | "write";
33+
34+interface LocalApiRouteDefinition {
35+ id: string;
36+ exposeInDescribe?: boolean;
37+ kind: LocalApiRouteKind;
38+ method: LocalApiRouteMethod;
39+ pathPattern: string;
40+ summary: string;
41+}
42+
43+interface LocalApiRouteMatch {
44+ params: Record<string, string>;
45+ route: LocalApiRouteDefinition;
46+}
47+
48+interface LocalApiRequestContext {
49+ now: () => number;
50+ params: Record<string, string>;
51+ repository: ControlPlaneRepository | null;
52+ request: ConductorHttpRequest;
53+ requestId: string;
54+ snapshotLoader: () => ConductorRuntimeApiSnapshot;
55+ url: URL;
56+}
57+
58+export interface ConductorRuntimeApiSnapshot {
59+ controlApi: {
60+ baseUrl: string;
61+ hasSharedToken: boolean;
62+ localApiBase: string | null;
63+ usesPlaceholderToken: boolean;
64+ };
65+ daemon: {
66+ currentLeaderId: string | null;
67+ currentTerm: number | null;
68+ host: string;
69+ lastError: string | null;
70+ leaseExpiresAt: number | null;
71+ leaseState: string;
72+ nodeId: string;
73+ role: string;
74+ schedulerEnabled: boolean;
75+ };
76+ identity: string;
77+ runtime: {
78+ pid: number | null;
79+ started: boolean;
80+ startedAt: number;
81+ };
82+ warnings: string[];
83+}
84+
85+export interface ConductorLocalApiContext {
86+ now?: () => number;
87+ repository: ControlPlaneRepository | null;
88+ snapshotLoader: () => ConductorRuntimeApiSnapshot;
89+ version?: string | null;
90+}
91+
92+class LocalApiHttpError extends Error {
93+ constructor(
94+ readonly status: number,
95+ readonly error: string,
96+ message: string,
97+ readonly details?: JsonValue,
98+ readonly headers?: Record<string, string>
99+ ) {
100+ super(message);
101+ }
102+}
103+
104+const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
105+ {
106+ id: "probe.healthz",
107+ exposeInDescribe: false,
108+ kind: "probe",
109+ method: "GET",
110+ pathPattern: "/healthz",
111+ summary: "最小 TCP/HTTP 健康探针"
112+ },
113+ {
114+ id: "probe.readyz",
115+ exposeInDescribe: false,
116+ kind: "probe",
117+ method: "GET",
118+ pathPattern: "/readyz",
119+ summary: "本地 runtime readiness 探针"
120+ },
121+ {
122+ id: "probe.rolez",
123+ exposeInDescribe: false,
124+ kind: "probe",
125+ method: "GET",
126+ pathPattern: "/rolez",
127+ summary: "当前 leader/standby 视图"
128+ },
129+ {
130+ id: "probe.runtime",
131+ exposeInDescribe: false,
132+ kind: "probe",
133+ method: "GET",
134+ pathPattern: "/v1/runtime",
135+ summary: "当前 runtime 快照"
136+ },
137+ {
138+ id: "service.describe",
139+ kind: "read",
140+ method: "GET",
141+ pathPattern: "/describe",
142+ summary: "读取完整自描述 JSON"
143+ },
144+ {
145+ id: "service.health",
146+ kind: "read",
147+ method: "GET",
148+ pathPattern: "/health",
149+ summary: "读取服务健康摘要"
150+ },
151+ {
152+ id: "service.version",
153+ kind: "read",
154+ method: "GET",
155+ pathPattern: "/version",
156+ summary: "读取服务版本"
157+ },
158+ {
159+ id: "system.capabilities",
160+ kind: "read",
161+ method: "GET",
162+ pathPattern: "/v1/capabilities",
163+ summary: "读取能力发现摘要"
164+ },
165+ {
166+ id: "system.state",
167+ kind: "read",
168+ method: "GET",
169+ pathPattern: "/v1/system/state",
170+ summary: "读取本地系统状态"
171+ },
172+ {
173+ id: "system.pause",
174+ kind: "write",
175+ method: "POST",
176+ pathPattern: "/v1/system/pause",
177+ summary: "把 automation 切到 paused"
178+ },
179+ {
180+ id: "system.resume",
181+ kind: "write",
182+ method: "POST",
183+ pathPattern: "/v1/system/resume",
184+ summary: "把 automation 切到 running"
185+ },
186+ {
187+ id: "system.drain",
188+ kind: "write",
189+ method: "POST",
190+ pathPattern: "/v1/system/drain",
191+ summary: "把 automation 切到 draining"
192+ },
193+ {
194+ id: "controllers.list",
195+ kind: "read",
196+ method: "GET",
197+ pathPattern: "/v1/controllers",
198+ summary: "列出本地 controller 摘要"
199+ },
200+ {
201+ id: "tasks.list",
202+ kind: "read",
203+ method: "GET",
204+ pathPattern: "/v1/tasks",
205+ summary: "列出本地 task 摘要"
206+ },
207+ {
208+ id: "tasks.read",
209+ kind: "read",
210+ method: "GET",
211+ pathPattern: "/v1/tasks/:task_id",
212+ summary: "读取单个 task"
213+ },
214+ {
215+ id: "tasks.logs.read",
216+ kind: "read",
217+ method: "GET",
218+ pathPattern: "/v1/tasks/:task_id/logs",
219+ summary: "读取 task 关联日志"
220+ },
221+ {
222+ id: "runs.list",
223+ kind: "read",
224+ method: "GET",
225+ pathPattern: "/v1/runs",
226+ summary: "列出本地 run 摘要"
227+ },
228+ {
229+ id: "runs.read",
230+ kind: "read",
231+ method: "GET",
232+ pathPattern: "/v1/runs/:run_id",
233+ summary: "读取单个 run"
234+ }
235+];
236+
237+function resolveServiceName(): string {
238+ return "baa-conductor-daemon";
239+}
240+
241+function normalizePathname(value: string): string {
242+ const normalized = value.replace(/\/+$/u, "");
243+ return normalized === "" ? "/" : normalized;
244+}
245+
246+function toUnixMilliseconds(value: number | null | undefined): number | null {
247+ if (value == null) {
248+ return null;
249+ }
250+
251+ return value * 1000;
252+}
253+
254+function resolveLeadershipRole(snapshot: ConductorRuntimeApiSnapshot): "leader" | "standby" {
255+ return snapshot.daemon.leaseState === "leader" ? "leader" : "standby";
256+}
257+
258+function isRuntimeReady(snapshot: ConductorRuntimeApiSnapshot): boolean {
259+ return snapshot.runtime.started && snapshot.daemon.leaseState !== "degraded";
260+}
261+
262+function isJsonObject(value: JsonValue | null): value is JsonObject {
263+ return value !== null && typeof value === "object" && !Array.isArray(value);
264+}
265+
266+function buildSuccessEnvelope(
267+ requestId: string,
268+ status: number,
269+ data: JsonValue,
270+ headers: Record<string, string> = {}
271+): ConductorHttpResponse {
272+ return jsonResponse(
273+ status,
274+ {
275+ ok: true,
276+ request_id: requestId,
277+ data
278+ },
279+ headers
280+ );
281+}
282+
283+function buildErrorEnvelope(requestId: string, error: LocalApiHttpError): ConductorHttpResponse {
284+ const payload: JsonObject = {
285+ ok: false,
286+ request_id: requestId,
287+ error: error.error,
288+ message: error.message
289+ };
290+
291+ if (error.details !== undefined) {
292+ payload.details = error.details;
293+ }
294+
295+ return jsonResponse(error.status, payload, error.headers ?? {});
296+}
297+
298+function buildAckResponse(requestId: string, summary: string): ConductorHttpResponse {
299+ return buildSuccessEnvelope(requestId, 200, {
300+ accepted: true,
301+ status: "applied",
302+ summary
303+ });
304+}
305+
306+function readBodyJson(request: ConductorHttpRequest): JsonValue | null {
307+ const rawBody = request.body?.trim() ?? "";
308+
309+ if (rawBody === "") {
310+ return null;
311+ }
312+
313+ try {
314+ return JSON.parse(rawBody) as JsonValue;
315+ } catch {
316+ throw new LocalApiHttpError(400, "invalid_json", "Request body must be valid JSON.");
317+ }
318+}
319+
320+function readBodyObject(request: ConductorHttpRequest, allowNull: boolean = false): JsonObject {
321+ const body = readBodyJson(request);
322+
323+ if (body === null) {
324+ if (allowNull) {
325+ return {};
326+ }
327+
328+ throw new LocalApiHttpError(400, "invalid_request", "Request body must be a JSON object.");
329+ }
330+
331+ if (!isJsonObject(body)) {
332+ throw new LocalApiHttpError(400, "invalid_request", "Request body must be a JSON object.");
333+ }
334+
335+ return body;
336+}
337+
338+function readOptionalStringField(body: JsonObject, fieldName: string): string | undefined {
339+ const value = body[fieldName];
340+
341+ if (value == null) {
342+ return undefined;
343+ }
344+
345+ if (typeof value !== "string") {
346+ throw new LocalApiHttpError(400, "invalid_request", `Field "${fieldName}" must be a string.`, {
347+ field: fieldName
348+ });
349+ }
350+
351+ const normalized = value.trim();
352+ return normalized === "" ? undefined : normalized;
353+}
354+
355+function readPositiveIntegerQuery(
356+ url: URL,
357+ fieldName: string,
358+ defaultValue: number,
359+ maximum: number
360+): number {
361+ const rawValue = url.searchParams.get(fieldName);
362+
363+ if (rawValue == null || rawValue.trim() === "") {
364+ return defaultValue;
365+ }
366+
367+ const numeric = Number(rawValue);
368+
369+ if (!Number.isInteger(numeric) || numeric <= 0) {
370+ throw new LocalApiHttpError(
371+ 400,
372+ "invalid_request",
373+ `Query parameter "${fieldName}" must be a positive integer.`,
374+ {
375+ field: fieldName
376+ }
377+ );
378+ }
379+
380+ if (numeric > maximum) {
381+ throw new LocalApiHttpError(
382+ 400,
383+ "invalid_request",
384+ `Query parameter "${fieldName}" must be less than or equal to ${maximum}.`,
385+ {
386+ field: fieldName,
387+ maximum
388+ }
389+ );
390+ }
391+
392+ return numeric;
393+}
394+
395+function readTaskStatusFilter(url: URL): TaskStatus | undefined {
396+ const rawValue = url.searchParams.get("status");
397+
398+ if (rawValue == null || rawValue.trim() === "") {
399+ return undefined;
400+ }
401+
402+ if (!TASK_STATUS_SET.has(rawValue as TaskStatus)) {
403+ throw new LocalApiHttpError(
404+ 400,
405+ "invalid_request",
406+ `Query parameter "status" must be one of ${TASK_STATUS_VALUES.join(", ")}.`,
407+ {
408+ field: "status",
409+ allowed_values: [...TASK_STATUS_VALUES]
410+ }
411+ );
412+ }
413+
414+ return rawValue as TaskStatus;
415+}
416+
417+function requireRepository(repository: ControlPlaneRepository | null): ControlPlaneRepository {
418+ if (repository == null) {
419+ throw new LocalApiHttpError(
420+ 503,
421+ "repository_not_configured",
422+ "Conductor local API repository is not configured."
423+ );
424+ }
425+
426+ return repository;
427+}
428+
429+function summarizeTask(task: TaskRecord): JsonObject {
430+ return {
431+ task_id: task.taskId,
432+ repo: task.repo,
433+ task_type: task.taskType,
434+ title: task.title,
435+ goal: task.goal,
436+ source: task.source,
437+ priority: task.priority,
438+ status: task.status,
439+ planner_provider: task.plannerProvider,
440+ planning_strategy: task.planningStrategy,
441+ branch_name: task.branchName,
442+ base_ref: task.baseRef,
443+ target_host: task.targetHost,
444+ assigned_controller_id: task.assignedControllerId,
445+ current_step_index: task.currentStepIndex,
446+ result_summary: task.resultSummary,
447+ error_text: task.errorText,
448+ created_at: toUnixMilliseconds(task.createdAt),
449+ updated_at: toUnixMilliseconds(task.updatedAt),
450+ started_at: toUnixMilliseconds(task.startedAt),
451+ finished_at: toUnixMilliseconds(task.finishedAt)
452+ };
453+}
454+
455+function summarizeRun(run: TaskRunRecord): JsonObject {
456+ return {
457+ run_id: run.runId,
458+ task_id: run.taskId,
459+ step_id: run.stepId,
460+ worker_id: run.workerId,
461+ controller_id: run.controllerId,
462+ host: run.host,
463+ status: run.status,
464+ pid: run.pid,
465+ checkpoint_seq: run.checkpointSeq,
466+ exit_code: run.exitCode,
467+ created_at: toUnixMilliseconds(run.createdAt),
468+ started_at: toUnixMilliseconds(run.startedAt),
469+ finished_at: toUnixMilliseconds(run.finishedAt),
470+ heartbeat_at: toUnixMilliseconds(run.heartbeatAt),
471+ lease_expires_at: toUnixMilliseconds(run.leaseExpiresAt)
472+ };
473+}
474+
475+function summarizeController(controller: ControllerRecord, leaderControllerId: string | null): JsonObject {
476+ return {
477+ controller_id: controller.controllerId,
478+ host: controller.host,
479+ role: controller.role,
480+ priority: controller.priority,
481+ status: controller.status,
482+ version: controller.version,
483+ last_heartbeat_at: toUnixMilliseconds(controller.lastHeartbeatAt),
484+ last_started_at: toUnixMilliseconds(controller.lastStartedAt),
485+ is_leader: leaderControllerId != null && leaderControllerId === controller.controllerId
486+ };
487+}
488+
489+function summarizeTaskLog(log: TaskLogRecord): JsonObject {
490+ return {
491+ seq: log.seq,
492+ stream: log.stream,
493+ level: log.level,
494+ message: log.message,
495+ created_at: toUnixMilliseconds(log.createdAt),
496+ step_id: log.stepId
497+ };
498+}
499+
500+function extractAutomationMetadata(valueJson: string | null | undefined): {
501+ mode: AutomationMode | null;
502+ reason: string | null;
503+ requestedBy: string | null;
504+ source: string | null;
505+} {
506+ const payload = parseJsonText<{
507+ mode?: unknown;
508+ reason?: unknown;
509+ requested_by?: unknown;
510+ source?: unknown;
511+ }>(valueJson);
512+
513+ const mode = payload?.mode;
514+
515+ return {
516+ mode: mode === "running" || mode === "draining" || mode === "paused" ? mode : null,
517+ reason: typeof payload?.reason === "string" ? payload.reason : null,
518+ requestedBy: typeof payload?.requested_by === "string" ? payload.requested_by : null,
519+ source: typeof payload?.source === "string" ? payload.source : null
520+ };
521+}
522+
523+async function buildSystemStateData(repository: ControlPlaneRepository): Promise<JsonObject> {
524+ const [automationState, lease, activeRuns, queuedTasks] = await Promise.all([
525+ repository.getAutomationState(),
526+ repository.getCurrentLease(),
527+ repository.countActiveRuns(),
528+ repository.countQueuedTasks()
529+ ]);
530+
531+ const leaderController = lease?.holderId ? await repository.getController(lease.holderId) : null;
532+ const automationMetadata = extractAutomationMetadata(automationState?.valueJson);
533+ const mode = automationMetadata.mode ?? automationState?.mode ?? DEFAULT_AUTOMATION_MODE;
534+ const updatedAt = toUnixMilliseconds(automationState?.updatedAt);
535+ const leaseExpiresAt = toUnixMilliseconds(lease?.leaseExpiresAt);
536+
537+ return {
538+ mode,
539+ updated_at: updatedAt,
540+ holder_id: lease?.holderId ?? null,
541+ holder_host: lease?.holderHost ?? null,
542+ lease_expires_at: leaseExpiresAt,
543+ term: lease?.term ?? null,
544+ automation: {
545+ mode,
546+ updated_at: updatedAt,
547+ requested_by: automationMetadata.requestedBy,
548+ reason: automationMetadata.reason,
549+ source: automationMetadata.source
550+ },
551+ leader: {
552+ controller_id: lease?.holderId ?? null,
553+ host: lease?.holderHost ?? leaderController?.host ?? null,
554+ role: leaderController?.role ?? null,
555+ status: leaderController?.status ?? null,
556+ version: leaderController?.version ?? null,
557+ lease_expires_at: leaseExpiresAt,
558+ term: lease?.term ?? null
559+ },
560+ queue: {
561+ active_runs: activeRuns,
562+ queued_tasks: queuedTasks
563+ }
564+ };
565+}
566+
567+function describeRoute(route: LocalApiRouteDefinition): JsonObject {
568+ return {
569+ id: route.id,
570+ method: route.method,
571+ path: route.pathPattern,
572+ kind: route.kind === "probe" ? "read" : route.kind,
573+ implementation: "implemented",
574+ summary: route.summary,
575+ access: "local_network"
576+ };
577+}
578+
579+function buildCapabilitiesData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
580+ const exposedRoutes = LOCAL_API_ROUTES.filter((route) => route.exposeInDescribe !== false);
581+
582+ return {
583+ deployment_mode: "single-node mini",
584+ auth_mode: "local_network_only",
585+ repository_configured: true,
586+ truth_source: "local sqlite control plane",
587+ workflow: [
588+ "GET /describe",
589+ "GET /v1/capabilities",
590+ "GET /v1/system/state",
591+ "GET /v1/tasks or /v1/runs",
592+ "Use POST system routes only when a write is intended"
593+ ],
594+ read_endpoints: exposedRoutes.filter((route) => route.kind === "read").map(describeRoute),
595+ write_endpoints: exposedRoutes.filter((route) => route.kind === "write").map(describeRoute),
596+ diagnostics: LOCAL_API_ROUTES.filter((route) => route.kind === "probe").map(describeRoute),
597+ runtime: {
598+ identity: snapshot.identity,
599+ lease_state: snapshot.daemon.leaseState,
600+ scheduler_enabled: snapshot.daemon.schedulerEnabled,
601+ started: snapshot.runtime.started
602+ }
603+ };
604+}
605+
606+function buildCurlExample(
607+ origin: string,
608+ route: LocalApiRouteDefinition,
609+ body?: JsonObject
610+): string {
611+ const payload = body
612+ ? ` \\\n -H 'Content-Type: application/json' \\\n -d '${JSON.stringify(body)}'`
613+ : "";
614+ return `curl -X ${route.method} '${origin}${route.pathPattern}'${payload}`;
615+}
616+
617+async function handleDescribeRead(context: LocalApiRequestContext, version: string): Promise<ConductorHttpResponse> {
618+ const repository = requireRepository(context.repository);
619+ const snapshot = context.snapshotLoader();
620+ const origin = snapshot.controlApi.localApiBase ?? "http://127.0.0.1";
621+ const system = await buildSystemStateData(repository);
622+
623+ return buildSuccessEnvelope(context.requestId, 200, {
624+ name: resolveServiceName(),
625+ version,
626+ description:
627+ "BAA conductor local control surface backed directly by the mini node's local truth source.",
628+ environment: {
629+ summary: "single-node mini local daemon",
630+ deployment_mode: "single-node mini",
631+ topology: "No Cloudflare Worker or D1 control-plane hop is required for these routes.",
632+ auth_mode: "local_network_only",
633+ truth_source: "local sqlite control plane",
634+ origin
635+ },
636+ system,
637+ endpoints: LOCAL_API_ROUTES.filter((route) => route.exposeInDescribe !== false).map(describeRoute),
638+ capabilities: buildCapabilitiesData(snapshot),
639+ examples: [
640+ {
641+ title: "Read the full self-description first",
642+ method: "GET",
643+ path: "/describe",
644+ curl: buildCurlExample(
645+ origin,
646+ LOCAL_API_ROUTES.find((route) => route.id === "service.describe")!
647+ )
648+ },
649+ {
650+ title: "Inspect the narrower capability surface",
651+ method: "GET",
652+ path: "/v1/capabilities",
653+ curl: buildCurlExample(
654+ origin,
655+ LOCAL_API_ROUTES.find((route) => route.id === "system.capabilities")!
656+ )
657+ },
658+ {
659+ title: "Read the current automation state",
660+ method: "GET",
661+ path: "/v1/system/state",
662+ curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "system.state")!)
663+ },
664+ {
665+ title: "Pause local automation explicitly",
666+ method: "POST",
667+ path: "/v1/system/pause",
668+ curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "system.pause")!, {
669+ requested_by: "human_operator",
670+ reason: "manual_pause",
671+ source: "local_control_surface"
672+ })
673+ }
674+ ],
675+ notes: [
676+ "These routes read and mutate the mini node's local truth source directly.",
677+ "GET /healthz, /readyz, /rolez and /v1/runtime remain available as low-level diagnostics.",
678+ "This surface is intended to replace the old business-control reads previously served by control-api-worker."
679+ ]
680+ });
681+}
682+
683+async function handleHealthRead(context: LocalApiRequestContext, version: string): Promise<ConductorHttpResponse> {
684+ const repository = requireRepository(context.repository);
685+ const snapshot = context.snapshotLoader();
686+
687+ return buildSuccessEnvelope(context.requestId, 200, {
688+ name: resolveServiceName(),
689+ version,
690+ status: snapshot.daemon.leaseState === "degraded" ? "degraded" : "ok",
691+ deployment_mode: "single-node mini",
692+ auth_mode: "local_network_only",
693+ repository_configured: true,
694+ system: await buildSystemStateData(repository)
695+ });
696+}
697+
698+function handleVersionRead(requestId: string, version: string): ConductorHttpResponse {
699+ return buildSuccessEnvelope(requestId, 200, {
700+ name: resolveServiceName(),
701+ version,
702+ description: "BAA conductor local control surface"
703+ });
704+}
705+
706+async function handleCapabilitiesRead(
707+ context: LocalApiRequestContext,
708+ version: string
709+): Promise<ConductorHttpResponse> {
710+ const repository = requireRepository(context.repository);
711+ const snapshot = context.snapshotLoader();
712+
713+ return buildSuccessEnvelope(context.requestId, 200, {
714+ ...buildCapabilitiesData(snapshot),
715+ version,
716+ system: await buildSystemStateData(repository),
717+ notes: [
718+ "Read routes are safe for discovery and inspection.",
719+ "POST /v1/system/* writes the local automation mode immediately."
720+ ]
721+ });
722+}
723+
724+async function handleSystemStateRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
725+ return buildSuccessEnvelope(
726+ context.requestId,
727+ 200,
728+ await buildSystemStateData(requireRepository(context.repository))
729+ );
730+}
731+
732+async function handleSystemMutation(
733+ context: LocalApiRequestContext,
734+ mode: AutomationMode
735+): Promise<ConductorHttpResponse> {
736+ const repository = requireRepository(context.repository);
737+ const body = readBodyObject(context.request, true);
738+ const requestedBy = readOptionalStringField(body, "requested_by");
739+ const reason = readOptionalStringField(body, "reason");
740+ const source = readOptionalStringField(body, "source");
741+
742+ await repository.putSystemState({
743+ stateKey: AUTOMATION_STATE_KEY,
744+ updatedAt: context.now(),
745+ valueJson: JSON.stringify({
746+ mode,
747+ ...(requestedBy ? { requested_by: requestedBy } : {}),
748+ ...(reason ? { reason } : {}),
749+ ...(source ? { source } : {})
750+ })
751+ });
752+
753+ const suffix = [requestedBy ? `requested by ${requestedBy}` : null, reason ? `reason: ${reason}` : null]
754+ .filter((value) => value !== null)
755+ .join("; ");
756+
757+ return buildAckResponse(
758+ context.requestId,
759+ suffix === "" ? `Automation mode set to ${mode}.` : `Automation mode set to ${mode}; ${suffix}.`
760+ );
761+}
762+
763+async function handleControllersList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
764+ const repository = requireRepository(context.repository);
765+ const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
766+ const [lease, controllers] = await Promise.all([
767+ repository.getCurrentLease(),
768+ repository.listControllers({
769+ limit
770+ })
771+ ]);
772+
773+ return buildSuccessEnvelope(context.requestId, 200, {
774+ active_controller_id: lease?.holderId ?? null,
775+ count: controllers.length,
776+ limit,
777+ controllers: controllers.map((controller) => summarizeController(controller, lease?.holderId ?? null))
778+ });
779+}
780+
781+async function handleTasksList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
782+ const repository = requireRepository(context.repository);
783+ const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
784+ const status = readTaskStatusFilter(context.url);
785+ const tasks = await repository.listTasks({
786+ limit,
787+ status
788+ });
789+
790+ return buildSuccessEnvelope(context.requestId, 200, {
791+ count: tasks.length,
792+ filters: {
793+ limit,
794+ status: status ?? null
795+ },
796+ tasks: tasks.map(summarizeTask)
797+ });
798+}
799+
800+async function handleTaskRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
801+ const repository = requireRepository(context.repository);
802+ const taskId = context.params.task_id;
803+
804+ if (!taskId) {
805+ throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"task_id\" is required.");
806+ }
807+
808+ const task = await repository.getTask(taskId);
809+
810+ if (task == null) {
811+ throw new LocalApiHttpError(404, "not_found", `Task "${taskId}" was not found.`, {
812+ resource: "task",
813+ resource_id: taskId
814+ });
815+ }
816+
817+ return buildSuccessEnvelope(context.requestId, 200, summarizeTask(task));
818+}
819+
820+async function handleTaskLogsRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
821+ const repository = requireRepository(context.repository);
822+ const taskId = context.params.task_id;
823+
824+ if (!taskId) {
825+ throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"task_id\" is required.");
826+ }
827+
828+ const task = await repository.getTask(taskId);
829+
830+ if (task == null) {
831+ throw new LocalApiHttpError(404, "not_found", `Task "${taskId}" was not found.`, {
832+ resource: "task",
833+ resource_id: taskId
834+ });
835+ }
836+
837+ const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT);
838+ const runId = context.url.searchParams.get("run_id")?.trim() || undefined;
839+ const logs = await repository.listTaskLogs(taskId, {
840+ limit,
841+ runId
842+ });
843+ const uniqueRunIds = [...new Set(logs.map((entry) => entry.runId))];
844+
845+ return buildSuccessEnvelope(context.requestId, 200, {
846+ task_id: taskId,
847+ run_id: uniqueRunIds.length === 1 ? (uniqueRunIds[0] ?? null) : runId ?? null,
848+ count: logs.length,
849+ limit,
850+ entries: logs.map(summarizeTaskLog)
851+ });
852+}
853+
854+async function handleRunsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
855+ const repository = requireRepository(context.repository);
856+ const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
857+ const runs = await repository.listRuns({
858+ limit
859+ });
860+
861+ return buildSuccessEnvelope(context.requestId, 200, {
862+ count: runs.length,
863+ limit,
864+ runs: runs.map(summarizeRun)
865+ });
866+}
867+
868+async function handleRunRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
869+ const repository = requireRepository(context.repository);
870+ const runId = context.params.run_id;
871+
872+ if (!runId) {
873+ throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"run_id\" is required.");
874+ }
875+
876+ const run = await repository.getRun(runId);
877+
878+ if (run == null) {
879+ throw new LocalApiHttpError(404, "not_found", `Run "${runId}" was not found.`, {
880+ resource: "run",
881+ resource_id: runId
882+ });
883+ }
884+
885+ return buildSuccessEnvelope(context.requestId, 200, summarizeRun(run));
886+}
887+
888+async function dispatchBusinessRoute(
889+ routeId: string,
890+ context: LocalApiRequestContext,
891+ version: string
892+): Promise<ConductorHttpResponse> {
893+ switch (routeId) {
894+ case "service.describe":
895+ return handleDescribeRead(context, version);
896+ case "service.health":
897+ return handleHealthRead(context, version);
898+ case "service.version":
899+ return handleVersionRead(context.requestId, version);
900+ case "system.capabilities":
901+ return handleCapabilitiesRead(context, version);
902+ case "system.state":
903+ return handleSystemStateRead(context);
904+ case "system.pause":
905+ return handleSystemMutation(context, "paused");
906+ case "system.resume":
907+ return handleSystemMutation(context, "running");
908+ case "system.drain":
909+ return handleSystemMutation(context, "draining");
910+ case "controllers.list":
911+ return handleControllersList(context);
912+ case "tasks.list":
913+ return handleTasksList(context);
914+ case "tasks.read":
915+ return handleTaskRead(context);
916+ case "tasks.logs.read":
917+ return handleTaskLogsRead(context);
918+ case "runs.list":
919+ return handleRunsList(context);
920+ case "runs.read":
921+ return handleRunRead(context);
922+ default:
923+ throw new LocalApiHttpError(404, "not_found", `No local route matches "${context.url.pathname}".`);
924+ }
925+}
926+
927+async function dispatchRoute(
928+ matchedRoute: LocalApiRouteMatch,
929+ context: LocalApiRequestContext,
930+ version: string
931+): Promise<ConductorHttpResponse> {
932+ switch (matchedRoute.route.id) {
933+ case "probe.healthz":
934+ return textResponse(200, "ok");
935+ case "probe.readyz": {
936+ const ready = isRuntimeReady(context.snapshotLoader());
937+ return textResponse(ready ? 200 : 503, ready ? "ready" : "not_ready");
938+ }
939+ case "probe.rolez":
940+ return textResponse(200, resolveLeadershipRole(context.snapshotLoader()));
941+ case "probe.runtime":
942+ return buildSuccessEnvelope(context.requestId, 200, context.snapshotLoader() as unknown as JsonValue);
943+ default:
944+ return dispatchBusinessRoute(matchedRoute.route.id, context, version);
945+ }
946+}
947+
948+function matchRoute(method: string, pathname: string): LocalApiRouteMatch | null {
949+ const normalizedPath = normalizePathname(pathname);
950+
951+ for (const route of LOCAL_API_ROUTES) {
952+ if (route.method !== method) {
953+ continue;
954+ }
955+
956+ const params = matchPathPattern(route.pathPattern, normalizedPath);
957+
958+ if (params) {
959+ return {
960+ params,
961+ route
962+ };
963+ }
964+ }
965+
966+ return null;
967+}
968+
969+function findAllowedMethods(pathname: string): LocalApiRouteMethod[] {
970+ const normalizedPath = normalizePathname(pathname);
971+ const methods = new Set<LocalApiRouteMethod>();
972+
973+ for (const route of LOCAL_API_ROUTES) {
974+ if (matchPathPattern(route.pathPattern, normalizedPath)) {
975+ methods.add(route.method);
976+ }
977+ }
978+
979+ return [...methods];
980+}
981+
982+function matchPathPattern(pathPattern: string, pathname: string): Record<string, string> | null {
983+ const patternSegments = normalizePathname(pathPattern).split("/");
984+ const pathSegments = normalizePathname(pathname).split("/");
985+
986+ if (patternSegments.length !== pathSegments.length) {
987+ return null;
988+ }
989+
990+ const params: Record<string, string> = {};
991+
992+ for (let index = 0; index < patternSegments.length; index += 1) {
993+ const patternSegment = patternSegments[index];
994+ const pathSegment = pathSegments[index];
995+
996+ if (patternSegment == null || pathSegment == null) {
997+ return null;
998+ }
999+
1000+ if (patternSegment.startsWith(":")) {
1001+ params[patternSegment.slice(1)] = decodeURIComponent(pathSegment);
1002+ continue;
1003+ }
1004+
1005+ if (patternSegment !== pathSegment) {
1006+ return null;
1007+ }
1008+ }
1009+
1010+ return params;
1011+}
1012+
1013+export async function handleConductorHttpRequest(
1014+ request: ConductorHttpRequest,
1015+ context: ConductorLocalApiContext
1016+): Promise<ConductorHttpResponse> {
1017+ const baseUrl = context.snapshotLoader().controlApi.localApiBase ?? "http://conductor.local";
1018+ const url = new URL(request.path || "/", baseUrl);
1019+ const matchedRoute = matchRoute(request.method.toUpperCase(), url.pathname);
1020+ const requestId = crypto.randomUUID();
1021+ const version = context.version?.trim() || "dev";
1022+
1023+ if (!matchedRoute) {
1024+ const allowedMethods = findAllowedMethods(url.pathname);
1025+
1026+ if (allowedMethods.length > 0) {
1027+ return buildErrorEnvelope(
1028+ requestId,
1029+ new LocalApiHttpError(
1030+ 405,
1031+ "method_not_allowed",
1032+ `Method ${request.method.toUpperCase()} is not allowed for ${normalizePathname(url.pathname)}.`,
1033+ {
1034+ allow: allowedMethods
1035+ },
1036+ {
1037+ Allow: allowedMethods.join(", ")
1038+ }
1039+ )
1040+ );
1041+ }
1042+
1043+ return buildErrorEnvelope(
1044+ requestId,
1045+ new LocalApiHttpError(
1046+ 404,
1047+ "not_found",
1048+ `No conductor route matches "${normalizePathname(url.pathname)}".`
1049+ )
1050+ );
1051+ }
1052+
1053+ try {
1054+ return await dispatchRoute(
1055+ matchedRoute,
1056+ {
1057+ now: context.now ?? (() => Math.floor(Date.now() / 1000)),
1058+ params: matchedRoute.params,
1059+ repository: context.repository,
1060+ request,
1061+ requestId,
1062+ snapshotLoader: context.snapshotLoader,
1063+ url
1064+ },
1065+ version
1066+ );
1067+ } catch (error) {
1068+ if (error instanceof LocalApiHttpError) {
1069+ return buildErrorEnvelope(requestId, error);
1070+ }
1071+
1072+ throw error;
1073+ }
1074+}
1@@ -0,0 +1,64 @@
2+import { readFileSync } from "node:fs";
3+import { resolve } from "node:path";
4+import { fileURLToPath } from "node:url";
5+
6+import { D1ControlPlaneRepository, SqliteD1Database } from "../../../packages/db/dist/index.js";
7+
8+const REPO_ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
9+
10+export const DEFAULT_CONTROL_PLANE_DB_FILENAME = "control-plane.sqlite";
11+
12+export interface ConductorLocalControlPlaneOptions {
13+ databasePath?: string | null;
14+ schemaPath?: string | null;
15+ stateDir?: string | null;
16+}
17+
18+export function resolveConductorRepoRoot(): string {
19+ return REPO_ROOT;
20+}
21+
22+export function resolveDefaultConductorStateDir(): string {
23+ return resolve(REPO_ROOT, "state");
24+}
25+
26+export function resolveConductorControlPlaneSchemaPath(schemaPath?: string | null): string {
27+ return resolve(schemaPath ?? resolve(REPO_ROOT, "ops/sql/schema.sql"));
28+}
29+
30+export function resolveConductorControlPlaneDatabasePath(
31+ options: Pick<ConductorLocalControlPlaneOptions, "databasePath" | "stateDir"> = {}
32+): string {
33+ const explicitDatabasePath = options.databasePath?.trim();
34+
35+ if (explicitDatabasePath) {
36+ return explicitDatabasePath === ":memory:" ? ":memory:" : resolve(explicitDatabasePath);
37+ }
38+
39+ return resolve(options.stateDir ?? resolveDefaultConductorStateDir(), DEFAULT_CONTROL_PLANE_DB_FILENAME);
40+}
41+
42+export class ConductorLocalControlPlane {
43+ readonly databasePath: string;
44+ readonly repository: D1ControlPlaneRepository;
45+ readonly schemaPath: string;
46+
47+ private readonly database: SqliteD1Database;
48+
49+ constructor(options: ConductorLocalControlPlaneOptions = {}) {
50+ this.databasePath = resolveConductorControlPlaneDatabasePath(options);
51+ this.schemaPath = resolveConductorControlPlaneSchemaPath(options.schemaPath);
52+ this.database = new SqliteD1Database(this.databasePath, {
53+ schemaSql: readFileSync(this.schemaPath, "utf8")
54+ });
55+ this.repository = new D1ControlPlaneRepository(this.database);
56+ }
57+
58+ async initialize(): Promise<void> {
59+ await this.repository.ensureAutomationState();
60+ }
61+
62+ close(): void {
63+ this.database.close();
64+ }
65+}
1@@ -6,11 +6,19 @@ declare module "node:net" {
2 }
3 }
4
5+declare module "node:fs" {
6+ export function readFileSync(path: string, encoding: string): string;
7+}
8+
9 declare module "node:http" {
10 import type { AddressInfo } from "node:net";
11
12 export interface IncomingMessage {
13 method?: string;
14+ on?(event: "data", listener: (chunk: string | Uint8Array) => void): this;
15+ on?(event: "end", listener: () => void): this;
16+ on?(event: "error", listener: (error: Error) => void): this;
17+ setEncoding?(encoding: string): void;
18 url?: string;
19 }
20
21@@ -36,3 +44,11 @@ declare module "node:http" {
22 handler: (request: IncomingMessage, response: ServerResponse<IncomingMessage>) => void
23 ): Server;
24 }
25+
26+declare module "node:path" {
27+ export function resolve(...paths: string[]): string;
28+}
29+
30+declare module "node:url" {
31+ export function fileURLToPath(url: string | URL): string;
32+}
+104,
-0
1@@ -0,0 +1,104 @@
2+---
3+task_id: T-L003
4+title: conductor-daemon 本地接口面合并
5+status: review
6+branch: feat/conductor-local-api-surface
7+repo: /Users/george/code/baa-conductor
8+base_ref: main
9+depends_on:
10+ - T-L002
11+write_scope:
12+ - apps/conductor-daemon/**
13+ - packages/db/**
14+ - docs/api/**
15+ - README.md
16+updated_at: 2026-03-22
17+---
18+
19+# conductor-daemon 本地接口面合并
20+
21+## 目标
22+
23+把原来分散在 `control-api-worker` 的业务控制接口并入 `conductor-daemon` 本地 HTTP 服务,并直接读取本地真相源。
24+
25+## 本任务包含
26+
27+- 在 `conductor-daemon` 暴露本地 describe / health / version / capabilities 接口
28+- 在 `conductor-daemon` 暴露 system、controllers、tasks、runs 相关只读/控制接口
29+- 给本地接口接入本地 repository,而不是继续回源远端 `control-api`
30+- 补最小验证、文档和 README
31+
32+## 本任务不包含
33+
34+- 恢复 Cloudflare Worker 路径
35+- 修改 `plugins/baa-firefox/**`
36+- 恢复历史主备切换设计
37+
38+## 建议起始文件
39+
40+- `apps/conductor-daemon/src/index.ts`
41+- `apps/conductor-daemon/src/index.test.js`
42+- `packages/db/src/index.ts`
43+- `docs/api/README.md`
44+- `README.md`
45+
46+## 交付物
47+
48+- conductor-daemon 本地 HTTP 面新增/迁移后的业务接口
49+- 本地 repository 读写与日志查询能力
50+- API 文档和任务卡更新
51+
52+## 验收
53+
54+- `apps/conductor-daemon` typecheck / build 通过
55+- 本地启动后能用 `curl` 验证关键接口
56+- `git diff --check` 通过
57+
58+## files_changed
59+
60+- `apps/conductor-daemon/src/index.ts`
61+- `apps/conductor-daemon/src/index.test.js`
62+- `apps/conductor-daemon/src/http-types.ts`
63+- `apps/conductor-daemon/src/local-api.ts`
64+- `apps/conductor-daemon/src/local-control-plane.ts`
65+- `apps/conductor-daemon/src/node-shims.d.ts`
66+- `apps/conductor-daemon/package.json`
67+- `packages/db/src/index.ts`
68+- `packages/db/src/index.test.js`
69+- `packages/db/src/node-shims.d.ts`
70+- `packages/db/package.json`
71+- `packages/db/tsconfig.json`
72+- `docs/api/README.md`
73+- `README.md`
74+
75+## commands_run
76+
77+- `git checkout -b feat/conductor-local-api-surface`
78+- `npx pnpm -C /Users/george/code/baa-conductor/packages/db build`
79+- `npx pnpm -C /Users/george/code/baa-conductor/packages/db test`
80+- `npx pnpm -C /Users/george/code/baa-conductor/apps/conductor-daemon typecheck`
81+- `npx pnpm -C /Users/george/code/baa-conductor/apps/conductor-daemon build`
82+- `npx pnpm -C /Users/george/code/baa-conductor/apps/conductor-daemon test`
83+- `curl http://127.0.0.1:43179/describe`
84+- `curl http://127.0.0.1:43179/v1/system/state`
85+- `curl -X POST http://127.0.0.1:43179/v1/system/pause`
86+- `curl http://127.0.0.1:43179/v1/controllers`
87+- `git diff --check`
88+
89+## result
90+
91+- `conductor-daemon` 现在默认创建本地 SQLite control-plane,并让 lease/heartbeat/system state 直接落本地真相源
92+- 本地 HTTP 面已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/system/state`
93+- 本地 HTTP 面已承接 `/v1/system/pause`、`/v1/system/resume`、`/v1/system/drain`
94+- 本地 HTTP 面已承接 `/v1/controllers`、`/v1/tasks`、`/v1/tasks/:task_id`、`/v1/tasks/:task_id/logs`、`/v1/runs`、`/v1/runs/:run_id`
95+- `packages/db` 新增 SQLite D1 适配和 task log 列表查询,供 daemon 直接复用
96+- 文档已更新为本地 API 真相源视角
97+
98+## risks
99+
100+- `status-api` 和部分运行文档仍有旧的 `BAA_CONTROL_API_BASE`/control-api 描述,后续还需要继续 cutover
101+- 当前本地 API 仍是 local-network 信任模型,没有单独再叠加 bearer 鉴权
102+
103+## next_handoff
104+
105+- 继续把 `status-api` 和剩余运维脚本切到 `conductor-daemon local-api` 作为唯一业务真相源
+76,
-68
1@@ -1,107 +1,115 @@
2-# Local API Cutover
3+# HTTP API Surfaces
4
5-`baa-conductor` 的 canonical 接口面已经收口为:
6+`baa-conductor` 当前把业务控制/查询接口收口到 `mini` 节点上的 `conductor-daemon` 本地 HTTP 面。
7
8-- canonical local API: `http://100.71.210.78:4317`
9-- canonical public host: `https://conductor.makefile.so`
10-- `https://control-api.makefile.so` 只作为迁移期兼容入口,不再是默认业务接口
11+原则:
12
13-## Canonical base URLs
14+- `conductor-daemon` 本地 API 是这些业务接口的真相源
15+- `status-api` 仍是只读状态视图
16+- 不恢复 Cloudflare Worker/D1 作为这批业务接口的主真相源
17
18-| 面 | 地址 | 用途 |
19+## 入口
20+
21+| 服务 | 地址 | 说明 |
22 | --- | --- | --- |
23-| local primary | `http://100.71.210.78:4317` | 唯一主接口、内网真相源、后续完整 HTTP API 的承载面 |
24-| public primary | `https://conductor.makefile.so` | 唯一对外域名,应与 local primary 暴露同一套 canonical 路由 |
25-| local status view | `http://100.71.210.78:4318` | 本地只读观察面;迁移期保留,不是主控制面 |
26+| conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/system/controllers/tasks/runs |
27+| status-api | `https://conductor.makefile.so` | 只读状态 JSON 和 HTML 视图 |
28+| control-api | `https://control-api.makefile.so` | 仍可保留给遗留/远端控制面合同,但不再是下列业务接口的真相源 |
29
30-## 当前已在主接口上的路由
31+## Describe First
32
33-当前 `4317` 已提供:
34+推荐顺序:
35
36-| 方法 | 路径 | 说明 |
37-| --- | --- | --- |
38-| `GET` | `/healthz` | 最小健康检查 |
39-| `GET` | `/readyz` | 就绪检查 |
40-| `GET` | `/rolez` | 当前角色信息 |
41-| `GET` | `/v1/runtime` | 本地 runtime 摘要 |
42+1. `GET ${BAA_CONDUCTOR_LOCAL_API}/describe`
43+2. `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/capabilities`
44+3. `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/system/state`
45+4. 按需查看 `controllers`、`tasks`、`runs`
46+5. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain`
47
48-这些路由已经通过 `conductor.makefile.so` 暴露给公网。
49+## Conductor Daemon Local API
50
51-## cutover 目标路由
52+### 可发现性接口
53
54-后续任务要把以下能力并到同一个接口面:
55-
56-### discovery
57+| 方法 | 路径 | 说明 |
58+| --- | --- | --- |
59+| `GET` | `/describe` | 完整自描述 JSON,说明当前模式、真相源、端点、示例和注意事项 |
60+| `GET` | `/health` | 服务健康摘要,带本地 system 状态 |
61+| `GET` | `/version` | 轻量版本查询 |
62+| `GET` | `/v1/capabilities` | 更窄的能力发现接口,区分读/写/诊断端点 |
63
64-- `GET /describe`
65-- `GET /version`
66-- `GET /health`
67-- `GET /v1/capabilities`
68+### 控制型接口
69
70-### control and query
71+| 方法 | 路径 | 说明 |
72+| --- | --- | --- |
73+| `GET` | `/v1/system/state` | 当前 automation / leader / queue 摘要 |
74+| `POST` | `/v1/system/pause` | 切到 `paused` |
75+| `POST` | `/v1/system/resume` | 切到 `running` |
76+| `POST` | `/v1/system/drain` | 切到 `draining` |
77
78-- `GET /v1/system/state`
79-- `GET /v1/controllers`
80-- `GET /v1/tasks`
81-- `GET /v1/tasks/:task_id`
82-- `GET /v1/tasks/:task_id/logs`
83-- `GET /v1/runs`
84-- `GET /v1/runs/:run_id`
85-- `POST /v1/tasks`
86-- `POST /v1/system/pause`
87-- `POST /v1/system/resume`
88-- `POST /v1/system/drain`
89+### 只读业务接口
90
91-### 来自 hand / shell 语义的可选扩展
92+| 方法 | 路径 | 说明 |
93+| --- | --- | --- |
94+| `GET` | `/v1/controllers?limit=20` | 已注册 controller 摘要,带 active leader 线索 |
95+| `GET` | `/v1/tasks?status=queued&limit=20` | 最近 task 摘要,可按 `status` 过滤 |
96+| `GET` | `/v1/tasks/:task_id` | 单个 task 详情 |
97+| `GET` | `/v1/tasks/:task_id/logs?limit=200` | task 关联日志,可选 `run_id` 过滤 |
98+| `GET` | `/v1/runs?limit=20` | 最近 run 摘要 |
99+| `GET` | `/v1/runs/:run_id` | 单个 run 详情 |
100
101-- `POST /v1/exec`
102-- `POST /v1/files/read`
103-- `POST /v1/files/write`
104+### 诊断接口
105
106-这些路由在 cutover 完成前可能仍存在于 legacy control plane;但新的文档、调用方和设计说明不再把它写成默认事实。
107+| 方法 | 路径 | 说明 |
108+| --- | --- | --- |
109+| `GET` | `/healthz` | 最小健康探针,返回 `ok` |
110+| `GET` | `/readyz` | readiness 探针 |
111+| `GET` | `/rolez` | 当前 `leader` / `standby` 视图 |
112+| `GET` | `/v1/runtime` | daemon runtime 快照 |
113
114-## cutover 完成后的推荐调用顺序
115+## Status API
116
117-1. 先请求 `GET /describe`
118-2. 再读 `GET /v1/capabilities`
119-3. 先做只读确认:
120- - `GET /v1/system/state`
121- - `GET /v1/tasks`
122- - `GET /v1/runs`
123-4. 只有在需要真实操作时,再调用写接口
124+`status-api` 仍是只读视图服务,不拥有真相。
125
126-## 迁移期兼容说明
127+truth source:
128
129-- `https://control-api.makefile.so` 仍可用于盘点旧调用方依赖
130-- `apps/control-api-worker`、Cloudflare Worker、D1 仍在仓库中,但只算迁移期兼容层
131-- `status-api` 当前仍从 `BAA_CONTROL_API_BASE` 读取 truth source,因此 `4318` 只能算本地观察面
132+- 当前应优先回源 `conductor-daemon local-api /v1/system/state`
133+- 它负责把该状态整理成 JSON 或 HTML
134
135-## Minimal Curl
136+当前端点:
137
138-当前已稳定可用的 canonical 路径:
139+| 方法 | 路径 | 说明 |
140+| --- | --- | --- |
141+| `GET` | `/describe` | 说明 status-api 本身、truth source 和返回格式 |
142+| `GET` | `/healthz` | 纯健康检查,返回 `ok` |
143+| `GET` | `/v1/status` | JSON 状态快照 |
144+| `GET` | `/v1/status/ui` | HTML 状态面板 |
145+| `GET` | `/` / `/ui` | `/v1/status/ui` 别名 |
146
147-```bash
148-curl https://conductor.makefile.so/healthz
149-```
150+## Minimal Curl
151
152 ```bash
153-curl https://conductor.makefile.so/readyz
154+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
155+curl "${LOCAL_API_BASE}/describe"
156 ```
157
158 ```bash
159-curl https://conductor.makefile.so/rolez
160+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
161+curl "${LOCAL_API_BASE}/v1/capabilities"
162 ```
163
164 ```bash
165-curl https://conductor.makefile.so/v1/runtime
166+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
167+curl "${LOCAL_API_BASE}/v1/system/state"
168 ```
169
170 ```bash
171-curl http://100.71.210.78:4318/v1/status
172+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
173+curl "${LOCAL_API_BASE}/v1/tasks?limit=5"
174 ```
175
176-legacy 盘点时才需要:
177-
178 ```bash
179-curl https://control-api.makefile.so/describe
180+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
181+curl -X POST "${LOCAL_API_BASE}/v1/system/pause" \
182+ -H 'Content-Type: application/json' \
183+ -d '{"requested_by":"human_operator","reason":"manual_pause"}'
184 ```
+4,
-1
1@@ -2,8 +2,11 @@
2 "name": "@baa-conductor/db",
3 "private": true,
4 "type": "module",
5+ "main": "dist/index.js",
6+ "types": "dist/index.d.ts",
7 "scripts": {
8- "build": "pnpm exec tsc --noEmit -p tsconfig.json",
9+ "build": "pnpm exec tsc -p tsconfig.json",
10+ "test": "pnpm run build && node --test src/index.test.js",
11 "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
12 }
13 }
+153,
-1
1@@ -1,14 +1,19 @@
2 import assert from "node:assert/strict";
3+import { readFileSync } from "node:fs";
4 import test from "node:test";
5
6 import {
7+ D1ControlPlaneRepository,
8 GLOBAL_LEASE_NAME,
9+ SqliteD1Database,
10 buildControllerHeartbeatRecord,
11 buildLeaderLeaseAcquireResult,
12 buildLeaderLeaseRecord,
13 getLeaderLeaseOperation,
14 isLeaderLeaseExpired
15-} from "./index.ts";
16+} from "../dist/index.js";
17+
18+const CONTROL_PLANE_SCHEMA_SQL = readFileSync(new URL("../../../ops/sql/schema.sql", import.meta.url), "utf8");
19
20 test("buildControllerHeartbeatRecord preserves heartbeat and startup timestamps", () => {
21 const record = buildControllerHeartbeatRecord({
22@@ -96,3 +101,150 @@ test("buildLeaderLeaseRecord increments term for takeover and marks standby resp
23 assert.equal(response.operation, "acquire");
24 assert.equal(response.holderId, "mini-main");
25 });
26+
27+test("SqliteD1Database supports task log queries through D1ControlPlaneRepository", async () => {
28+ const db = new SqliteD1Database(":memory:", {
29+ schemaSql: CONTROL_PLANE_SCHEMA_SQL
30+ });
31+ const repository = new D1ControlPlaneRepository(db);
32+
33+ try {
34+ await repository.ensureAutomationState();
35+ await repository.heartbeatController({
36+ controllerId: "mini-main",
37+ heartbeatAt: 100,
38+ host: "mini",
39+ priority: 100,
40+ role: "primary",
41+ startedAt: 100,
42+ status: "alive",
43+ version: "1.0.0"
44+ });
45+ await repository.upsertWorker({
46+ workerId: "worker-shell-1",
47+ controllerId: "mini-main",
48+ host: "mini",
49+ workerType: "shell",
50+ status: "idle",
51+ maxParallelism: 1,
52+ currentLoad: 0,
53+ lastHeartbeatAt: 100,
54+ capabilitiesJson: null,
55+ metadataJson: null
56+ });
57+ await repository.insertTask({
58+ acceptanceJson: null,
59+ assignedControllerId: "mini-main",
60+ baseRef: "main",
61+ branchName: null,
62+ constraintsJson: null,
63+ createdAt: 100,
64+ currentStepIndex: 0,
65+ errorText: null,
66+ finishedAt: null,
67+ goal: "verify sqlite adapter",
68+ metadataJson: null,
69+ plannerProvider: null,
70+ planningStrategy: null,
71+ priority: 50,
72+ repo: "/tmp/repo",
73+ resultJson: null,
74+ resultSummary: null,
75+ source: "test",
76+ startedAt: 100,
77+ status: "running",
78+ targetHost: "mini",
79+ taskId: "task_demo",
80+ taskType: "shell",
81+ title: "demo",
82+ updatedAt: 100
83+ });
84+ await repository.insertTaskStep({
85+ stepId: "step_demo",
86+ taskId: "task_demo",
87+ stepIndex: 0,
88+ stepName: "run demo",
89+ stepKind: "shell",
90+ status: "running",
91+ assignedWorkerId: "worker-shell-1",
92+ assignedControllerId: "mini-main",
93+ timeoutSec: 60,
94+ retryLimit: 0,
95+ retryCount: 0,
96+ leaseExpiresAt: 130,
97+ inputJson: null,
98+ outputJson: null,
99+ summary: null,
100+ errorText: null,
101+ createdAt: 100,
102+ updatedAt: 100,
103+ startedAt: 100,
104+ finishedAt: null
105+ });
106+ await repository.insertTaskRun({
107+ runId: "run_demo",
108+ taskId: "task_demo",
109+ stepId: "step_demo",
110+ workerId: "worker-shell-1",
111+ controllerId: "mini-main",
112+ host: "mini",
113+ pid: 1234,
114+ status: "running",
115+ leaseExpiresAt: 130,
116+ heartbeatAt: 100,
117+ logDir: "/tmp/run-demo",
118+ stdoutPath: null,
119+ stderrPath: null,
120+ workerLogPath: null,
121+ checkpointSeq: 0,
122+ exitCode: null,
123+ resultJson: null,
124+ errorText: null,
125+ createdAt: 100,
126+ startedAt: 100,
127+ finishedAt: null
128+ });
129+ await repository.appendTaskLog({
130+ taskId: "task_demo",
131+ stepId: "step_demo",
132+ runId: "run_demo",
133+ seq: 1,
134+ stream: "stdout",
135+ level: "info",
136+ message: "first",
137+ createdAt: 100
138+ });
139+ await repository.appendTaskLog({
140+ taskId: "task_demo",
141+ stepId: "step_demo",
142+ runId: "run_demo",
143+ seq: 2,
144+ stream: "stdout",
145+ level: "info",
146+ message: "second",
147+ createdAt: 101
148+ });
149+ await repository.appendTaskLog({
150+ taskId: "task_demo",
151+ stepId: "step_demo",
152+ runId: "run_demo",
153+ seq: 3,
154+ stream: "stdout",
155+ level: "info",
156+ message: "third",
157+ createdAt: 102
158+ });
159+
160+ const logs = await repository.listTaskLogs("task_demo", {
161+ limit: 2
162+ });
163+
164+ assert.equal(logs.length, 2);
165+ assert.deepEqual(
166+ logs.map((entry) => entry.message),
167+ ["second", "third"]
168+ );
169+ } finally {
170+ db.close();
171+ }
172+});
+219,
-0
1@@ -1,3 +1,7 @@
2+import { mkdirSync } from "node:fs";
3+import { dirname } from "node:path";
4+import { DatabaseSync } from "node:sqlite";
5+
6 export const D1_TABLES = [
7 "leader_lease",
8 "controllers",
9@@ -306,6 +310,11 @@ export interface ListRunsOptions {
10 limit?: number;
11 }
12
13+export interface ListTaskLogsOptions {
14+ limit?: number;
15+ runId?: string;
16+}
17+
18 export interface ControlPlaneRepository {
19 appendTaskLog(record: NewTaskLogRecord): Promise<number | null>;
20 countActiveRuns(): Promise<number>;
21@@ -326,6 +335,7 @@ export interface ControlPlaneRepository {
22 insertTaskStep(record: TaskStepRecord): Promise<void>;
23 insertTaskSteps(records: TaskStepRecord[]): Promise<void>;
24 listControllers(options?: ListControllersOptions): Promise<ControllerRecord[]>;
25+ listTaskLogs(taskId: string, options?: ListTaskLogsOptions): Promise<TaskLogRecord[]>;
26 listRuns(options?: ListRunsOptions): Promise<TaskRunRecord[]>;
27 listTasks(options?: ListTasksOptions): Promise<TaskRecord[]>;
28 listTaskSteps(taskId: string): Promise<TaskStepRecord[]>;
29@@ -1217,6 +1227,67 @@ export const SELECT_RUNS_SQL = `
30 LIMIT ?
31 `;
32
33+export const SELECT_TASK_LOGS_SQL = `
34+ SELECT
35+ log_id,
36+ task_id,
37+ step_id,
38+ run_id,
39+ seq,
40+ stream,
41+ level,
42+ message,
43+ created_at
44+ FROM (
45+ SELECT
46+ log_id,
47+ task_id,
48+ step_id,
49+ run_id,
50+ seq,
51+ stream,
52+ level,
53+ message,
54+ created_at
55+ FROM task_logs
56+ WHERE task_id = ?
57+ ORDER BY created_at DESC, log_id DESC
58+ LIMIT ?
59+ ) AS recent_logs
60+ ORDER BY created_at ASC, log_id ASC
61+`;
62+
63+export const SELECT_TASK_LOGS_BY_RUN_SQL = `
64+ SELECT
65+ log_id,
66+ task_id,
67+ step_id,
68+ run_id,
69+ seq,
70+ stream,
71+ level,
72+ message,
73+ created_at
74+ FROM (
75+ SELECT
76+ log_id,
77+ task_id,
78+ step_id,
79+ run_id,
80+ seq,
81+ stream,
82+ level,
83+ message,
84+ created_at
85+ FROM task_logs
86+ WHERE task_id = ?
87+ AND run_id = ?
88+ ORDER BY created_at DESC, log_id DESC
89+ LIMIT ?
90+ ) AS recent_logs
91+ ORDER BY created_at ASC, log_id ASC
92+`;
93+
94 export const INSERT_TASK_CHECKPOINT_SQL = `
95 INSERT INTO task_checkpoints (
96 checkpoint_id,
97@@ -1439,6 +1510,145 @@ function taskArtifactParams(record: TaskArtifactRecord): D1Bindable[] {
98 ];
99 }
100
101+function sqliteQueryMeta(extra: D1ResultMeta = {}): D1ResultMeta {
102+ return {
103+ changes: 0,
104+ changed_db: false,
105+ ...extra
106+ };
107+}
108+
109+export interface SqliteD1DatabaseOptions {
110+ schemaSql: string;
111+}
112+
113+class SqliteD1PreparedStatement implements D1PreparedStatementLike {
114+ constructor(
115+ private readonly db: DatabaseSync,
116+ private readonly query: string,
117+ private readonly params: D1Bindable[] = []
118+ ) {}
119+
120+ bind(...values: D1Bindable[]): D1PreparedStatementLike {
121+ return new SqliteD1PreparedStatement(this.db, this.query, values);
122+ }
123+
124+ async all<T = DatabaseRow>(): Promise<D1QueryResult<T>> {
125+ const statement = this.db.prepare(this.query);
126+ const results = statement.all(...this.params) as T[];
127+
128+ return {
129+ success: true,
130+ results,
131+ meta: sqliteQueryMeta({
132+ rows_read: results.length
133+ })
134+ };
135+ }
136+
137+ async first<T = DatabaseRow>(columnName?: string): Promise<T | null> {
138+ const statement = this.db.prepare(this.query);
139+ const row = statement.get(...this.params) as T | null;
140+
141+ if (row == null) {
142+ return null;
143+ }
144+
145+ if (!columnName) {
146+ return row;
147+ }
148+
149+ return ((row as DatabaseRow)[columnName] ?? null) as T | null;
150+ }
151+
152+ async raw<T = unknown[]>(options: { columnNames?: boolean } = {}): Promise<T[]> {
153+ const statement = this.db.prepare(this.query);
154+ const columns = statement.columns().map((column) => column.name) as unknown as T;
155+ statement.setReturnArrays(true);
156+
157+ const rows = statement.all(...this.params) as T[];
158+
159+ if (options.columnNames) {
160+ return [columns, ...rows];
161+ }
162+
163+ return rows;
164+ }
165+
166+ async run(): Promise<D1QueryResult<never>> {
167+ const statement = this.db.prepare(this.query);
168+ const result = statement.run(...this.params);
169+ const lastRowId =
170+ result.lastInsertRowid == null ? undefined : Number(result.lastInsertRowid);
171+
172+ return {
173+ success: true,
174+ meta: sqliteQueryMeta({
175+ changes: result.changes ?? 0,
176+ changed_db: (result.changes ?? 0) > 0,
177+ last_row_id: lastRowId,
178+ rows_written: result.changes ?? 0
179+ })
180+ };
181+ }
182+}
183+
184+export class SqliteD1Database implements D1DatabaseLike {
185+ private readonly db: DatabaseSync;
186+
187+ constructor(
188+ private readonly databasePath: string,
189+ options: SqliteD1DatabaseOptions
190+ ) {
191+ if (databasePath !== ":memory:") {
192+ mkdirSync(dirname(databasePath), {
193+ recursive: true
194+ });
195+ }
196+
197+ this.db = new DatabaseSync(databasePath);
198+ this.db.exec("PRAGMA foreign_keys = ON;");
199+ this.db.exec(options.schemaSql);
200+ }
201+
202+ prepare(query: string): D1PreparedStatementLike {
203+ return new SqliteD1PreparedStatement(this.db, query);
204+ }
205+
206+ async exec(query: string): Promise<D1ExecResult> {
207+ this.db.exec(query);
208+ return {
209+ count: 0,
210+ duration: 0
211+ };
212+ }
213+
214+ async batch<T = never>(statements: D1PreparedStatementLike[]): Promise<Array<D1QueryResult<T>>> {
215+ const results: Array<D1QueryResult<T>> = [];
216+ this.db.exec("BEGIN;");
217+
218+ try {
219+ for (const statement of statements) {
220+ results.push((await statement.run()) as D1QueryResult<T>);
221+ }
222+
223+ this.db.exec("COMMIT;");
224+ return results;
225+ } catch (error) {
226+ this.db.exec("ROLLBACK;");
227+ throw error;
228+ }
229+ }
230+
231+ close(): void {
232+ this.db.close();
233+ }
234+
235+ getDatabasePath(): string {
236+ return this.databasePath;
237+ }
238+}
239+
240 export class D1ControlPlaneRepository implements ControlPlaneRepository {
241 private readonly db: D1DatabaseLike;
242
243@@ -1548,6 +1758,15 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
244 return rows.map(mapControllerRow);
245 }
246
247+ async listTaskLogs(taskId: string, options: ListTaskLogsOptions = {}): Promise<TaskLogRecord[]> {
248+ const limit = options.limit ?? 200;
249+ const query = options.runId == null ? SELECT_TASK_LOGS_SQL : SELECT_TASK_LOGS_BY_RUN_SQL;
250+ const params =
251+ options.runId == null ? [taskId, limit] : [taskId, options.runId, limit];
252+ const rows = await this.fetchAll(query, params);
253+ return rows.map(mapTaskLogRow);
254+ }
255+
256 async listRuns(options: ListRunsOptions = {}): Promise<TaskRunRecord[]> {
257 const rows = await this.fetchAll(SELECT_RUNS_SQL, [options.limit ?? 20]);
258 return rows.map(mapTaskRunRow);
+38,
-0
1@@ -0,0 +1,38 @@
2+declare module "node:fs" {
3+ export function mkdirSync(
4+ path: string,
5+ options?: {
6+ recursive?: boolean;
7+ }
8+ ): void;
9+}
10+
11+declare module "node:path" {
12+ export function dirname(path: string): string;
13+}
14+
15+declare module "node:sqlite" {
16+ export interface DatabaseColumnDefinition {
17+ name: string;
18+ }
19+
20+ export interface StatementRunResult {
21+ changes?: number;
22+ lastInsertRowid?: number | bigint;
23+ }
24+
25+ export class StatementSync {
26+ all(...params: unknown[]): unknown[];
27+ columns(): DatabaseColumnDefinition[];
28+ get(...params: unknown[]): unknown;
29+ run(...params: unknown[]): StatementRunResult;
30+ setReturnArrays(enabled: boolean): void;
31+ }
32+
33+ export class DatabaseSync {
34+ constructor(path: string);
35+ close(): void;
36+ exec(query: string): void;
37+ prepare(query: string): StatementSync;
38+ }
39+}
+2,
-2
1@@ -1,9 +1,9 @@
2 {
3 "extends": "../../tsconfig.base.json",
4 "compilerOptions": {
5+ "declaration": true,
6 "rootDir": "src",
7 "outDir": "dist"
8 },
9- "include": ["src/**/*.ts"]
10+ "include": ["src/**/*.ts", "src/**/*.d.ts"]
11 }
12-