- 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
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
+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();
+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+}
+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));
+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+}
+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"
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 里继续扩展。
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+);
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+});