baa-conductor

git clone 

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
M README.md
+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` 会基于它继续接。
M apps/conductor-daemon/package.json
+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 }
A apps/conductor-daemon/src/http-types.ts
+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+}
M apps/conductor-daemon/src/index.test.js
+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 });
M apps/conductor-daemon/src/index.ts
+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 
A apps/conductor-daemon/src/local-api.ts
+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+}
A apps/conductor-daemon/src/local-control-plane.ts
+64, -0
 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+}
M apps/conductor-daemon/src/node-shims.d.ts
+16, -0
 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+}
A coordination/tasks/T-L003.md
+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` 作为唯一业务真相源
M docs/api/README.md
+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 ```
M packages/db/package.json
+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 }
M packages/db/src/index.test.js
+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+});
M packages/db/src/index.ts
+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);
A packages/db/src/node-shims.d.ts
+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+}
M packages/db/tsconfig.json
+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-