baa-conductor

git clone 

commit
ce59af5
parent
6505a31
author
im_wower
date
2026-03-22 01:47:04 +0800 CST
feat(control-api): add local d1 smoke harness
9 files changed,  +682, -8
M apps/control-api-worker/.dev.vars.example
+1, -0
1@@ -1,6 +1,7 @@
2 # Copy to .dev.vars or .dev.vars.production for local wrangler dev.
3 # Keep the deployed Worker secrets in Cloudflare; do not commit real values.
4 
5+CONTROL_API_AUTH_REQUIRED=true
6 BAA_SHARED_TOKEN=replace-me
7 CONTROL_API_BROWSER_ADMIN_TOKEN=replace-me
8 CONTROL_API_CONTROLLER_TOKEN=replace-me
A apps/control-api-worker/local/dev.mjs
+168, -0
  1@@ -0,0 +1,168 @@
  2+import { createServer } from "node:http";
  3+import { Readable } from "node:stream";
  4+import { parseArgs } from "node:util";
  5+
  6+import {
  7+  createLocalControlApiHarness,
  8+  runMinimalControlApiSmoke
  9+} from "./harness.mjs";
 10+import { DEFAULT_LOCAL_CONTROL_API_DB_PATH } from "./sqlite-d1.mjs";
 11+
 12+const cliArgs = process.argv.slice(2).filter((value) => value !== "--");
 13+const { values } = parseArgs({
 14+  args: cliArgs,
 15+  options: {
 16+    db: {
 17+      type: "string",
 18+      default: DEFAULT_LOCAL_CONTROL_API_DB_PATH
 19+    },
 20+    host: {
 21+      type: "string",
 22+      default: "127.0.0.1"
 23+    },
 24+    port: {
 25+      type: "string",
 26+      default: "8788"
 27+    },
 28+    resetDb: {
 29+      type: "boolean",
 30+      default: false
 31+    },
 32+    smoke: {
 33+      type: "boolean",
 34+      default: false
 35+    }
 36+  }
 37+});
 38+
 39+const port = Number.parseInt(values.port, 10);
 40+
 41+if (!Number.isInteger(port) || port <= 0) {
 42+  throw new Error(`Invalid --port value: ${values.port}`);
 43+}
 44+
 45+const harness = await createLocalControlApiHarness({
 46+  databasePath: values.db,
 47+  resetDatabase: values.resetDb
 48+});
 49+
 50+if (values.smoke) {
 51+  await runMinimalControlApiSmoke({
 52+    harness,
 53+    log: (message) => {
 54+      console.log(`[smoke] ${message}`);
 55+    }
 56+  });
 57+}
 58+
 59+const server = createServer(async (nodeRequest, nodeResponse) => {
 60+  try {
 61+    const body = nodeRequest.method === "GET" ? undefined : Readable.toWeb(nodeRequest);
 62+    const request = new Request(new URL(nodeRequest.url ?? "/", `http://${values.host}:${port}`), {
 63+      method: nodeRequest.method ?? "GET",
 64+      headers: toFetchHeaders(nodeRequest.headers),
 65+      body,
 66+      duplex: body ? "half" : undefined
 67+    });
 68+    const response = await harness.handle(request);
 69+
 70+    nodeResponse.statusCode = response.status;
 71+
 72+    for (const [name, value] of response.headers) {
 73+      nodeResponse.setHeader(name, value);
 74+    }
 75+
 76+    if (!response.body) {
 77+      nodeResponse.end();
 78+      return;
 79+    }
 80+
 81+    nodeResponse.end(Buffer.from(await response.arrayBuffer()));
 82+  } catch (error) {
 83+    nodeResponse.statusCode = 500;
 84+    nodeResponse.setHeader("content-type", "application/json; charset=utf-8");
 85+    nodeResponse.end(
 86+      JSON.stringify(
 87+        {
 88+          ok: false,
 89+          error: "local_dev_error",
 90+          message: error instanceof Error ? error.message : String(error)
 91+        },
 92+        null,
 93+        2
 94+      )
 95+    );
 96+  }
 97+});
 98+
 99+function toFetchHeaders(nodeHeaders) {
100+  const headers = new Headers();
101+
102+  for (const [name, value] of Object.entries(nodeHeaders)) {
103+    if (Array.isArray(value)) {
104+      for (const entry of value) {
105+        headers.append(name, entry);
106+      }
107+
108+      continue;
109+    }
110+
111+    if (value != null) {
112+      headers.set(name, value);
113+    }
114+  }
115+
116+  return headers;
117+}
118+
119+function printBanner() {
120+  const baseUrl = `http://${values.host}:${port}/`;
121+
122+  console.log(`control-api local dev listening on ${baseUrl}`);
123+  console.log(`local sqlite path: ${harness.databasePath}`);
124+  console.log(`readonly token: ${harness.tokens.readonly}`);
125+  console.log(`browser_admin token: ${harness.tokens.browserAdmin}`);
126+  console.log(`controller token: ${harness.tokens.controller}`);
127+  console.log("example reads:");
128+  console.log(
129+    `curl -H 'Authorization: Bearer ${harness.tokens.readonly}' ${baseUrl}v1/system/state`
130+  );
131+  console.log("example writes:");
132+  console.log(
133+    `curl -X POST -H 'Authorization: Bearer ${harness.tokens.browserAdmin}' -H 'Content-Type: application/json' ${baseUrl}v1/system/pause -d '{\"reason\":\"manual test\"}'`
134+  );
135+}
136+
137+let shuttingDown = false;
138+
139+async function shutdown(signal) {
140+  if (shuttingDown) {
141+    return;
142+  }
143+
144+  shuttingDown = true;
145+  console.log(`received ${signal}, closing local control-api dev server`);
146+
147+  await new Promise((resolve) => {
148+    server.close(() => {
149+      harness.close();
150+      resolve();
151+    });
152+  });
153+}
154+
155+process.on("SIGINT", async () => {
156+  await shutdown("SIGINT");
157+  process.exit(0);
158+});
159+
160+process.on("SIGTERM", async () => {
161+  await shutdown("SIGTERM");
162+  process.exit(0);
163+});
164+
165+await new Promise((resolve) => {
166+  server.listen(port, values.host, resolve);
167+});
168+
169+printBanner();
A apps/control-api-worker/local/harness.mjs
+233, -0
  1@@ -0,0 +1,233 @@
  2+import assert from "node:assert/strict";
  3+
  4+import {
  5+  DEFAULT_LOCAL_CONTROL_API_DB_PATH,
  6+  prepareLocalControlApiDatabase
  7+} from "./sqlite-d1.mjs";
  8+
  9+const CONTROL_API_DIST_ENTRY_URL = new URL("../dist/index.js", import.meta.url);
 10+
 11+export const DEFAULT_LOCAL_CONTROL_API_BASE_URL = "http://127.0.0.1";
 12+export const LOCAL_CONTROL_API_TOKENS = Object.freeze({
 13+  shared: "local-shared-token",
 14+  browserAdmin: "local-browser-admin-token",
 15+  controller: "local-controller-token",
 16+  opsAdmin: "local-ops-admin-token",
 17+  readonly: "local-readonly-token",
 18+  worker: "local-worker-token"
 19+});
 20+
 21+async function loadControlApiModule() {
 22+  return import(CONTROL_API_DIST_ENTRY_URL.href);
 23+}
 24+
 25+function buildLocalControlApiEnv(controlApiModule, database, tokens, authRequired) {
 26+  return {
 27+    [controlApiModule.CONTROL_API_D1_BINDING_NAME]: database,
 28+    [controlApiModule.CONTROL_API_VERSION_ENV_NAME]: "local-smoke",
 29+    [controlApiModule.CONTROL_API_AUTH_REQUIRED_ENV_NAME]: authRequired ? "true" : "false",
 30+    [controlApiModule.BAA_SHARED_TOKEN_ENV_NAME]: tokens.shared,
 31+    [controlApiModule.CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME]: tokens.browserAdmin,
 32+    [controlApiModule.CONTROL_API_CONTROLLER_TOKEN_ENV_NAME]: tokens.controller,
 33+    [controlApiModule.CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME]: tokens.opsAdmin,
 34+    [controlApiModule.CONTROL_API_READONLY_TOKEN_ENV_NAME]: tokens.readonly,
 35+    [controlApiModule.CONTROL_API_WORKER_TOKEN_ENV_NAME]: tokens.worker
 36+  };
 37+}
 38+
 39+export async function createLocalControlApiHarness(options = {}) {
 40+  const {
 41+    authRequired = true,
 42+    databasePath = DEFAULT_LOCAL_CONTROL_API_DB_PATH,
 43+    resetDatabase = false,
 44+    tokens = LOCAL_CONTROL_API_TOKENS
 45+  } = options;
 46+  const database = prepareLocalControlApiDatabase({
 47+    databasePath,
 48+    resetDatabase
 49+  });
 50+  const controlApiModule = await loadControlApiModule();
 51+  const env = buildLocalControlApiEnv(controlApiModule, database, tokens, authRequired);
 52+  const worker = controlApiModule.default;
 53+
 54+  return {
 55+    controlApiModule,
 56+    database,
 57+    databasePath: database.databasePath,
 58+    env,
 59+    tokens,
 60+    async handle(request) {
 61+      return worker.fetch(request, env, {});
 62+    },
 63+    async request({ body, headers = {}, method = "GET", path, token } = {}) {
 64+      const requestHeaders = new Headers(headers);
 65+      let requestBody;
 66+
 67+      if (token) {
 68+        requestHeaders.set("authorization", `Bearer ${token}`);
 69+      }
 70+
 71+      if (body !== undefined) {
 72+        requestBody = typeof body === "string" ? body : JSON.stringify(body);
 73+        requestHeaders.set("content-type", "application/json; charset=utf-8");
 74+      }
 75+
 76+      const request = new Request(new URL(path, DEFAULT_LOCAL_CONTROL_API_BASE_URL), {
 77+        method,
 78+        headers: requestHeaders,
 79+        body: requestBody
 80+      });
 81+      const response = await this.handle(request);
 82+      const text = await response.text();
 83+      let json = null;
 84+
 85+      if (text.length > 0) {
 86+        try {
 87+          json = JSON.parse(text);
 88+        } catch {
 89+          json = null;
 90+        }
 91+      }
 92+
 93+      return {
 94+        headers: response.headers,
 95+        json,
 96+        status: response.status,
 97+        text
 98+      };
 99+    },
100+    close() {
101+      database.close();
102+    }
103+  };
104+}
105+
106+export async function runMinimalControlApiSmoke(options = {}) {
107+  const { databasePath = ":memory:", log = () => {}, resetDatabase = true } = options;
108+  const harness = options.harness ?? (await createLocalControlApiHarness({
109+    databasePath,
110+    resetDatabase
111+  }));
112+  const shouldCloseHarness = options.harness == null;
113+
114+  try {
115+    const initialState = await harness.request({
116+      method: "GET",
117+      path: "/v1/system/state",
118+      token: harness.tokens.readonly
119+    });
120+    assert.equal(initialState.status, 200);
121+    assert.equal(initialState.json?.data?.mode, "running");
122+    log("system.state initial read succeeded");
123+
124+    const pauseResult = await harness.request({
125+      method: "POST",
126+      path: "/v1/system/pause",
127+      token: harness.tokens.browserAdmin,
128+      body: {
129+        reason: "local smoke",
130+        requested_by: "local-smoke"
131+      }
132+    });
133+    assert.equal(pauseResult.status, 200);
134+    assert.equal(pauseResult.json?.data?.status, "applied");
135+    log("system.pause write succeeded");
136+
137+    const pausedState = await harness.request({
138+      method: "GET",
139+      path: "/v1/system/state",
140+      token: harness.tokens.readonly
141+    });
142+    assert.equal(pausedState.status, 200);
143+    assert.equal(pausedState.json?.data?.mode, "paused");
144+    log("system.state reflects pause");
145+
146+    const heartbeatResult = await harness.request({
147+      method: "POST",
148+      path: "/v1/controllers/heartbeat",
149+      token: harness.tokens.controller,
150+      body: {
151+        controller_id: "mini-local",
152+        host: "127.0.0.1",
153+        metadata: {
154+          source: "local-smoke"
155+        },
156+        priority: 1,
157+        role: "primary",
158+        status: "active",
159+        version: "local-smoke"
160+      }
161+    });
162+    assert.equal(heartbeatResult.status, 200);
163+    log("controllers.heartbeat write succeeded");
164+
165+    const leaderLease = await harness.request({
166+      method: "POST",
167+      path: "/v1/leader/acquire",
168+      token: harness.tokens.controller,
169+      body: {
170+        controller_id: "mini-local",
171+        host: "127.0.0.1",
172+        preferred: true,
173+        ttl_sec: 30
174+      }
175+    });
176+    assert.equal(leaderLease.status, 200);
177+    assert.equal(leaderLease.json?.data?.holder_id, "mini-local");
178+    assert.equal(leaderLease.json?.data?.is_leader, true);
179+    log("leader.acquire write succeeded");
180+
181+    const leasedState = await harness.request({
182+      method: "GET",
183+      path: "/v1/system/state",
184+      token: harness.tokens.readonly
185+    });
186+    assert.equal(leasedState.status, 200);
187+    assert.equal(leasedState.json?.data?.holder_id, "mini-local");
188+    assert.equal(leasedState.json?.data?.mode, "paused");
189+    log("system.state reflects active lease");
190+
191+    const createdTask = await harness.request({
192+      method: "POST",
193+      path: "/v1/tasks",
194+      token: harness.tokens.browserAdmin,
195+      body: {
196+        acceptance: ["minimal smoke closes a read/write loop"],
197+        goal: "Verify local control-api D1 smoke",
198+        metadata: {
199+          requested_by: "local-smoke"
200+        },
201+        repo: "/Users/george/code/baa-conductor",
202+        task_type: "smoke",
203+        title: "Local control-api smoke"
204+      }
205+    });
206+    assert.equal(createdTask.status, 201);
207+    assert.match(createdTask.json?.data?.task_id ?? "", /^task_/u);
208+    log("tasks.create write succeeded");
209+
210+    const taskId = createdTask.json.data.task_id;
211+    const readTask = await harness.request({
212+      method: "GET",
213+      path: `/v1/tasks/${taskId}`,
214+      token: harness.tokens.readonly
215+    });
216+    assert.equal(readTask.status, 200);
217+    assert.equal(readTask.json?.data?.task_id, taskId);
218+    assert.equal(readTask.json?.data?.status, "queued");
219+    log("tasks.read reflects persisted task");
220+
221+    return {
222+      database_path: harness.databasePath,
223+      holder_id: leasedState.json.data.holder_id,
224+      mode: leasedState.json.data.mode,
225+      task_id: taskId,
226+      task_status: readTask.json.data.status,
227+      term: leaderLease.json.data.term
228+    };
229+  } finally {
230+    if (shouldCloseHarness) {
231+      harness.close();
232+    }
233+  }
234+}
A apps/control-api-worker/local/smoke.mjs
+27, -0
 1@@ -0,0 +1,27 @@
 2+import { parseArgs } from "node:util";
 3+
 4+import { runMinimalControlApiSmoke } from "./harness.mjs";
 5+
 6+const cliArgs = process.argv.slice(2).filter((value) => value !== "--");
 7+const { values } = parseArgs({
 8+  args: cliArgs,
 9+  options: {
10+    db: {
11+      type: "string"
12+    },
13+    resetDb: {
14+      type: "boolean",
15+      default: false
16+    }
17+  }
18+});
19+
20+const summary = await runMinimalControlApiSmoke({
21+  databasePath: values.db ?? ":memory:",
22+  log: (message) => {
23+    console.log(`[smoke] ${message}`);
24+  },
25+  resetDatabase: values.db ? values.resetDb : true
26+});
27+
28+console.log(JSON.stringify(summary, null, 2));
A apps/control-api-worker/local/sqlite-d1.mjs
+166, -0
  1@@ -0,0 +1,166 @@
  2+import { mkdirSync, readFileSync, rmSync } from "node:fs";
  3+import { dirname, resolve } from "node:path";
  4+import { DatabaseSync } from "node:sqlite";
  5+import { fileURLToPath } from "node:url";
  6+
  7+const APP_DIR = fileURLToPath(new URL("..", import.meta.url));
  8+const REPO_ROOT = resolve(APP_DIR, "../..");
  9+
 10+export const DEFAULT_CONTROL_API_SCHEMA_PATH = resolve(REPO_ROOT, "ops/sql/schema.sql");
 11+export const DEFAULT_LOCAL_CONTROL_API_DB_PATH = resolve(
 12+  APP_DIR,
 13+  ".wrangler/state/local-control-api.sqlite"
 14+);
 15+
 16+function queryMeta(extra = {}) {
 17+  return {
 18+    changes: 0,
 19+    changed_db: false,
 20+    ...extra
 21+  };
 22+}
 23+
 24+export function resolveLocalControlApiDatabasePath(databasePath = DEFAULT_LOCAL_CONTROL_API_DB_PATH) {
 25+  if (!databasePath || databasePath === ":memory:") {
 26+    return ":memory:";
 27+  }
 28+
 29+  return resolve(process.cwd(), databasePath);
 30+}
 31+
 32+class SqliteD1PreparedStatement {
 33+  constructor(db, query, params = []) {
 34+    this.db = db;
 35+    this.query = query;
 36+    this.params = params;
 37+  }
 38+
 39+  bind(...values) {
 40+    return new SqliteD1PreparedStatement(this.db, this.query, values);
 41+  }
 42+
 43+  async all() {
 44+    const statement = this.db.prepare(this.query);
 45+    const results = statement.all(...this.params);
 46+
 47+    return {
 48+      success: true,
 49+      results,
 50+      meta: queryMeta({
 51+        rows_read: results.length
 52+      })
 53+    };
 54+  }
 55+
 56+  async first(columnName) {
 57+    const statement = this.db.prepare(this.query);
 58+    const row = statement.get(...this.params);
 59+
 60+    if (row == null) {
 61+      return null;
 62+    }
 63+
 64+    if (!columnName) {
 65+      return row;
 66+    }
 67+
 68+    return row[columnName] ?? null;
 69+  }
 70+
 71+  async raw(options = {}) {
 72+    const statement = this.db.prepare(this.query);
 73+    const columns = statement.columns().map((column) => column.name);
 74+    statement.setReturnArrays(true);
 75+
 76+    const rows = statement.all(...this.params);
 77+
 78+    if (options.columnNames) {
 79+      return [columns, ...rows];
 80+    }
 81+
 82+    return rows;
 83+  }
 84+
 85+  async run() {
 86+    const statement = this.db.prepare(this.query);
 87+    const result = statement.run(...this.params);
 88+    const lastRowId =
 89+      result.lastInsertRowid == null ? undefined : Number(result.lastInsertRowid);
 90+
 91+    return {
 92+      success: true,
 93+      meta: queryMeta({
 94+        changes: result.changes ?? 0,
 95+        changed_db: (result.changes ?? 0) > 0,
 96+        last_row_id: lastRowId,
 97+        rows_written: result.changes ?? 0
 98+      })
 99+    };
100+  }
101+}
102+
103+export class SqliteD1Database {
104+  constructor(databasePath, { schemaPath = DEFAULT_CONTROL_API_SCHEMA_PATH } = {}) {
105+    this.databasePath = resolveLocalControlApiDatabasePath(databasePath);
106+
107+    if (this.databasePath !== ":memory:") {
108+      mkdirSync(dirname(this.databasePath), { recursive: true });
109+    }
110+
111+    this.db = new DatabaseSync(this.databasePath);
112+    this.db.exec("PRAGMA foreign_keys = ON;");
113+    this.db.exec(readFileSync(schemaPath, "utf8"));
114+  }
115+
116+  prepare(query) {
117+    return new SqliteD1PreparedStatement(this.db, query);
118+  }
119+
120+  async exec(query) {
121+    this.db.exec(query);
122+    return {
123+      count: 0,
124+      duration: 0
125+    };
126+  }
127+
128+  async batch(statements) {
129+    const results = [];
130+    this.db.exec("BEGIN;");
131+
132+    try {
133+      for (const statement of statements) {
134+        results.push(await statement.run());
135+      }
136+
137+      this.db.exec("COMMIT;");
138+      return results;
139+    } catch (error) {
140+      this.db.exec("ROLLBACK;");
141+      throw error;
142+    }
143+  }
144+
145+  close() {
146+    this.db.close();
147+  }
148+}
149+
150+export function prepareLocalControlApiDatabase(options = {}) {
151+  const {
152+    databasePath = DEFAULT_LOCAL_CONTROL_API_DB_PATH,
153+    resetDatabase = false,
154+    schemaPath = DEFAULT_CONTROL_API_SCHEMA_PATH
155+  } = options;
156+  const resolvedPath = resolveLocalControlApiDatabasePath(databasePath);
157+
158+  if (resetDatabase && resolvedPath !== ":memory:") {
159+    rmSync(resolvedPath, {
160+      force: true
161+    });
162+  }
163+
164+  return new SqliteD1Database(resolvedPath, {
165+    schemaPath
166+  });
167+}
M apps/control-api-worker/package.json
+4, -0
 1@@ -6,6 +6,10 @@
 2   "scripts": {
 3     "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/control-api-worker/dist BAA_DIST_ENTRY=apps/control-api-worker/src/index.js BAA_IMPORT_ALIASES='@baa-conductor/auth=../../../packages/auth/src/index.js;@baa-conductor/db=../../../packages/db/src/index.js' BAA_FIX_RELATIVE_EXTENSIONS=true BAA_EXPORT_DEFAULT=true pnpm -C ../.. run build:runtime-postprocess",
 4     "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json",
 5+    "db:prepare:local": "node ../../scripts/cloudflare/prepare-control-api-local-db.mjs",
 6+    "dev": "pnpm run build && node local/dev.mjs",
 7+    "smoke": "pnpm run build && node local/smoke.mjs",
 8+    "test:integration": "pnpm run build && node --test ../../tests/control-api/control-api-smoke.test.mjs",
 9     "deploy:cloudflare": "bash ../../ops/cloudflare/deploy-control-api-worker.sh",
10     "migrate:d1:remote": "bash ../../ops/cloudflare/apply-control-api-d1-migrations.sh",
11     "tail:cloudflare": "npx --yes wrangler@4 tail --config wrangler.jsonc"
M coordination/tasks/T-023-control-api-smoke.md
+36, -8
 1@@ -1,17 +1,17 @@
 2 ---
 3 task_id: T-023
 4 title: Control API 本地 D1 与 smoke
 5-status: todo
 6+status: review
 7 branch: feat/T-023-control-api-smoke
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@6505a31
11 depends_on:
12   - T-018
13 write_scope:
14   - apps/control-api-worker/**
15   - tests/control-api/**
16   - scripts/cloudflare/**
17-updated_at: 2026-03-22
18+updated_at: 2026-03-22T01:45:46+0800
19 ---
20 
21 # T-023 Control API 本地 D1 与 smoke
22@@ -65,20 +65,48 @@ updated_at: 2026-03-22
23 
24 ## files_changed
25 
26-- 待填写
27+- `coordination/tasks/T-023-control-api-smoke.md`
28+- `apps/control-api-worker/.dev.vars.example`
29+- `apps/control-api-worker/package.json`
30+- `apps/control-api-worker/local/sqlite-d1.mjs`
31+- `apps/control-api-worker/local/harness.mjs`
32+- `apps/control-api-worker/local/dev.mjs`
33+- `apps/control-api-worker/local/smoke.mjs`
34+- `scripts/cloudflare/prepare-control-api-local-db.mjs`
35+- `tests/control-api/control-api-smoke.test.mjs`
36 
37 ## commands_run
38 
39-- 待填写
40+- `git worktree add /Users/george/code/baa-conductor-T023 -b feat/T-023-control-api-smoke 6505a31`
41+- `npx --yes pnpm install`
42+- `node --check apps/control-api-worker/local/sqlite-d1.mjs`
43+- `node --check apps/control-api-worker/local/harness.mjs`
44+- `node --check apps/control-api-worker/local/dev.mjs`
45+- `node --check apps/control-api-worker/local/smoke.mjs`
46+- `node --check scripts/cloudflare/prepare-control-api-local-db.mjs`
47+- `node --check tests/control-api/control-api-smoke.test.mjs`
48+- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
49+- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
50+- `npx --yes pnpm --filter @baa-conductor/control-api-worker db:prepare:local`
51+- `npx --yes pnpm --filter @baa-conductor/control-api-worker smoke -- --db .wrangler/state/local-control-api.sqlite --resetDb`
52+- `npx --yes pnpm --filter @baa-conductor/control-api-worker test:integration`
53+- `node local/dev.mjs --smoke --resetDb`
54+- `git diff --check`
55 
56 ## result
57 
58-- 待填写
59+- 在 `apps/control-api-worker/local/**` 新增了 SQLite-backed D1 shim、本地 harness、`dev` server 和 `smoke` runner;`pnpm --filter @baa-conductor/control-api-worker dev` 现在会先构建,再在本地起一个可直接用 Bearer token 访问的 control-api。
60+- 在 `tests/control-api/control-api-smoke.test.mjs` 增加了最小集成 smoke,覆盖 `system.pause -> system.state`、`leader.acquire -> system.state`、`tasks.create -> tasks.read` 三条最小读写闭环。
61+- 在 `scripts/cloudflare/prepare-control-api-local-db.mjs` 增加了本地 DB 准备脚本,并在包脚本中暴露 `db:prepare:local`、`smoke`、`test:integration` 入口,方便后续联调复用。
62 
63 ## risks
64 
65-- 待填写
66+- 当前本地验证使用的是 Node `node:sqlite` 驱动实现的 D1-compatible shim,而不是 `wrangler dev` 内建的真实本地 D1 运行时;SQL 行为与当前仓库 schema 一致,但仍可能与 Cloudflare 边缘环境存在细小差异。
67+- 本次 smoke 聚焦已经实现的读写路由;`tasks.plan`、`tasks.claim`、`steps.*`、`tasks.logs.read`、`runs.read` 这些仍保持占位或未在测试中覆盖。
68+- `dev` 入口是 Node HTTP wrapper,不包含 Cloudflare 特有的 request metadata 或 Worker 生命周期细节;如果后续要验证这些差异,仍需单独补 wrangler-local smoke。
69 
70 ## next_handoff
71 
72-- 待填写
73+- 后续如果要把 control-api 纳入 `T-024` 端到端 smoke,可直接复用 `pnpm --filter @baa-conductor/control-api-worker dev` 和 `tests/control-api/control-api-smoke.test.mjs` 里的本地 harness。
74+- 如果需要更贴近 Cloudflare 本地运行时,再补一层 `wrangler dev --local` / `wrangler d1` 路径,把现在的 SQLite shim smoke 与 wrangler smoke 并排保留。
75+- 若后续开始实现 `tasks.plan`、`tasks.claim` 或 `steps.*` 的 durable 写入,应沿用当前 harness,把新增路由接到同一套本地 D1 smoke 里继续扩展。
A scripts/cloudflare/prepare-control-api-local-db.mjs
+33, -0
 1@@ -0,0 +1,33 @@
 2+import { parseArgs } from "node:util";
 3+
 4+import {
 5+  DEFAULT_LOCAL_CONTROL_API_DB_PATH,
 6+  prepareLocalControlApiDatabase,
 7+  resolveLocalControlApiDatabasePath
 8+} from "../../apps/control-api-worker/local/sqlite-d1.mjs";
 9+
10+const cliArgs = process.argv.slice(2).filter((value) => value !== "--");
11+const { values } = parseArgs({
12+  args: cliArgs,
13+  options: {
14+    db: {
15+      type: "string",
16+      default: DEFAULT_LOCAL_CONTROL_API_DB_PATH
17+    },
18+    resetDb: {
19+      type: "boolean",
20+      default: false
21+    }
22+  }
23+});
24+
25+const database = prepareLocalControlApiDatabase({
26+  databasePath: values.db,
27+  resetDatabase: values.resetDb
28+});
29+
30+database.close();
31+
32+console.log(
33+  `Prepared local control-api sqlite database at ${resolveLocalControlApiDatabasePath(values.db)}`
34+);
A tests/control-api/control-api-smoke.test.mjs
+14, -0
 1@@ -0,0 +1,14 @@
 2+import assert from "node:assert/strict";
 3+import test from "node:test";
 4+
 5+import { runMinimalControlApiSmoke } from "../../apps/control-api-worker/local/harness.mjs";
 6+
 7+test("control-api smoke completes a minimal local D1 read/write loop", async () => {
 8+  const summary = await runMinimalControlApiSmoke();
 9+
10+  assert.equal(summary.mode, "paused");
11+  assert.equal(summary.holder_id, "mini-local");
12+  assert.equal(summary.task_status, "queued");
13+  assert.match(summary.task_id, /^task_/u);
14+  assert.ok(summary.term >= 1);
15+});