baa-conductor

git clone 

commit
3cf57d0
parent
70f8a64
author
im_wower
date
2026-03-22 18:54:12 +0800 CST
feat: remove worker control plane surface
31 files changed,  +215, -3596
D apps/control-api-worker/.dev.vars.example
+0, -13
 1@@ -1,13 +0,0 @@
 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=false
 6-# Temporary single-node mode currently runs without Control API auth.
 7-# If you later want to re-enable auth, set CONTROL_API_AUTH_REQUIRED=true
 8-# and provide the tokens below.
 9-# BAA_SHARED_TOKEN=replace-me
10-# CONTROL_API_BROWSER_ADMIN_TOKEN=replace-me
11-# CONTROL_API_CONTROLLER_TOKEN=replace-me
12-# CONTROL_API_OPS_ADMIN_TOKEN=replace-me
13-# CONTROL_API_READONLY_TOKEN=replace-me
14-# CONTROL_API_WORKER_TOKEN=replace-me
D apps/control-api-worker/local/dev.mjs
+0, -168
  1@@ -1,168 +0,0 @@
  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();
D apps/control-api-worker/local/harness.mjs
+0, -239
  1@@ -1,239 +0,0 @@
  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 {
108-    databasePath = ":memory:",
109-    log = () => {},
110-    requestWithoutToken = false,
111-    resetDatabase = true
112-  } = options;
113-  const harness = options.harness ?? (await createLocalControlApiHarness({
114-    authRequired: options.authRequired,
115-    databasePath,
116-    resetDatabase
117-  }));
118-  const shouldCloseHarness = options.harness == null;
119-
120-  try {
121-    const initialState = await harness.request({
122-      method: "GET",
123-      path: "/v1/system/state",
124-      token: requestWithoutToken ? undefined : harness.tokens.readonly
125-    });
126-    assert.equal(initialState.status, 200);
127-    assert.equal(initialState.json?.data?.mode, "running");
128-    log("system.state initial read succeeded");
129-
130-    const pauseResult = await harness.request({
131-      method: "POST",
132-      path: "/v1/system/pause",
133-      token: requestWithoutToken ? undefined : harness.tokens.browserAdmin,
134-      body: {
135-        reason: "local smoke",
136-        requested_by: "local-smoke"
137-      }
138-    });
139-    assert.equal(pauseResult.status, 200);
140-    assert.equal(pauseResult.json?.data?.status, "applied");
141-    log("system.pause write succeeded");
142-
143-    const pausedState = await harness.request({
144-      method: "GET",
145-      path: "/v1/system/state",
146-      token: requestWithoutToken ? undefined : harness.tokens.readonly
147-    });
148-    assert.equal(pausedState.status, 200);
149-    assert.equal(pausedState.json?.data?.mode, "paused");
150-    log("system.state reflects pause");
151-
152-    const heartbeatResult = await harness.request({
153-      method: "POST",
154-      path: "/v1/controllers/heartbeat",
155-      token: requestWithoutToken ? undefined : harness.tokens.controller,
156-      body: {
157-        controller_id: "mini-local",
158-        host: "127.0.0.1",
159-        metadata: {
160-          source: "local-smoke"
161-        },
162-        priority: 1,
163-        role: "primary",
164-        status: "active",
165-        version: "local-smoke"
166-      }
167-    });
168-    assert.equal(heartbeatResult.status, 200);
169-    log("controllers.heartbeat write succeeded");
170-
171-    const leaderLease = await harness.request({
172-      method: "POST",
173-      path: "/v1/leader/acquire",
174-      token: requestWithoutToken ? undefined : harness.tokens.controller,
175-      body: {
176-        controller_id: "mini-local",
177-        host: "127.0.0.1",
178-        preferred: true,
179-        ttl_sec: 30
180-      }
181-    });
182-    assert.equal(leaderLease.status, 200);
183-    assert.equal(leaderLease.json?.data?.holder_id, "mini-local");
184-    assert.equal(leaderLease.json?.data?.is_leader, true);
185-    log("leader.acquire write succeeded");
186-
187-    const leasedState = await harness.request({
188-      method: "GET",
189-      path: "/v1/system/state",
190-      token: requestWithoutToken ? undefined : harness.tokens.readonly
191-    });
192-    assert.equal(leasedState.status, 200);
193-    assert.equal(leasedState.json?.data?.holder_id, "mini-local");
194-    assert.equal(leasedState.json?.data?.mode, "paused");
195-    log("system.state reflects active lease");
196-
197-    const createdTask = await harness.request({
198-      method: "POST",
199-      path: "/v1/tasks",
200-      token: requestWithoutToken ? undefined : harness.tokens.browserAdmin,
201-      body: {
202-        acceptance: ["minimal smoke closes a read/write loop"],
203-        goal: "Verify local control-api D1 smoke",
204-        metadata: {
205-          requested_by: "local-smoke"
206-        },
207-        repo: "/Users/george/code/baa-conductor",
208-        task_type: "smoke",
209-        title: "Local control-api smoke"
210-      }
211-    });
212-    assert.equal(createdTask.status, 201);
213-    assert.match(createdTask.json?.data?.task_id ?? "", /^task_/u);
214-    log("tasks.create write succeeded");
215-
216-    const taskId = createdTask.json.data.task_id;
217-    const readTask = await harness.request({
218-      method: "GET",
219-      path: `/v1/tasks/${taskId}`,
220-      token: requestWithoutToken ? undefined : harness.tokens.readonly
221-    });
222-    assert.equal(readTask.status, 200);
223-    assert.equal(readTask.json?.data?.task_id, taskId);
224-    assert.equal(readTask.json?.data?.status, "queued");
225-    log("tasks.read reflects persisted task");
226-
227-    return {
228-      database_path: harness.databasePath,
229-      holder_id: leasedState.json.data.holder_id,
230-      mode: leasedState.json.data.mode,
231-      task_id: taskId,
232-      task_status: readTask.json.data.status,
233-      term: leaderLease.json.data.term
234-    };
235-  } finally {
236-    if (shouldCloseHarness) {
237-      harness.close();
238-    }
239-  }
240-}
D apps/control-api-worker/local/smoke.mjs
+0, -27
 1@@ -1,27 +0,0 @@
 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));
D apps/control-api-worker/local/sqlite-d1.mjs
+0, -166
  1@@ -1,166 +0,0 @@
  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-}
D apps/control-api-worker/package.json
+0, -17
 1@@ -1,17 +0,0 @@
 2-{
 3-  "name": "@baa-conductor/control-api-worker",
 4-  "private": true,
 5-  "type": "module",
 6-  "main": "dist/index.js",
 7-  "scripts": {
 8-    "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/control-api-worker/dist BAA_DIST_ENTRY=apps/control-api-worker/src/index.js BAA_IMPORT_ALIASES='@baa-conductor/auth=../../../packages/auth/src/index.js;@baa-conductor/db=../../../packages/db/src/index.js' BAA_FIX_RELATIVE_EXTENSIONS=true BAA_EXPORT_DEFAULT=true pnpm -C ../.. run build:runtime-postprocess",
 9-    "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json",
10-    "db:prepare:local": "node ../../scripts/cloudflare/prepare-control-api-local-db.mjs",
11-    "dev": "pnpm run build && node local/dev.mjs",
12-    "smoke": "pnpm run build && node local/smoke.mjs",
13-    "test:integration": "pnpm run build && node --test ../../tests/control-api/control-api-smoke.test.mjs",
14-    "deploy:cloudflare": "bash ../../ops/cloudflare/deploy-control-api-worker.sh",
15-    "migrate:d1:remote": "bash ../../ops/cloudflare/apply-control-api-d1-migrations.sh",
16-    "tail:cloudflare": "npx --yes wrangler@4 tail --config wrangler.jsonc"
17-  }
18-}
D apps/control-api-worker/src/contracts.ts
+0, -207
  1@@ -1,207 +0,0 @@
  2-import type {
  3-  AuthPrincipal,
  4-  AuthResourceOwnership,
  5-  AuthTokenVerifier,
  6-  ControlApiAuthRule
  7-} from "@baa-conductor/auth";
  8-import type { ControlPlaneRepository, D1DatabaseLike, JsonValue } from "@baa-conductor/db";
  9-
 10-export const CONTROL_API_WORKER_NAME = "baa-conductor-control-api" as const;
 11-export const CONTROL_API_WORKER_ENTRY = "dist/index.js" as const;
 12-export const CONTROL_API_CUSTOM_DOMAIN = "control-api.makefile.so" as const;
 13-export const CONTROL_API_D1_BINDING_NAME = "CONTROL_DB" as const;
 14-export const CONTROL_API_D1_DATABASE_NAME = "baa-conductor-control-prod" as const;
 15-export const CONTROL_API_VERSION_ENV_NAME = "CONTROL_API_VERSION" as const;
 16-export const CONTROL_API_AUTH_REQUIRED_ENV_NAME = "CONTROL_API_AUTH_REQUIRED" as const;
 17-export const CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME =
 18-  "CONTROL_API_BROWSER_ADMIN_TOKEN" as const;
 19-export const CONTROL_API_CONTROLLER_TOKEN_ENV_NAME = "CONTROL_API_CONTROLLER_TOKEN" as const;
 20-export const CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME = "CONTROL_API_OPS_ADMIN_TOKEN" as const;
 21-export const CONTROL_API_READONLY_TOKEN_ENV_NAME = "CONTROL_API_READONLY_TOKEN" as const;
 22-export const CONTROL_API_WORKER_TOKEN_ENV_NAME = "CONTROL_API_WORKER_TOKEN" as const;
 23-export const BAA_SHARED_TOKEN_ENV_NAME = "BAA_SHARED_TOKEN" as const;
 24-
 25-export const CONTROL_API_SECRET_ENV_NAMES = [
 26-  BAA_SHARED_TOKEN_ENV_NAME,
 27-  CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME,
 28-  CONTROL_API_CONTROLLER_TOKEN_ENV_NAME,
 29-  CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME,
 30-  CONTROL_API_READONLY_TOKEN_ENV_NAME,
 31-  CONTROL_API_WORKER_TOKEN_ENV_NAME
 32-] as const;
 33-
 34-export type ControlApiRouteMethod = "GET" | "POST";
 35-export type ControlApiRouteAccess = "public" | "protected";
 36-export type ControlApiRouteCategory =
 37-  | "discoverability"
 38-  | "controllers"
 39-  | "leader"
 40-  | "tasks"
 41-  | "steps"
 42-  | "system"
 43-  | "runs";
 44-export type ControlApiRouteImplementation = "implemented" | "placeholder";
 45-
 46-export type ControlApiRouteId =
 47-  | "service.describe"
 48-  | "service.version"
 49-  | "service.health"
 50-  | "system.capabilities"
 51-  | "controllers.list"
 52-  | "controllers.heartbeat"
 53-  | "leader.acquire"
 54-  | "tasks.list"
 55-  | "tasks.create"
 56-  | "tasks.plan"
 57-  | "tasks.claim"
 58-  | "steps.heartbeat"
 59-  | "steps.checkpoint"
 60-  | "steps.complete"
 61-  | "steps.fail"
 62-  | "system.pause"
 63-  | "system.resume"
 64-  | "system.drain"
 65-  | "system.state"
 66-  | "tasks.read"
 67-  | "tasks.logs.read"
 68-  | "runs.list"
 69-  | "runs.read";
 70-
 71-export interface ControlApiRouteSchemaDescriptor {
 72-  requestBody: string | null;
 73-  responseBody: string;
 74-  notes: string[];
 75-}
 76-
 77-export interface ControlApiEnv {
 78-  [CONTROL_API_D1_BINDING_NAME]?: D1DatabaseLike;
 79-  [CONTROL_API_VERSION_ENV_NAME]?: string;
 80-  [CONTROL_API_AUTH_REQUIRED_ENV_NAME]?: string;
 81-  [CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME]?: string;
 82-  [CONTROL_API_CONTROLLER_TOKEN_ENV_NAME]?: string;
 83-  [CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME]?: string;
 84-  [CONTROL_API_READONLY_TOKEN_ENV_NAME]?: string;
 85-  [CONTROL_API_WORKER_TOKEN_ENV_NAME]?: string;
 86-  [BAA_SHARED_TOKEN_ENV_NAME]?: string;
 87-}
 88-
 89-export interface ControlApiExecutionContext {
 90-  passThroughOnException?(): void;
 91-  waitUntil?(promise: Promise<unknown>): void;
 92-}
 93-
 94-export interface ControlApiServices {
 95-  authHook: ControlApiRequestAuthHook | null;
 96-  now: () => number;
 97-  repository: ControlPlaneRepository | null;
 98-}
 99-
100-export interface ControlApiOwnershipResolverInput {
101-  params: Record<string, string>;
102-  body: JsonValue;
103-}
104-
105-export interface ControlApiRouteAuthorization {
106-  mode: "public" | "skipped" | "verified";
107-  rule: ControlApiAuthRule | null;
108-  principal?: AuthPrincipal;
109-  skipReason?: string;
110-}
111-
112-export interface ControlApiRequestAuthInput {
113-  body: JsonValue;
114-  params: Record<string, string>;
115-  request: Request;
116-  route: ControlApiRouteDefinition;
117-  url: URL;
118-}
119-
120-export interface ControlApiRequestAuthHook {
121-  authorize(
122-    input: ControlApiRequestAuthInput
123-  ): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure>;
124-}
125-
126-export interface ControlApiRouteContext {
127-  request: Request;
128-  env: ControlApiEnv;
129-  executionContext: ControlApiExecutionContext;
130-  url: URL;
131-  requestId: string;
132-  route: ControlApiRouteDefinition;
133-  params: Record<string, string>;
134-  body: JsonValue;
135-  services: ControlApiServices;
136-  auth: ControlApiRouteAuthorization;
137-}
138-
139-export interface ControlApiHandlerSuccess<T extends JsonValue = JsonValue> {
140-  ok: true;
141-  status: number;
142-  data: T;
143-  headers?: Record<string, string>;
144-}
145-
146-export interface ControlApiHandlerFailure {
147-  ok: false;
148-  status: number;
149-  error: string;
150-  message: string;
151-  details?: JsonValue;
152-  headers?: Record<string, string>;
153-}
154-
155-export type ControlApiHandlerResult<T extends JsonValue = JsonValue> =
156-  | ControlApiHandlerSuccess<T>
157-  | ControlApiHandlerFailure;
158-
159-export type ControlApiRouteHandler = (
160-  context: ControlApiRouteContext
161-) => Promise<ControlApiHandlerResult>;
162-
163-export interface ControlApiRouteDefinition {
164-  id: ControlApiRouteId;
165-  method: ControlApiRouteMethod;
166-  pathPattern: string;
167-  summary: string;
168-  category: ControlApiRouteCategory;
169-  access: ControlApiRouteAccess;
170-  implementation: ControlApiRouteImplementation;
171-  authRule: ControlApiAuthRule | null;
172-  schema: ControlApiRouteSchemaDescriptor;
173-  ownershipResolver?: (
174-    input: ControlApiOwnershipResolverInput
175-  ) => AuthResourceOwnership | undefined;
176-  handler: ControlApiRouteHandler;
177-}
178-
179-export interface ControlApiSuccessEnvelope<T extends JsonValue = JsonValue> {
180-  ok: true;
181-  request_id: string;
182-  data: T;
183-}
184-
185-export interface ControlApiErrorEnvelope {
186-  ok: false;
187-  request_id: string;
188-  error: string;
189-  message: string;
190-  details?: JsonValue;
191-}
192-
193-export interface ControlApiWorkerOptions {
194-  authHook?: ControlApiRequestAuthHook;
195-  now?: () => number;
196-  tokenVerifier?: AuthTokenVerifier;
197-  repository?: ControlPlaneRepository;
198-  repositoryFactory?: (db: D1DatabaseLike) => ControlPlaneRepository;
199-  requestIdFactory?: (request: Request) => string;
200-}
201-
202-export interface ControlApiWorker {
203-  fetch(
204-    request: Request,
205-    env: ControlApiEnv,
206-    executionContext: ControlApiExecutionContext
207-  ): Promise<Response>;
208-}
D apps/control-api-worker/src/handlers.ts
+0, -1547
   1@@ -1,1547 +0,0 @@
   2-import {
   3-  AUTH_ACTION_DESCRIPTORS,
   4-  AUTH_ROLE_BOUNDARIES,
   5-  findControlApiAuthRule,
   6-  getAllowedRolesForAction
   7-} from "@baa-conductor/auth";
   8-import {
   9-  AUTOMATION_STATE_KEY,
  10-  DEFAULT_AUTOMATION_MODE,
  11-  TASK_STATUS_VALUES,
  12-  parseJsonText,
  13-  stringifyJson,
  14-  type AutomationMode,
  15-  type ControlPlaneRepository,
  16-  type ControllerRecord,
  17-  type JsonObject,
  18-  type JsonValue,
  19-  type TaskRecord,
  20-  type TaskRunRecord,
  21-  type TaskStatus
  22-} from "@baa-conductor/db";
  23-import type {
  24-  ControlApiHandlerFailure,
  25-  ControlApiHandlerResult,
  26-  ControlApiOwnershipResolverInput,
  27-  ControlApiRouteAccess,
  28-  ControlApiRouteContext,
  29-  ControlApiRouteDefinition,
  30-  ControlApiRouteHandler,
  31-  ControlApiRouteMethod
  32-} from "./contracts.js";
  33-import {
  34-  CONTROL_API_AUTH_REQUIRED_ENV_NAME,
  35-  CONTROL_API_CUSTOM_DOMAIN,
  36-  CONTROL_API_D1_BINDING_NAME,
  37-  CONTROL_API_VERSION_ENV_NAME,
  38-  CONTROL_API_WORKER_NAME
  39-} from "./contracts.js";
  40-import { CONTROL_API_ROUTE_SCHEMAS } from "./schemas.js";
  41-
  42-const DEFAULT_TASK_PRIORITY = 50;
  43-const DEFAULT_LIST_LIMIT = 20;
  44-const MAX_LIST_LIMIT = 100;
  45-const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
  46-const FALSE_ENV_VALUES = new Set(["0", "false", "no", "off"]);
  47-const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
  48-const MODE_NOTES = [
  49-  {
  50-    mode: "running",
  51-    summary: "正常调度,允许领取和启动新的工作。"
  52-  },
  53-  {
  54-    mode: "draining",
  55-    summary: "停止新分配,让已启动 run 自然完成。"
  56-  },
  57-  {
  58-    mode: "paused",
  59-    summary: "暂停新的调度动作;已运行中的工作是否继续由后端策略决定。"
  60-  }
  61-] as const;
  62-
  63-function requireAuthRule(method: ControlApiRouteMethod, pathPattern: string) {
  64-  const authRule = findControlApiAuthRule(method, pathPattern);
  65-
  66-  if (!authRule) {
  67-    throw new Error(`Missing control-api auth rule for ${method} ${pathPattern}.`);
  68-  }
  69-
  70-  return authRule;
  71-}
  72-
  73-function parseBooleanEnv(value: string | undefined): boolean | undefined {
  74-  if (value == null) {
  75-    return undefined;
  76-  }
  77-
  78-  const normalized = value.trim().toLowerCase();
  79-
  80-  if (TRUE_ENV_VALUES.has(normalized)) {
  81-    return true;
  82-  }
  83-
  84-  if (FALSE_ENV_VALUES.has(normalized)) {
  85-    return false;
  86-  }
  87-
  88-  return undefined;
  89-}
  90-
  91-function resolveControlApiVersion(context: ControlApiRouteContext): string {
  92-  const version = context.env[CONTROL_API_VERSION_ENV_NAME]?.trim();
  93-  return version && version.length > 0 ? version : "dev";
  94-}
  95-
  96-function resolveAuthMode(context: ControlApiRouteContext): "disabled" | "bearer" | "optional" {
  97-  const envValue = parseBooleanEnv(context.env[CONTROL_API_AUTH_REQUIRED_ENV_NAME]);
  98-
  99-  if (envValue === false) {
 100-    return "disabled";
 101-  }
 102-
 103-  if (context.services.authHook == null) {
 104-    return "optional";
 105-  }
 106-
 107-  return "bearer";
 108-}
 109-
 110-function toUnixMilliseconds(value: number | null | undefined): number | null {
 111-  if (value == null) {
 112-    return null;
 113-  }
 114-
 115-  return value * 1000;
 116-}
 117-
 118-function coercePositiveIntegerQuery(value: string | null): number | null {
 119-  if (value == null || value.trim() === "") {
 120-    return null;
 121-  }
 122-
 123-  const numeric = Number(value);
 124-
 125-  if (!Number.isInteger(numeric) || numeric <= 0) {
 126-    return null;
 127-  }
 128-
 129-  return numeric;
 130-}
 131-
 132-function readListLimit(
 133-  context: ControlApiRouteContext,
 134-  defaultLimit: number = DEFAULT_LIST_LIMIT
 135-): number | ControlApiHandlerFailure {
 136-  const rawValue = context.url.searchParams.get("limit");
 137-
 138-  if (rawValue == null || rawValue.trim() === "") {
 139-    return defaultLimit;
 140-  }
 141-
 142-  const parsed = coercePositiveIntegerQuery(rawValue);
 143-
 144-  if (parsed == null) {
 145-    return buildInvalidRequestFailure(context, "Query parameter \"limit\" must be a positive integer.", {
 146-      field: "limit"
 147-    });
 148-  }
 149-
 150-  if (parsed > MAX_LIST_LIMIT) {
 151-    return buildInvalidRequestFailure(
 152-      context,
 153-      `Query parameter "limit" must be less than or equal to ${MAX_LIST_LIMIT}.`,
 154-      {
 155-        field: "limit",
 156-        maximum: MAX_LIST_LIMIT
 157-      }
 158-    );
 159-  }
 160-
 161-  return parsed;
 162-}
 163-
 164-function readTaskStatusFilter(
 165-  context: ControlApiRouteContext
 166-): TaskStatus | undefined | ControlApiHandlerFailure {
 167-  const rawValue = context.url.searchParams.get("status");
 168-
 169-  if (rawValue == null || rawValue.trim() === "") {
 170-    return undefined;
 171-  }
 172-
 173-  const normalized = rawValue.trim();
 174-
 175-  if (!TASK_STATUS_SET.has(normalized as TaskStatus)) {
 176-    return buildInvalidRequestFailure(
 177-      context,
 178-      `Query parameter "status" must be one of ${TASK_STATUS_VALUES.join(", ")}.`,
 179-      {
 180-        field: "status",
 181-        allowed_values: [...TASK_STATUS_VALUES]
 182-      }
 183-    );
 184-  }
 185-
 186-  return normalized as TaskStatus;
 187-}
 188-
 189-function asJsonObject(value: JsonValue): JsonObject | null {
 190-  if (value === null || Array.isArray(value) || typeof value !== "object") {
 191-    return null;
 192-  }
 193-
 194-  return value as JsonObject;
 195-}
 196-
 197-function readNonEmptyStringField(source: JsonValue | JsonObject, fieldName: string): string | undefined {
 198-  const object = asJsonObject(source);
 199-  const value = object?.[fieldName];
 200-
 201-  if (typeof value !== "string") {
 202-    return undefined;
 203-  }
 204-
 205-  const normalized = value.trim();
 206-  return normalized.length > 0 ? normalized : undefined;
 207-}
 208-
 209-function readRequiredStringField(
 210-  context: ControlApiRouteContext,
 211-  body: JsonObject,
 212-  fieldName: string
 213-): string | ControlApiHandlerFailure {
 214-  const value = readNonEmptyStringField(body, fieldName);
 215-
 216-  if (value) {
 217-    return value;
 218-  }
 219-
 220-  return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a non-empty string.`, {
 221-    field: fieldName
 222-  });
 223-}
 224-
 225-function readOptionalStringField(
 226-  context: ControlApiRouteContext,
 227-  body: JsonObject,
 228-  fieldName: string
 229-): string | undefined | ControlApiHandlerFailure {
 230-  const rawValue = body[fieldName];
 231-
 232-  if (rawValue == null) {
 233-    return undefined;
 234-  }
 235-
 236-  if (typeof rawValue !== "string") {
 237-    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a string when provided.`, {
 238-      field: fieldName
 239-    });
 240-  }
 241-
 242-  const normalized = rawValue.trim();
 243-  return normalized.length > 0 ? normalized : undefined;
 244-}
 245-
 246-function readRequiredIntegerField(
 247-  context: ControlApiRouteContext,
 248-  body: JsonObject,
 249-  fieldName: string,
 250-  minimum: number = 0
 251-): number | ControlApiHandlerFailure {
 252-  const rawValue = body[fieldName];
 253-
 254-  if (typeof rawValue !== "number" || !Number.isInteger(rawValue) || rawValue < minimum) {
 255-    return buildInvalidRequestFailure(
 256-      context,
 257-      `Field "${fieldName}" must be an integer greater than or equal to ${minimum}.`,
 258-      {
 259-        field: fieldName,
 260-        minimum
 261-      }
 262-    );
 263-  }
 264-
 265-  return rawValue;
 266-}
 267-
 268-function readOptionalIntegerField(
 269-  context: ControlApiRouteContext,
 270-  body: JsonObject,
 271-  fieldName: string,
 272-  minimum: number = 0
 273-): number | undefined | ControlApiHandlerFailure {
 274-  const rawValue = body[fieldName];
 275-
 276-  if (rawValue == null) {
 277-    return undefined;
 278-  }
 279-
 280-  if (typeof rawValue !== "number" || !Number.isInteger(rawValue) || rawValue < minimum) {
 281-    return buildInvalidRequestFailure(
 282-      context,
 283-      `Field "${fieldName}" must be an integer greater than or equal to ${minimum} when provided.`,
 284-      {
 285-        field: fieldName,
 286-        minimum
 287-      }
 288-    );
 289-  }
 290-
 291-  return rawValue;
 292-}
 293-
 294-function readOptionalBooleanField(
 295-  context: ControlApiRouteContext,
 296-  body: JsonObject,
 297-  fieldName: string
 298-): boolean | undefined | ControlApiHandlerFailure {
 299-  const rawValue = body[fieldName];
 300-
 301-  if (rawValue == null) {
 302-    return undefined;
 303-  }
 304-
 305-  if (typeof rawValue !== "boolean") {
 306-    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a boolean when provided.`, {
 307-      field: fieldName
 308-    });
 309-  }
 310-
 311-  return rawValue;
 312-}
 313-
 314-function readOptionalJsonObjectField(
 315-  context: ControlApiRouteContext,
 316-  body: JsonObject,
 317-  fieldName: string
 318-): JsonObject | undefined | ControlApiHandlerFailure {
 319-  const rawValue = body[fieldName];
 320-
 321-  if (rawValue == null) {
 322-    return undefined;
 323-  }
 324-
 325-  const object = asJsonObject(rawValue);
 326-
 327-  if (!object) {
 328-    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be a JSON object when provided.`, {
 329-      field: fieldName
 330-    });
 331-  }
 332-
 333-  return object;
 334-}
 335-
 336-function readOptionalStringArrayField(
 337-  context: ControlApiRouteContext,
 338-  body: JsonObject,
 339-  fieldName: string
 340-): string[] | undefined | ControlApiHandlerFailure {
 341-  const rawValue = body[fieldName];
 342-
 343-  if (rawValue == null) {
 344-    return undefined;
 345-  }
 346-
 347-  if (!Array.isArray(rawValue)) {
 348-    return buildInvalidRequestFailure(context, `Field "${fieldName}" must be an array of strings when provided.`, {
 349-      field: fieldName
 350-    });
 351-  }
 352-
 353-  const values: string[] = [];
 354-
 355-  for (const [index, value] of rawValue.entries()) {
 356-    if (typeof value !== "string" || value.trim().length === 0) {
 357-      return buildInvalidRequestFailure(
 358-        context,
 359-        `Field "${fieldName}[${index}]" must be a non-empty string.`,
 360-        {
 361-          field: fieldName,
 362-          index
 363-        }
 364-      );
 365-    }
 366-
 367-    values.push(value.trim());
 368-  }
 369-
 370-  return values;
 371-}
 372-
 373-function resolveControllerOwnership(
 374-  input: ControlApiOwnershipResolverInput
 375-): { controllerId: string } | undefined {
 376-  const controllerId = readNonEmptyStringField(input.body, "controller_id");
 377-  return controllerId ? { controllerId } : undefined;
 378-}
 379-
 380-function resolveWorkerOwnership(
 381-  input: ControlApiOwnershipResolverInput
 382-): { workerId: string } | undefined {
 383-  const workerId = readNonEmptyStringField(input.body, "worker_id");
 384-  return workerId ? { workerId } : undefined;
 385-}
 386-
 387-function buildInvalidRequestFailure(
 388-  context: ControlApiRouteContext,
 389-  message: string,
 390-  details?: JsonValue
 391-): ControlApiHandlerFailure {
 392-  const normalizedDetails = asJsonObject(details ?? null);
 393-
 394-  return {
 395-    ok: false,
 396-    status: 400,
 397-    error: "invalid_request",
 398-    message,
 399-    details: {
 400-      route_id: context.route.id,
 401-      ...(normalizedDetails ?? {})
 402-    }
 403-  };
 404-}
 405-
 406-function buildRepositoryNotConfiguredFailure(context: ControlApiRouteContext): ControlApiHandlerFailure {
 407-  return {
 408-    ok: false,
 409-    status: 503,
 410-    error: "repository_not_configured",
 411-    message: `Route ${context.route.id} requires ${CONTROL_API_D1_BINDING_NAME} or an injected repository.`,
 412-    details: {
 413-      route_id: context.route.id
 414-    }
 415-  };
 416-}
 417-
 418-function buildNotFoundFailure(
 419-  context: ControlApiRouteContext,
 420-  resourceType: string,
 421-  resourceId: string
 422-): ControlApiHandlerFailure {
 423-  return {
 424-    ok: false,
 425-    status: 404,
 426-    error: `${resourceType}_not_found`,
 427-    message: `${resourceType} "${resourceId}" was not found.`,
 428-    details: {
 429-      route_id: context.route.id,
 430-      resource_id: resourceId,
 431-      resource_type: resourceType
 432-    }
 433-  };
 434-}
 435-
 436-function buildNotImplementedFailure(context: ControlApiRouteContext): ControlApiHandlerFailure {
 437-  return {
 438-    ok: false,
 439-    status: 501,
 440-    error: "not_implemented",
 441-    message: `${context.route.summary} 已完成路由建模,但 durable 状态迁移逻辑仍待后续任务接入。`,
 442-    details: {
 443-      route_id: context.route.id,
 444-      method: context.route.method,
 445-      path: context.route.pathPattern,
 446-      request_schema: context.route.schema.requestBody,
 447-      response_schema: context.route.schema.responseBody,
 448-      access: context.route.access,
 449-      category: context.route.category,
 450-      auth_action: context.auth.rule?.action ?? null,
 451-      authorization_mode: context.auth.mode,
 452-      authorization_skip_reason: context.auth.skipReason ?? null,
 453-      principal_role: context.auth.principal?.role ?? null,
 454-      principal_subject: context.auth.principal?.subject ?? null,
 455-      d1_binding_configured: context.services.repository !== null,
 456-      request_body_received: context.body !== null,
 457-      path_params: context.params,
 458-      notes: [...context.route.schema.notes]
 459-    }
 460-  };
 461-}
 462-
 463-function createPlaceholderHandler(): ControlApiRouteHandler {
 464-  return async (context) => buildNotImplementedFailure(context);
 465-}
 466-
 467-function buildAppliedAck(summary: string): ControlApiHandlerResult {
 468-  return {
 469-    ok: true,
 470-    status: 200,
 471-    data: {
 472-      accepted: true,
 473-      status: "applied",
 474-      summary
 475-    } as JsonObject
 476-  };
 477-}
 478-
 479-function requireBodyObject(
 480-  context: ControlApiRouteContext,
 481-  allowNull = false
 482-): JsonObject | ControlApiHandlerFailure {
 483-  if (allowNull && context.body === null) {
 484-    return {};
 485-  }
 486-
 487-  const body = asJsonObject(context.body);
 488-
 489-  if (!body) {
 490-    return buildInvalidRequestFailure(context, "Request body must be a JSON object.", {
 491-      route_id: context.route.id
 492-    });
 493-  }
 494-
 495-  return body;
 496-}
 497-
 498-function requireRepository(
 499-  context: ControlApiRouteContext
 500-): NonNullable<ControlApiRouteContext["services"]["repository"]> | ControlApiHandlerFailure {
 501-  return context.services.repository ?? buildRepositoryNotConfiguredFailure(context);
 502-}
 503-
 504-function isHandlerFailure(value: unknown): value is ControlApiHandlerFailure {
 505-  return value != null && typeof value === "object" && "ok" in value && (value as { ok?: unknown }).ok === false;
 506-}
 507-
 508-function findHandlerFailure(...values: unknown[]): ControlApiHandlerFailure | null {
 509-  for (const value of values) {
 510-    if (isHandlerFailure(value)) {
 511-      return value;
 512-    }
 513-  }
 514-
 515-  return null;
 516-}
 517-
 518-function summarizeTask(task: TaskRecord): JsonObject {
 519-  return {
 520-    task_id: task.taskId,
 521-    repo: task.repo,
 522-    task_type: task.taskType,
 523-    title: task.title,
 524-    goal: task.goal,
 525-    source: task.source,
 526-    priority: task.priority,
 527-    status: task.status,
 528-    planner_provider: task.plannerProvider,
 529-    planning_strategy: task.planningStrategy,
 530-    branch_name: task.branchName,
 531-    base_ref: task.baseRef,
 532-    target_host: task.targetHost,
 533-    assigned_controller_id: task.assignedControllerId,
 534-    current_step_index: task.currentStepIndex,
 535-    result_summary: task.resultSummary,
 536-    error_text: task.errorText,
 537-    created_at: toUnixMilliseconds(task.createdAt),
 538-    updated_at: toUnixMilliseconds(task.updatedAt),
 539-    started_at: toUnixMilliseconds(task.startedAt),
 540-    finished_at: toUnixMilliseconds(task.finishedAt)
 541-  };
 542-}
 543-
 544-function summarizeRun(run: TaskRunRecord): JsonObject {
 545-  return {
 546-    run_id: run.runId,
 547-    task_id: run.taskId,
 548-    step_id: run.stepId,
 549-    worker_id: run.workerId,
 550-    controller_id: run.controllerId,
 551-    host: run.host,
 552-    status: run.status,
 553-    pid: run.pid,
 554-    checkpoint_seq: run.checkpointSeq,
 555-    exit_code: run.exitCode,
 556-    created_at: toUnixMilliseconds(run.createdAt),
 557-    started_at: toUnixMilliseconds(run.startedAt),
 558-    finished_at: toUnixMilliseconds(run.finishedAt),
 559-    heartbeat_at: toUnixMilliseconds(run.heartbeatAt),
 560-    lease_expires_at: toUnixMilliseconds(run.leaseExpiresAt)
 561-  };
 562-}
 563-
 564-function summarizeController(controller: ControllerRecord, leaderControllerId: string | null): JsonObject {
 565-  return {
 566-    controller_id: controller.controllerId,
 567-    host: controller.host,
 568-    role: controller.role,
 569-    priority: controller.priority,
 570-    status: controller.status,
 571-    version: controller.version,
 572-    last_heartbeat_at: toUnixMilliseconds(controller.lastHeartbeatAt),
 573-    last_started_at: toUnixMilliseconds(controller.lastStartedAt),
 574-    is_leader: leaderControllerId != null && leaderControllerId === controller.controllerId
 575-  };
 576-}
 577-
 578-function extractAutomationMetadata(valueJson: string | null | undefined): {
 579-  mode: AutomationMode | null;
 580-  requestedBy: string | null;
 581-  reason: string | null;
 582-  source: string | null;
 583-} {
 584-  const payload = parseJsonText<{
 585-    mode?: unknown;
 586-    requested_by?: unknown;
 587-    reason?: unknown;
 588-    source?: unknown;
 589-  }>(valueJson);
 590-
 591-  const mode = payload?.mode;
 592-
 593-  return {
 594-    mode: mode === "running" || mode === "draining" || mode === "paused" ? mode : null,
 595-    requestedBy: typeof payload?.requested_by === "string" ? payload.requested_by : null,
 596-    reason: typeof payload?.reason === "string" ? payload.reason : null,
 597-    source: typeof payload?.source === "string" ? payload.source : null
 598-  };
 599-}
 600-
 601-async function buildSystemStateData(
 602-  repository: ControlPlaneRepository
 603-): Promise<JsonObject> {
 604-  const [automationState, lease, activeRuns, queuedTasks] = await Promise.all([
 605-    repository.getAutomationState(),
 606-    repository.getCurrentLease(),
 607-    repository.countActiveRuns(),
 608-    repository.countQueuedTasks()
 609-  ]);
 610-
 611-  const leaderController = lease?.holderId ? await repository.getController(lease.holderId) : null;
 612-  const automationMetadata = extractAutomationMetadata(automationState?.valueJson);
 613-  const mode = automationMetadata.mode ?? automationState?.mode ?? DEFAULT_AUTOMATION_MODE;
 614-  const updatedAt = toUnixMilliseconds(automationState?.updatedAt);
 615-  const leaseExpiresAt = toUnixMilliseconds(lease?.leaseExpiresAt);
 616-
 617-  return {
 618-    mode,
 619-    updated_at: updatedAt,
 620-    holder_id: lease?.holderId ?? null,
 621-    holder_host: lease?.holderHost ?? null,
 622-    lease_expires_at: leaseExpiresAt,
 623-    term: lease?.term ?? null,
 624-    automation: {
 625-      mode,
 626-      updated_at: updatedAt,
 627-      requested_by: automationMetadata.requestedBy,
 628-      reason: automationMetadata.reason,
 629-      source: automationMetadata.source
 630-    },
 631-    leader: {
 632-      controller_id: lease?.holderId ?? null,
 633-      host: lease?.holderHost ?? leaderController?.host ?? null,
 634-      role: leaderController?.role ?? null,
 635-      status: leaderController?.status ?? null,
 636-      version: leaderController?.version ?? null,
 637-      lease_expires_at: leaseExpiresAt,
 638-      term: lease?.term ?? null
 639-    },
 640-    queue: {
 641-      active_runs: activeRuns,
 642-      queued_tasks: queuedTasks
 643-    }
 644-  };
 645-}
 646-
 647-function classifyRoute(route: ControlApiRouteDefinition): "read" | "write" {
 648-  if (route.access === "public") {
 649-    return route.method === "GET" ? "read" : "write";
 650-  }
 651-
 652-  return AUTH_ACTION_DESCRIPTORS[route.authRule!.action].mutatesState ? "write" : "read";
 653-}
 654-
 655-function describeRoute(route: ControlApiRouteDefinition): JsonObject {
 656-  let auth: JsonObject;
 657-
 658-  if (route.access === "public") {
 659-    auth = {
 660-      mode: "public"
 661-    };
 662-  } else {
 663-    auth = {
 664-      mode: "bearer",
 665-      action: route.authRule!.action,
 666-      allowed_roles: getAllowedRolesForAction(route.authRule!.action),
 667-      summary: route.authRule!.summary
 668-    };
 669-  }
 670-
 671-  return {
 672-    id: route.id,
 673-    method: route.method,
 674-    path: route.pathPattern,
 675-    category: route.category,
 676-    access: route.access,
 677-    kind: classifyRoute(route),
 678-    implementation: route.implementation,
 679-    summary: route.summary,
 680-    request_body: route.schema.requestBody,
 681-    response_body: route.schema.responseBody,
 682-    notes: [...route.schema.notes],
 683-    auth
 684-  };
 685-}
 686-
 687-function buildCurlExample(
 688-  context: ControlApiRouteContext,
 689-  route: ControlApiRouteDefinition,
 690-  body?: JsonObject
 691-): string {
 692-  const authHeader =
 693-    route.access === "protected" && context.services.authHook != null
 694-      ? " \\\n  -H 'Authorization: Bearer <token>'"
 695-      : "";
 696-  const contentTypeHeader = body ? " \\\n  -H 'Content-Type: application/json'" : "";
 697-  const payload = body ? ` \\\n  -d '${JSON.stringify(body)}'` : "";
 698-
 699-  return `curl -X ${route.method} '${context.url.origin}${route.pathPattern}'${authHeader}${contentTypeHeader}${payload}`;
 700-}
 701-
 702-function buildCapabilitiesData(context: ControlApiRouteContext): JsonObject {
 703-  const readEndpoints = CONTROL_API_ROUTES.filter((route) => classifyRoute(route) === "read").map(describeRoute);
 704-  const writeEndpoints = CONTROL_API_ROUTES.filter((route) => classifyRoute(route) === "write").map(describeRoute);
 705-  const publicEndpoints = CONTROL_API_ROUTES.filter((route) => route.access === "public").map(describeRoute);
 706-  const implementedEndpoints = CONTROL_API_ROUTES.filter((route) => route.implementation === "implemented").map(
 707-    describeRoute
 708-  );
 709-
 710-  return {
 711-    deployment_mode: "single-node mini",
 712-    auth_mode: resolveAuthMode(context),
 713-    repository_configured: context.services.repository !== null,
 714-    workflow: [
 715-      "GET /describe",
 716-      "GET /v1/capabilities",
 717-      "GET /v1/system/state",
 718-      "GET /v1/tasks or /v1/runs",
 719-      "Use POST control routes only when a write is intended"
 720-    ],
 721-    read_endpoints: readEndpoints,
 722-    write_endpoints: writeEndpoints,
 723-    public_endpoints: publicEndpoints,
 724-    implemented_endpoints: implementedEndpoints
 725-  };
 726-}
 727-
 728-async function buildDescribeData(context: ControlApiRouteContext): Promise<JsonObject> {
 729-  const repository = context.services.repository;
 730-  const system = repository ? await buildSystemStateData(repository) : null;
 731-  const capabilities = buildCapabilitiesData(context);
 732-
 733-  return {
 734-    name: CONTROL_API_WORKER_NAME,
 735-    version: resolveControlApiVersion(context),
 736-    description:
 737-      "BAA conductor control surface for a temporary single-node mini deployment. Call /describe first, then choose read or write endpoints.",
 738-    environment: {
 739-      summary: "single-node mini",
 740-      deployment_mode: "single-node mini",
 741-      topology: "No primary/secondary failover or switchback path is active on this branch.",
 742-      auth_mode: resolveAuthMode(context),
 743-      repository_binding: CONTROL_API_D1_BINDING_NAME,
 744-      repository_configured: repository !== null,
 745-      custom_domain: CONTROL_API_CUSTOM_DOMAIN,
 746-      origin: context.url.origin
 747-    },
 748-    system,
 749-    endpoints: CONTROL_API_ROUTES.map(describeRoute),
 750-    capabilities,
 751-    auth_roles: Object.entries(AUTH_ROLE_BOUNDARIES).map(([role, boundary]) => ({
 752-      role,
 753-      summary: boundary.summary,
 754-      token_kinds: [...boundary.tokenKinds],
 755-      allowed_actions: [...boundary.allowedActions]
 756-    })),
 757-    actions: CONTROL_API_ROUTES.filter((route) => classifyRoute(route) === "write").map((route) => ({
 758-      action: route.access === "protected" ? route.authRule!.action : route.id,
 759-      method: route.method,
 760-      path: route.pathPattern,
 761-      summary: route.summary,
 762-      implementation: route.implementation
 763-    })),
 764-    modes: MODE_NOTES.map((note) => ({ ...note })),
 765-    examples: [
 766-      {
 767-        title: "Read the full self-description first",
 768-        method: "GET",
 769-        path: "/describe",
 770-        curl: buildCurlExample(context, CONTROL_API_ROUTES.find((route) => route.id === "service.describe")!)
 771-      },
 772-      {
 773-        title: "Inspect the narrower capability surface",
 774-        method: "GET",
 775-        path: "/v1/capabilities",
 776-        curl: buildCurlExample(context, CONTROL_API_ROUTES.find((route) => route.id === "system.capabilities")!)
 777-      },
 778-      {
 779-        title: "Read the current automation state",
 780-        method: "GET",
 781-        path: "/v1/system/state",
 782-        curl: buildCurlExample(context, CONTROL_API_ROUTES.find((route) => route.id === "system.state")!)
 783-      },
 784-      {
 785-        title: "List recent queued tasks",
 786-        method: "GET",
 787-        path: "/v1/tasks?status=queued&limit=5",
 788-        curl: `curl '${context.url.origin}/v1/tasks?status=queued&limit=5'${
 789-          context.services.authHook != null ? " \\\n  -H 'Authorization: Bearer <token>'" : ""
 790-        }`
 791-      },
 792-      {
 793-        title: "Pause automation when a human explicitly wants a write",
 794-        method: "POST",
 795-        path: "/v1/system/pause",
 796-        curl: buildCurlExample(
 797-          context,
 798-          CONTROL_API_ROUTES.find((route) => route.id === "system.pause")!,
 799-          {
 800-            requested_by: "browser_admin",
 801-            reason: "human_clicked_pause",
 802-            source: "human_control_surface"
 803-          }
 804-        )
 805-      }
 806-    ],
 807-    notes: [
 808-      "This repo currently targets the single-node mini path only.",
 809-      "Public discoverability routes are safe to call before any authenticated workflow.",
 810-      "Several control-plane write routes still expose placeholder contracts; check each endpoint's implementation field before using it."
 811-    ]
 812-  };
 813-}
 814-
 815-async function handleControllerHeartbeat(
 816-  context: ControlApiRouteContext
 817-): Promise<ControlApiHandlerResult> {
 818-  const repository = requireRepository(context);
 819-
 820-  if (isHandlerFailure(repository)) {
 821-    return repository;
 822-  }
 823-
 824-  const body = requireBodyObject(context);
 825-
 826-  if (isHandlerFailure(body)) {
 827-    return body;
 828-  }
 829-
 830-  const controllerId = readRequiredStringField(context, body, "controller_id");
 831-  const host = readRequiredStringField(context, body, "host");
 832-  const role = readRequiredStringField(context, body, "role");
 833-  const priority = readRequiredIntegerField(context, body, "priority");
 834-  const status = readRequiredStringField(context, body, "status");
 835-  const version = readOptionalStringField(context, body, "version");
 836-  const metadata = readOptionalJsonObjectField(context, body, "metadata");
 837-
 838-  const failure = findHandlerFailure(controllerId, host, role, priority, status, version, metadata);
 839-
 840-  if (failure) {
 841-    return failure;
 842-  }
 843-
 844-  const controllerIdValue = controllerId as string;
 845-  const hostValue = host as string;
 846-  const roleValue = role as string;
 847-  const priorityValue = priority as number;
 848-  const statusValue = status as string;
 849-  const versionValue = version as string | undefined;
 850-  const metadataValue = metadata as JsonObject | undefined;
 851-
 852-  await repository.heartbeatController({
 853-    controllerId: controllerIdValue,
 854-    heartbeatAt: context.services.now(),
 855-    host: hostValue,
 856-    metadataJson: stringifyJson(metadataValue),
 857-    priority: priorityValue,
 858-    role: roleValue,
 859-    status: statusValue,
 860-    version: versionValue ?? null
 861-  });
 862-
 863-  return buildAppliedAck(`Controller heartbeat recorded for ${controllerIdValue}.`);
 864-}
 865-
 866-async function handleLeaderAcquire(
 867-  context: ControlApiRouteContext
 868-): Promise<ControlApiHandlerResult> {
 869-  const repository = requireRepository(context);
 870-
 871-  if (isHandlerFailure(repository)) {
 872-    return repository;
 873-  }
 874-
 875-  const body = requireBodyObject(context);
 876-
 877-  if (isHandlerFailure(body)) {
 878-    return body;
 879-  }
 880-
 881-  const controllerId = readRequiredStringField(context, body, "controller_id");
 882-  const host = readRequiredStringField(context, body, "host");
 883-  const ttlSec = readRequiredIntegerField(context, body, "ttl_sec", 1);
 884-  const preferred = readOptionalBooleanField(context, body, "preferred");
 885-
 886-  const failure = findHandlerFailure(controllerId, host, ttlSec, preferred);
 887-
 888-  if (failure) {
 889-    return failure;
 890-  }
 891-
 892-  const controllerIdValue = controllerId as string;
 893-  const hostValue = host as string;
 894-  const ttlSecValue = ttlSec as number;
 895-  const preferredValue = preferred as boolean | undefined;
 896-
 897-  const result = await repository.acquireLeaderLease({
 898-    controllerId: controllerIdValue,
 899-    host: hostValue,
 900-    preferred: preferredValue ?? false,
 901-    ttlSec: ttlSecValue
 902-  });
 903-
 904-  return {
 905-    ok: true,
 906-    status: 200,
 907-    data: {
 908-      holder_id: result.holderId,
 909-      is_leader: result.isLeader,
 910-      lease_expires_at: result.leaseExpiresAt,
 911-      term: result.term
 912-    }
 913-  };
 914-}
 915-
 916-async function handleTaskCreate(
 917-  context: ControlApiRouteContext
 918-): Promise<ControlApiHandlerResult> {
 919-  const repository = requireRepository(context);
 920-
 921-  if (isHandlerFailure(repository)) {
 922-    return repository;
 923-  }
 924-
 925-  const body = requireBodyObject(context);
 926-
 927-  if (isHandlerFailure(body)) {
 928-    return body;
 929-  }
 930-
 931-  const repo = readRequiredStringField(context, body, "repo");
 932-  const taskType = readRequiredStringField(context, body, "task_type");
 933-  const title = readRequiredStringField(context, body, "title");
 934-  const goal = readRequiredStringField(context, body, "goal");
 935-  const priority = readOptionalIntegerField(context, body, "priority");
 936-  const constraints = readOptionalJsonObjectField(context, body, "constraints");
 937-  const acceptance = readOptionalStringArrayField(context, body, "acceptance");
 938-  const metadata = readOptionalJsonObjectField(context, body, "metadata");
 939-
 940-  const failure = findHandlerFailure(
 941-    repo,
 942-    taskType,
 943-    title,
 944-    goal,
 945-    priority,
 946-    constraints,
 947-    acceptance,
 948-    metadata
 949-  );
 950-
 951-  if (failure) {
 952-    return failure;
 953-  }
 954-
 955-  const repoValue = repo as string;
 956-  const taskTypeValue = taskType as string;
 957-  const titleValue = title as string;
 958-  const goalValue = goal as string;
 959-  const priorityValue = priority as number | undefined;
 960-  const constraintsValue = constraints as JsonObject | undefined;
 961-  const acceptanceValue = acceptance as string[] | undefined;
 962-  const metadataValue = metadata as JsonObject | undefined;
 963-
 964-  const now = context.services.now();
 965-  const taskId = `task_${crypto.randomUUID()}`;
 966-  const source = readNonEmptyStringField(metadataValue ?? {}, "requested_by") ?? "control_api";
 967-  const targetHost = readNonEmptyStringField(constraintsValue ?? {}, "target_host") ?? null;
 968-
 969-  await repository.insertTask({
 970-    acceptanceJson: stringifyJson(acceptanceValue),
 971-    assignedControllerId: null,
 972-    baseRef: null,
 973-    branchName: null,
 974-    constraintsJson: stringifyJson(constraintsValue),
 975-    createdAt: now,
 976-    currentStepIndex: 0,
 977-    errorText: null,
 978-    finishedAt: null,
 979-    goal: goalValue,
 980-    metadataJson: stringifyJson(metadataValue),
 981-    plannerProvider: null,
 982-    planningStrategy: null,
 983-    priority: priorityValue ?? DEFAULT_TASK_PRIORITY,
 984-    repo: repoValue,
 985-    resultJson: null,
 986-    resultSummary: null,
 987-    source,
 988-    startedAt: null,
 989-    status: "queued",
 990-    targetHost,
 991-    taskId,
 992-    taskType: taskTypeValue,
 993-    title: titleValue,
 994-    updatedAt: now
 995-  });
 996-
 997-  return {
 998-    ok: true,
 999-    status: 201,
1000-    data: {
1001-      base_ref: null,
1002-      branch_name: null,
1003-      status: "queued",
1004-      task_id: taskId
1005-    }
1006-  };
1007-}
1008-
1009-function createSystemMutationHandler(mode: AutomationMode): ControlApiRouteHandler {
1010-  return async (context) => {
1011-    const repository = requireRepository(context);
1012-
1013-    if (isHandlerFailure(repository)) {
1014-      return repository;
1015-    }
1016-
1017-    const body = requireBodyObject(context, true);
1018-
1019-    if (isHandlerFailure(body)) {
1020-      return body;
1021-    }
1022-
1023-    const reason = readOptionalStringField(context, body, "reason");
1024-    const requestedBy = readOptionalStringField(context, body, "requested_by");
1025-    const source = readOptionalStringField(context, body, "source");
1026-
1027-    const failure = findHandlerFailure(reason, requestedBy, source);
1028-
1029-    if (failure) {
1030-      return failure;
1031-    }
1032-
1033-    const reasonValue = reason as string | undefined;
1034-    const requestedByValue = requestedBy as string | undefined;
1035-    const sourceValue = source as string | undefined;
1036-
1037-    await repository.putSystemState({
1038-      stateKey: AUTOMATION_STATE_KEY,
1039-      updatedAt: context.services.now(),
1040-      valueJson: JSON.stringify({
1041-        mode,
1042-        ...(requestedByValue ? { requested_by: requestedByValue } : {}),
1043-        ...(reasonValue ? { reason: reasonValue } : {}),
1044-        ...(sourceValue ? { source: sourceValue } : {})
1045-      })
1046-    });
1047-
1048-    const summarySuffix = [
1049-      requestedByValue ? `requested by ${requestedByValue}` : null,
1050-      reasonValue ? `reason: ${reasonValue}` : null,
1051-      sourceValue ? `source: ${sourceValue}` : null
1052-    ].filter((value) => value !== null);
1053-
1054-    return buildAppliedAck(
1055-      summarySuffix.length > 0
1056-        ? `Automation mode set to ${mode}; ${summarySuffix.join("; ")}.`
1057-        : `Automation mode set to ${mode}.`
1058-    );
1059-  };
1060-}
1061-
1062-async function handleDescribeRead(
1063-  context: ControlApiRouteContext
1064-): Promise<ControlApiHandlerResult> {
1065-  return {
1066-    ok: true,
1067-    status: 200,
1068-    data: await buildDescribeData(context)
1069-  };
1070-}
1071-
1072-async function handleVersionRead(
1073-  context: ControlApiRouteContext
1074-): Promise<ControlApiHandlerResult> {
1075-  return {
1076-    ok: true,
1077-    status: 200,
1078-    data: {
1079-      description: "BAA conductor control surface",
1080-      name: CONTROL_API_WORKER_NAME,
1081-      version: resolveControlApiVersion(context)
1082-    }
1083-  };
1084-}
1085-
1086-async function handleHealthRead(
1087-  context: ControlApiRouteContext
1088-): Promise<ControlApiHandlerResult> {
1089-  const repository = context.services.repository;
1090-  const system = repository ? await buildSystemStateData(repository) : null;
1091-  const status = repository ? "ok" : "degraded";
1092-
1093-  return {
1094-    ok: true,
1095-    status: 200,
1096-    data: {
1097-      name: CONTROL_API_WORKER_NAME,
1098-      version: resolveControlApiVersion(context),
1099-      status,
1100-      deployment_mode: "single-node mini",
1101-      auth_mode: resolveAuthMode(context),
1102-      repository_configured: repository !== null,
1103-      system
1104-    }
1105-  };
1106-}
1107-
1108-async function handleCapabilitiesRead(
1109-  context: ControlApiRouteContext
1110-): Promise<ControlApiHandlerResult> {
1111-  const repository = context.services.repository;
1112-
1113-  return {
1114-    ok: true,
1115-    status: 200,
1116-    data: {
1117-      ...buildCapabilitiesData(context),
1118-      system: repository ? await buildSystemStateData(repository) : null,
1119-      notes: [
1120-        "Read routes are safe for discovery and inspection.",
1121-        "Write routes should only be used after a human or higher-level agent has made a control decision."
1122-      ]
1123-    }
1124-  };
1125-}
1126-
1127-async function handleSystemStateRead(
1128-  context: ControlApiRouteContext
1129-): Promise<ControlApiHandlerResult> {
1130-  const repository = requireRepository(context);
1131-
1132-  if (isHandlerFailure(repository)) {
1133-    return repository;
1134-  }
1135-
1136-  return {
1137-    ok: true,
1138-    status: 200,
1139-    data: await buildSystemStateData(repository)
1140-  };
1141-}
1142-
1143-async function handleControllersList(
1144-  context: ControlApiRouteContext
1145-): Promise<ControlApiHandlerResult> {
1146-  const repository = requireRepository(context);
1147-
1148-  if (isHandlerFailure(repository)) {
1149-    return repository;
1150-  }
1151-
1152-  const limit = readListLimit(context);
1153-
1154-  if (isHandlerFailure(limit)) {
1155-    return limit;
1156-  }
1157-
1158-  const lease = await repository.getCurrentLease();
1159-  const limitValue = limit as number;
1160-  const controllers = await repository.listControllers({
1161-    limit: limitValue
1162-  });
1163-
1164-  return {
1165-    ok: true,
1166-    status: 200,
1167-    data: {
1168-      active_controller_id: lease?.holderId ?? null,
1169-      count: controllers.length,
1170-      controllers: controllers.map((controller) => summarizeController(controller, lease?.holderId ?? null)),
1171-      limit: limitValue
1172-    }
1173-  };
1174-}
1175-
1176-async function handleTasksList(
1177-  context: ControlApiRouteContext
1178-): Promise<ControlApiHandlerResult> {
1179-  const repository = requireRepository(context);
1180-
1181-  if (isHandlerFailure(repository)) {
1182-    return repository;
1183-  }
1184-
1185-  const limit = readListLimit(context);
1186-  const status = readTaskStatusFilter(context);
1187-  const failure = findHandlerFailure(limit, status);
1188-
1189-  if (failure) {
1190-    return failure;
1191-  }
1192-
1193-  const tasks = await repository.listTasks({
1194-    limit: limit as number,
1195-    status: status as TaskStatus | undefined
1196-  });
1197-  const limitValue = limit as number;
1198-  const statusValue = status as TaskStatus | undefined;
1199-
1200-  return {
1201-    ok: true,
1202-    status: 200,
1203-    data: {
1204-      count: tasks.length,
1205-      filters: {
1206-        limit: limitValue,
1207-        status: statusValue ?? null
1208-      },
1209-      tasks: tasks.map(summarizeTask)
1210-    }
1211-  };
1212-}
1213-
1214-async function handleRunsList(
1215-  context: ControlApiRouteContext
1216-): Promise<ControlApiHandlerResult> {
1217-  const repository = requireRepository(context);
1218-
1219-  if (isHandlerFailure(repository)) {
1220-    return repository;
1221-  }
1222-
1223-  const limit = readListLimit(context);
1224-
1225-  if (isHandlerFailure(limit)) {
1226-    return limit;
1227-  }
1228-
1229-  const limitValue = limit as number;
1230-  const runs = await repository.listRuns({
1231-    limit: limitValue
1232-  });
1233-
1234-  return {
1235-    ok: true,
1236-    status: 200,
1237-    data: {
1238-      count: runs.length,
1239-      limit: limitValue,
1240-      runs: runs.map(summarizeRun)
1241-    }
1242-  };
1243-}
1244-
1245-async function handleTaskRead(
1246-  context: ControlApiRouteContext
1247-): Promise<ControlApiHandlerResult> {
1248-  const repository = requireRepository(context);
1249-
1250-  if (isHandlerFailure(repository)) {
1251-    return repository;
1252-  }
1253-
1254-  const taskId = context.params.task_id;
1255-
1256-  if (!taskId) {
1257-    return buildInvalidRequestFailure(context, "Route parameter \"task_id\" is required.", {
1258-      field: "task_id"
1259-    });
1260-  }
1261-
1262-  const task = await repository.getTask(taskId);
1263-
1264-  if (!task) {
1265-    return buildNotFoundFailure(context, "task", taskId);
1266-  }
1267-
1268-  return {
1269-    ok: true,
1270-    status: 200,
1271-    data: summarizeTask(task)
1272-  };
1273-}
1274-
1275-async function handleRunRead(
1276-  context: ControlApiRouteContext
1277-): Promise<ControlApiHandlerResult> {
1278-  const repository = requireRepository(context);
1279-
1280-  if (isHandlerFailure(repository)) {
1281-    return repository;
1282-  }
1283-
1284-  const runId = context.params.run_id;
1285-
1286-  if (!runId) {
1287-    return buildInvalidRequestFailure(context, "Route parameter \"run_id\" is required.", {
1288-      field: "run_id"
1289-    });
1290-  }
1291-
1292-  const run = await repository.getRun(runId);
1293-
1294-  if (!run) {
1295-    return buildNotFoundFailure(context, "run", runId);
1296-  }
1297-
1298-  return {
1299-    ok: true,
1300-    status: 200,
1301-    data: summarizeRun(run)
1302-  };
1303-}
1304-
1305-function defineRoute(
1306-  definition: Omit<ControlApiRouteDefinition, "access" | "authRule" | "handler" | "implementation"> & {
1307-    access?: ControlApiRouteAccess;
1308-    handler?: ControlApiRouteHandler;
1309-  }
1310-): ControlApiRouteDefinition {
1311-  const access = definition.access ?? "protected";
1312-  const authRule = access === "public" ? null : requireAuthRule(definition.method, definition.pathPattern);
1313-  const handler = definition.handler ?? createPlaceholderHandler();
1314-
1315-  return {
1316-    ...definition,
1317-    access,
1318-    authRule,
1319-    handler,
1320-    implementation: definition.handler ? "implemented" : "placeholder"
1321-  };
1322-}
1323-
1324-export const CONTROL_API_ROUTES: ControlApiRouteDefinition[] = [
1325-  defineRoute({
1326-    id: "service.describe",
1327-    access: "public",
1328-    category: "discoverability",
1329-    method: "GET",
1330-    pathPattern: "/describe",
1331-    summary: "读取完整自描述 JSON",
1332-    schema: CONTROL_API_ROUTE_SCHEMAS["service.describe"],
1333-    handler: handleDescribeRead
1334-  }),
1335-  defineRoute({
1336-    id: "service.version",
1337-    access: "public",
1338-    category: "discoverability",
1339-    method: "GET",
1340-    pathPattern: "/version",
1341-    summary: "读取服务版本",
1342-    schema: CONTROL_API_ROUTE_SCHEMAS["service.version"],
1343-    handler: handleVersionRead
1344-  }),
1345-  defineRoute({
1346-    id: "service.health",
1347-    access: "public",
1348-    category: "discoverability",
1349-    method: "GET",
1350-    pathPattern: "/health",
1351-    summary: "读取服务健康摘要",
1352-    schema: CONTROL_API_ROUTE_SCHEMAS["service.health"],
1353-    handler: handleHealthRead
1354-  }),
1355-  defineRoute({
1356-    id: "system.capabilities",
1357-    access: "public",
1358-    category: "discoverability",
1359-    method: "GET",
1360-    pathPattern: "/v1/capabilities",
1361-    summary: "读取能力发现摘要",
1362-    schema: CONTROL_API_ROUTE_SCHEMAS["system.capabilities"],
1363-    handler: handleCapabilitiesRead
1364-  }),
1365-  defineRoute({
1366-    id: "controllers.list",
1367-    category: "controllers",
1368-    method: "GET",
1369-    pathPattern: "/v1/controllers",
1370-    summary: "列出 controller 摘要",
1371-    schema: CONTROL_API_ROUTE_SCHEMAS["controllers.list"],
1372-    handler: handleControllersList
1373-  }),
1374-  defineRoute({
1375-    id: "controllers.heartbeat",
1376-    category: "controllers",
1377-    method: "POST",
1378-    pathPattern: "/v1/controllers/heartbeat",
1379-    summary: "controller 心跳",
1380-    schema: CONTROL_API_ROUTE_SCHEMAS["controllers.heartbeat"],
1381-    ownershipResolver: resolveControllerOwnership,
1382-    handler: handleControllerHeartbeat
1383-  }),
1384-  defineRoute({
1385-    id: "leader.acquire",
1386-    category: "leader",
1387-    method: "POST",
1388-    pathPattern: "/v1/leader/acquire",
1389-    summary: "获取或续租 leader lease",
1390-    schema: CONTROL_API_ROUTE_SCHEMAS["leader.acquire"],
1391-    ownershipResolver: resolveControllerOwnership,
1392-    handler: handleLeaderAcquire
1393-  }),
1394-  defineRoute({
1395-    id: "tasks.list",
1396-    category: "tasks",
1397-    method: "GET",
1398-    pathPattern: "/v1/tasks",
1399-    summary: "列出 task 摘要",
1400-    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.list"],
1401-    handler: handleTasksList
1402-  }),
1403-  defineRoute({
1404-    id: "tasks.create",
1405-    category: "tasks",
1406-    method: "POST",
1407-    pathPattern: "/v1/tasks",
1408-    summary: "创建 task",
1409-    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.create"],
1410-    handler: handleTaskCreate
1411-  }),
1412-  defineRoute({
1413-    id: "tasks.plan",
1414-    category: "tasks",
1415-    method: "POST",
1416-    pathPattern: "/v1/tasks/:task_id/plan",
1417-    summary: "持久化已验收 plan",
1418-    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.plan"],
1419-    ownershipResolver: resolveControllerOwnership
1420-  }),
1421-  defineRoute({
1422-    id: "tasks.claim",
1423-    category: "tasks",
1424-    method: "POST",
1425-    pathPattern: "/v1/tasks/claim",
1426-    summary: "领取待规划 task 或 runnable step",
1427-    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.claim"],
1428-    ownershipResolver: resolveControllerOwnership
1429-  }),
1430-  defineRoute({
1431-    id: "steps.heartbeat",
1432-    category: "steps",
1433-    method: "POST",
1434-    pathPattern: "/v1/steps/:step_id/heartbeat",
1435-    summary: "step 心跳",
1436-    schema: CONTROL_API_ROUTE_SCHEMAS["steps.heartbeat"],
1437-    ownershipResolver: resolveWorkerOwnership
1438-  }),
1439-  defineRoute({
1440-    id: "steps.checkpoint",
1441-    category: "steps",
1442-    method: "POST",
1443-    pathPattern: "/v1/steps/:step_id/checkpoint",
1444-    summary: "写 step checkpoint",
1445-    schema: CONTROL_API_ROUTE_SCHEMAS["steps.checkpoint"],
1446-    ownershipResolver: resolveWorkerOwnership
1447-  }),
1448-  defineRoute({
1449-    id: "steps.complete",
1450-    category: "steps",
1451-    method: "POST",
1452-    pathPattern: "/v1/steps/:step_id/complete",
1453-    summary: "标记 step 完成",
1454-    schema: CONTROL_API_ROUTE_SCHEMAS["steps.complete"],
1455-    ownershipResolver: resolveWorkerOwnership
1456-  }),
1457-  defineRoute({
1458-    id: "steps.fail",
1459-    category: "steps",
1460-    method: "POST",
1461-    pathPattern: "/v1/steps/:step_id/fail",
1462-    summary: "标记 step 失败",
1463-    schema: CONTROL_API_ROUTE_SCHEMAS["steps.fail"],
1464-    ownershipResolver: resolveWorkerOwnership
1465-  }),
1466-  defineRoute({
1467-    id: "system.pause",
1468-    category: "system",
1469-    method: "POST",
1470-    pathPattern: "/v1/system/pause",
1471-    summary: "暂停自动化",
1472-    schema: CONTROL_API_ROUTE_SCHEMAS["system.pause"],
1473-    handler: createSystemMutationHandler("paused")
1474-  }),
1475-  defineRoute({
1476-    id: "system.resume",
1477-    category: "system",
1478-    method: "POST",
1479-    pathPattern: "/v1/system/resume",
1480-    summary: "恢复自动化",
1481-    schema: CONTROL_API_ROUTE_SCHEMAS["system.resume"],
1482-    handler: createSystemMutationHandler("running")
1483-  }),
1484-  defineRoute({
1485-    id: "system.drain",
1486-    category: "system",
1487-    method: "POST",
1488-    pathPattern: "/v1/system/drain",
1489-    summary: "drain 自动化",
1490-    schema: CONTROL_API_ROUTE_SCHEMAS["system.drain"],
1491-    handler: createSystemMutationHandler("draining")
1492-  }),
1493-  defineRoute({
1494-    id: "system.state",
1495-    category: "system",
1496-    method: "GET",
1497-    pathPattern: "/v1/system/state",
1498-    summary: "读取系统状态",
1499-    schema: CONTROL_API_ROUTE_SCHEMAS["system.state"],
1500-    handler: handleSystemStateRead
1501-  }),
1502-  defineRoute({
1503-    id: "tasks.read",
1504-    category: "tasks",
1505-    method: "GET",
1506-    pathPattern: "/v1/tasks/:task_id",
1507-    summary: "读取 task 详情",
1508-    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.read"],
1509-    handler: handleTaskRead
1510-  }),
1511-  defineRoute({
1512-    id: "tasks.logs.read",
1513-    category: "tasks",
1514-    method: "GET",
1515-    pathPattern: "/v1/tasks/:task_id/logs",
1516-    summary: "读取 task 日志",
1517-    schema: CONTROL_API_ROUTE_SCHEMAS["tasks.logs.read"]
1518-  }),
1519-  defineRoute({
1520-    id: "runs.list",
1521-    category: "runs",
1522-    method: "GET",
1523-    pathPattern: "/v1/runs",
1524-    summary: "列出 run 摘要",
1525-    schema: CONTROL_API_ROUTE_SCHEMAS["runs.list"],
1526-    handler: handleRunsList
1527-  }),
1528-  defineRoute({
1529-    id: "runs.read",
1530-    category: "runs",
1531-    method: "GET",
1532-    pathPattern: "/v1/runs/:run_id",
1533-    summary: "读取 run 详情",
1534-    schema: CONTROL_API_ROUTE_SCHEMAS["runs.read"],
1535-    handler: handleRunRead
1536-  })
1537-];
1538-
1539-export function describeControlApiSurface(): string[] {
1540-  return CONTROL_API_ROUTES.map((route) => `${route.method} ${route.pathPattern} - ${route.summary}`);
1541-}
1542-
1543-export function describeControlApiContracts(): string[] {
1544-  return CONTROL_API_ROUTES.map((route) => {
1545-    const requestBody = route.schema.requestBody ?? "none";
1546-    return `${route.method} ${route.pathPattern} -> request: ${requestBody}, response: ${route.schema.responseBody}`;
1547-  });
1548-}
D apps/control-api-worker/src/index.ts
+0, -11
 1@@ -1,11 +0,0 @@
 2-export * from "./contracts.js";
 3-export * from "./handlers.js";
 4-export * from "./router.js";
 5-export * from "./runtime.js";
 6-export * from "./schemas.js";
 7-
 8-import { createControlApiWorker } from "./router.js";
 9-
10-const controlApiWorker = createControlApiWorker();
11-
12-export default controlApiWorker;
D apps/control-api-worker/src/router.ts
+0, -350
  1@@ -1,350 +0,0 @@
  2-import type { JsonValue } from "@baa-conductor/db";
  3-import type {
  4-  ControlApiErrorEnvelope,
  5-  ControlApiExecutionContext,
  6-  ControlApiHandlerFailure,
  7-  ControlApiHandlerResult,
  8-  ControlApiRouteAuthorization,
  9-  ControlApiRouteDefinition,
 10-  ControlApiRouteMethod,
 11-  ControlApiServices,
 12-  ControlApiSuccessEnvelope,
 13-  ControlApiWorker,
 14-  ControlApiWorkerOptions,
 15-  ControlApiEnv
 16-} from "./contracts.js";
 17-import { CONTROL_API_ROUTES } from "./handlers.js";
 18-import { createControlApiServices } from "./runtime.js";
 19-
 20-const SUPPORTED_METHODS: ControlApiRouteMethod[] = ["GET", "POST"];
 21-const SUPPORTED_REQUEST_HEADERS = ["Accept", "Authorization", "Content-Type", "X-Request-Id"];
 22-
 23-interface ControlApiRouteMatch {
 24-  route: ControlApiRouteDefinition;
 25-  params: Record<string, string>;
 26-}
 27-
 28-export function createControlApiWorker(options: ControlApiWorkerOptions = {}): ControlApiWorker {
 29-  return {
 30-    async fetch(
 31-      request: Request,
 32-      env: ControlApiEnv,
 33-      executionContext: ControlApiExecutionContext
 34-    ): Promise<Response> {
 35-      return handleControlApiRequest(request, env, executionContext, options);
 36-    }
 37-  };
 38-}
 39-
 40-export async function handleControlApiRequest(
 41-  request: Request,
 42-  env: ControlApiEnv,
 43-  executionContext: ControlApiExecutionContext = {},
 44-  options: ControlApiWorkerOptions = {}
 45-): Promise<Response> {
 46-  const url = new URL(request.url);
 47-  const requestId = resolveRequestId(request, options);
 48-
 49-  if (request.method === "OPTIONS") {
 50-    return preflightResponse(url.pathname);
 51-  }
 52-
 53-  const matchedRoute = matchRoute(request.method, url.pathname);
 54-
 55-  if (!matchedRoute) {
 56-    const allowedMethods = findAllowedMethods(url.pathname);
 57-
 58-    if (allowedMethods.length > 0) {
 59-      return errorResponse(
 60-        requestId,
 61-        {
 62-          ok: false,
 63-          status: 405,
 64-          error: "method_not_allowed",
 65-          message: `Method ${request.method} is not allowed for ${url.pathname}.`,
 66-          details: {
 67-            allow: allowedMethods,
 68-            supported_methods: SUPPORTED_METHODS
 69-          },
 70-          headers: {
 71-            Allow: allowedMethods.join(", ")
 72-          }
 73-        }
 74-      );
 75-    }
 76-
 77-    return errorResponse(requestId, {
 78-      ok: false,
 79-      status: 404,
 80-      error: "route_not_found",
 81-      message: `No Control API route is registered for ${request.method} ${url.pathname}.`,
 82-      details: {
 83-        available_routes: CONTROL_API_ROUTES.map((route) => `${route.method} ${route.pathPattern}`)
 84-      }
 85-    });
 86-  }
 87-
 88-  const bodyResult = await readRequestBody(request);
 89-
 90-  if (!bodyResult.ok) {
 91-    return errorResponse(requestId, bodyResult);
 92-  }
 93-
 94-  const services = createControlApiServices(env, options);
 95-  const authorization = await resolveAuthorization(
 96-    request,
 97-    url,
 98-    matchedRoute,
 99-    bodyResult.body,
100-    services
101-  );
102-
103-  if (isHandlerFailure(authorization)) {
104-    return errorResponse(requestId, authorization);
105-  }
106-
107-  const result = await matchedRoute.route.handler({
108-    request,
109-    env,
110-    executionContext,
111-    url,
112-    requestId,
113-    route: matchedRoute.route,
114-    params: matchedRoute.params,
115-    body: bodyResult.body,
116-    services,
117-    auth: authorization
118-  });
119-
120-  return routeResultToResponse(requestId, result);
121-}
122-
123-function resolveRequestId(request: Request, options: ControlApiWorkerOptions): string {
124-  const headerValue = request.headers.get("x-request-id")?.trim();
125-
126-  if (headerValue) {
127-    return headerValue;
128-  }
129-
130-  return options.requestIdFactory?.(request) ?? crypto.randomUUID();
131-}
132-
133-async function readRequestBody(
134-  request: Request
135-): Promise<
136-  | {
137-      ok: true;
138-      body: JsonValue;
139-    }
140-  | ControlApiHandlerFailure
141-> {
142-  if (request.method === "GET") {
143-    return {
144-      ok: true,
145-      body: null
146-    };
147-  }
148-
149-  const rawBody = await request.text();
150-
151-  if (rawBody.trim().length === 0) {
152-    return {
153-      ok: true,
154-      body: null
155-    };
156-  }
157-
158-  try {
159-    return {
160-      ok: true,
161-      body: JSON.parse(rawBody) as JsonValue
162-    };
163-  } catch {
164-    return {
165-      ok: false,
166-      status: 400,
167-      error: "invalid_json",
168-      message: "Request body must be valid JSON.",
169-      details: {
170-        method: request.method
171-      }
172-    };
173-  }
174-}
175-
176-async function resolveAuthorization(
177-  request: Request,
178-  url: URL,
179-  matchedRoute: ControlApiRouteMatch,
180-  body: JsonValue,
181-  services: ControlApiServices
182-): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure> {
183-  const rule = matchedRoute.route.authRule;
184-
185-  if (matchedRoute.route.access === "public") {
186-    return {
187-      mode: "public",
188-      rule: null,
189-      skipReason: "public_route"
190-    };
191-  }
192-
193-  if (!services.authHook) {
194-    return {
195-      mode: "skipped",
196-      rule,
197-      skipReason: "auth_not_configured"
198-    };
199-  }
200-
201-  return services.authHook.authorize({
202-    body,
203-    params: matchedRoute.params,
204-    request,
205-    route: matchedRoute.route,
206-    url
207-  });
208-}
209-
210-function isHandlerFailure(
211-  result: ControlApiRouteAuthorization | ControlApiHandlerFailure
212-): result is ControlApiHandlerFailure {
213-  return "ok" in result;
214-}
215-
216-function routeResultToResponse(requestId: string, result: ControlApiHandlerResult): Response {
217-  if (result.ok) {
218-    const payload: ControlApiSuccessEnvelope = {
219-      ok: true,
220-      request_id: requestId,
221-      data: result.data
222-    };
223-
224-    return jsonResponse(result.status, payload, requestId, result.headers);
225-  }
226-
227-  return errorResponse(requestId, result);
228-}
229-
230-function errorResponse(requestId: string, result: ControlApiHandlerFailure): Response {
231-  const payload: ControlApiErrorEnvelope = {
232-    ok: false,
233-    request_id: requestId,
234-    error: result.error,
235-    message: result.message
236-  };
237-
238-  if (result.details !== undefined) {
239-    payload.details = result.details;
240-  }
241-
242-  return jsonResponse(result.status, payload, requestId, result.headers);
243-}
244-
245-function jsonResponse(
246-  status: number,
247-  payload: ControlApiSuccessEnvelope | ControlApiErrorEnvelope,
248-  requestId: string,
249-  extraHeaders?: Record<string, string>
250-): Response {
251-  const headers = createCorsHeaders(extraHeaders);
252-  headers.set("content-type", "application/json; charset=utf-8");
253-  headers.set("x-request-id", requestId);
254-
255-  return new Response(JSON.stringify(payload, null, 2), {
256-    status,
257-    headers
258-  });
259-}
260-
261-function preflightResponse(pathname: string): Response {
262-  const allowMethods = findAllowedMethods(pathname);
263-  const methods = allowMethods.length > 0
264-    ? [...new Set(["OPTIONS", ...allowMethods])]
265-    : ["OPTIONS", ...SUPPORTED_METHODS];
266-  const headers = createCorsHeaders({
267-    Allow: methods.join(", "),
268-    "access-control-allow-methods": methods.join(", "),
269-    "access-control-allow-headers": SUPPORTED_REQUEST_HEADERS.join(", "),
270-    "access-control-max-age": "86400"
271-  });
272-
273-  return new Response(null, {
274-    status: 204,
275-    headers
276-  });
277-}
278-
279-function createCorsHeaders(extraHeaders?: Record<string, string>): Headers {
280-  const headers = new Headers(extraHeaders);
281-  headers.set("access-control-allow-origin", "*");
282-  headers.set("access-control-allow-credentials", "false");
283-  headers.set("access-control-expose-headers", "x-request-id");
284-  headers.set("vary", "Origin");
285-  return headers;
286-}
287-
288-function matchRoute(method: string, pathname: string): ControlApiRouteMatch | null {
289-  for (const route of CONTROL_API_ROUTES) {
290-    if (route.method !== method) {
291-      continue;
292-    }
293-
294-    const params = matchPathPattern(route.pathPattern, pathname);
295-
296-    if (params) {
297-      return {
298-        route,
299-        params
300-      };
301-    }
302-  }
303-
304-  return null;
305-}
306-
307-function findAllowedMethods(pathname: string): ControlApiRouteMethod[] {
308-  const methods = new Set<ControlApiRouteMethod>();
309-
310-  for (const route of CONTROL_API_ROUTES) {
311-    if (matchPathPattern(route.pathPattern, pathname)) {
312-      methods.add(route.method);
313-    }
314-  }
315-
316-  return [...methods.values()];
317-}
318-
319-function matchPathPattern(pattern: string, actualPath: string): Record<string, string> | null {
320-  const patternSegments = normalizePath(pattern);
321-  const actualSegments = normalizePath(actualPath);
322-
323-  if (patternSegments.length !== actualSegments.length) {
324-    return null;
325-  }
326-
327-  const params: Record<string, string> = {};
328-
329-  for (const [index, patternSegment] of patternSegments.entries()) {
330-    const actualSegment = actualSegments[index];
331-
332-    if (actualSegment === undefined) {
333-      return null;
334-    }
335-
336-    if (patternSegment.startsWith(":")) {
337-      params[patternSegment.slice(1)] = actualSegment;
338-      continue;
339-    }
340-
341-    if (patternSegment !== actualSegment) {
342-      return null;
343-    }
344-  }
345-
346-  return params;
347-}
348-
349-function normalizePath(path: string): string[] {
350-  return path.replace(/\/+$/u, "").split("/").filter((segment) => segment.length > 0);
351-}
D apps/control-api-worker/src/runtime.ts
+0, -315
  1@@ -1,315 +0,0 @@
  2-import {
  3-  authorizeControlApiRoute,
  4-  extractBearerToken,
  5-  type AuthPrincipal,
  6-  type AuthResourceOwnership,
  7-  type AuthTokenVerifier,
  8-  type AuthVerificationResult,
  9-  DEFAULT_AUTH_AUDIENCE
 10-} from "@baa-conductor/auth";
 11-import { createD1ControlPlaneRepository } from "@baa-conductor/db";
 12-import {
 13-  BAA_SHARED_TOKEN_ENV_NAME,
 14-  CONTROL_API_AUTH_REQUIRED_ENV_NAME,
 15-  CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME,
 16-  CONTROL_API_CONTROLLER_TOKEN_ENV_NAME,
 17-  CONTROL_API_D1_BINDING_NAME,
 18-  CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME,
 19-  CONTROL_API_READONLY_TOKEN_ENV_NAME,
 20-  CONTROL_API_WORKER_TOKEN_ENV_NAME,
 21-  type ControlApiEnv,
 22-  type ControlApiHandlerFailure,
 23-  type ControlApiRequestAuthHook,
 24-  type ControlApiRouteAuthorization,
 25-  type ControlApiServices,
 26-  type ControlApiWorkerOptions
 27-} from "./contracts.js";
 28-
 29-const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
 30-
 31-export function createControlApiServices(
 32-  env: ControlApiEnv,
 33-  options: ControlApiWorkerOptions
 34-): ControlApiServices {
 35-  return {
 36-    authHook: options.authHook ?? createControlApiAuthHook(env, options.tokenVerifier),
 37-    now: options.now ?? (() => Math.floor(Date.now() / 1000)),
 38-    repository: resolveRepository(env, options)
 39-  };
 40-}
 41-
 42-function resolveRepository(
 43-  env: ControlApiEnv,
 44-  options: ControlApiWorkerOptions
 45-): ControlApiServices["repository"] {
 46-  if (options.repository) {
 47-    return options.repository;
 48-  }
 49-
 50-  const database = env[CONTROL_API_D1_BINDING_NAME];
 51-
 52-  if (!database) {
 53-    return null;
 54-  }
 55-
 56-  const repositoryFactory = options.repositoryFactory ?? createD1ControlPlaneRepository;
 57-  return repositoryFactory(database);
 58-}
 59-
 60-export function createControlApiAuthHook(
 61-  env: ControlApiEnv,
 62-  tokenVerifier?: AuthTokenVerifier
 63-): ControlApiRequestAuthHook | null {
 64-  const hasEnvTokens = hasConfiguredEnvTokens(env);
 65-  const authRequired = parseBooleanEnv(env[CONTROL_API_AUTH_REQUIRED_ENV_NAME]);
 66-
 67-  if (authRequired === false) {
 68-    return null;
 69-  }
 70-
 71-  if (!tokenVerifier && !hasEnvTokens && authRequired !== true) {
 72-    return null;
 73-  }
 74-
 75-  return {
 76-    async authorize(input): Promise<ControlApiRouteAuthorization | ControlApiHandlerFailure> {
 77-      if (!tokenVerifier && !hasEnvTokens) {
 78-        return {
 79-          ok: false,
 80-          status: 503,
 81-          error: "auth_not_configured",
 82-          message: "Control API auth is required, but no token verifier or runtime tokens were configured."
 83-        };
 84-      }
 85-
 86-      const tokenResult = extractBearerToken(input.request.headers.get("authorization") ?? undefined);
 87-
 88-      if (!tokenResult.ok) {
 89-        return {
 90-          ok: false,
 91-          status: 401,
 92-          error: tokenResult.reason,
 93-          message: "Authorization header must use Bearer token syntax for Control API requests."
 94-        };
 95-      }
 96-
 97-      const resource = input.route.ownershipResolver?.({
 98-        params: input.params,
 99-        body: input.body
100-      });
101-
102-      if (input.route.authRule == null) {
103-        return {
104-          mode: "public",
105-          rule: null,
106-          skipReason: "public_route"
107-        };
108-      }
109-
110-      const verification = await verifyBearerToken(
111-        tokenResult.token,
112-        input.route.authRule.action,
113-        resource,
114-        env,
115-        tokenVerifier
116-      );
117-
118-      if (!verification.ok) {
119-        return {
120-          ok: false,
121-          status: verification.statusCode,
122-          error: verification.reason,
123-          message: `Bearer token verification failed: ${verification.reason}.`
124-        };
125-      }
126-
127-      const authorization = authorizeControlApiRoute({
128-        method: input.route.method,
129-        path: input.url.pathname,
130-        principal: verification.principal,
131-        resource
132-      });
133-
134-      if (!authorization.ok) {
135-        return {
136-          ok: false,
137-          status: authorization.statusCode,
138-          error: authorization.reason,
139-          message: `Authenticated principal is not allowed to access ${input.route.method} ${input.route.pathPattern}.`
140-        };
141-      }
142-
143-      return {
144-        mode: "verified",
145-        rule: authorization.matchedRule ?? input.route.authRule,
146-        principal: verification.principal
147-      };
148-    }
149-  };
150-}
151-
152-async function verifyBearerToken(
153-  token: string,
154-  action: string,
155-  resource: AuthResourceOwnership | undefined,
156-  env: ControlApiEnv,
157-  tokenVerifier?: AuthTokenVerifier
158-): Promise<AuthVerificationResult> {
159-  if (tokenVerifier) {
160-    const result = await tokenVerifier.verifyBearerToken(token);
161-
162-    if (result.ok || result.reason !== "unknown_token") {
163-      return result;
164-    }
165-  }
166-
167-  return verifyEnvToken(token, action, resource, env);
168-}
169-
170-function verifyEnvToken(
171-  token: string,
172-  action: string,
173-  resource: AuthResourceOwnership | undefined,
174-  env: ControlApiEnv
175-): AuthVerificationResult {
176-  if (token === env[CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME]) {
177-    return {
178-      ok: true,
179-      principal: buildStaticPrincipal("browser_admin")
180-    };
181-  }
182-
183-  if (token === env[CONTROL_API_READONLY_TOKEN_ENV_NAME]) {
184-    return {
185-      ok: true,
186-      principal: buildStaticPrincipal("readonly")
187-    };
188-  }
189-
190-  if (token === env[CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME]) {
191-    return {
192-      ok: true,
193-      principal: buildStaticPrincipal("ops_admin")
194-    };
195-  }
196-
197-  if (token === env[CONTROL_API_CONTROLLER_TOKEN_ENV_NAME]) {
198-    return {
199-      ok: true,
200-      principal: buildServicePrincipal("controller", resource)
201-    };
202-  }
203-
204-  if (token === env[CONTROL_API_WORKER_TOKEN_ENV_NAME]) {
205-    return {
206-      ok: true,
207-      principal: buildServicePrincipal("worker", resource)
208-    };
209-  }
210-
211-  if (token === env[BAA_SHARED_TOKEN_ENV_NAME]) {
212-    const role = resolveServiceRole(action);
213-
214-    if (role) {
215-      return {
216-        ok: true,
217-        principal: buildServicePrincipal(role, resource)
218-      };
219-    }
220-  }
221-
222-  return {
223-    ok: false,
224-    reason: "unknown_token",
225-    statusCode: 401
226-  };
227-}
228-
229-function hasConfiguredEnvTokens(env: ControlApiEnv): boolean {
230-  return [
231-    env[BAA_SHARED_TOKEN_ENV_NAME],
232-    env[CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME],
233-    env[CONTROL_API_CONTROLLER_TOKEN_ENV_NAME],
234-    env[CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME],
235-    env[CONTROL_API_READONLY_TOKEN_ENV_NAME],
236-    env[CONTROL_API_WORKER_TOKEN_ENV_NAME]
237-  ].some((value) => typeof value === "string" && value.trim().length > 0);
238-}
239-
240-function parseBooleanEnv(value: string | undefined): boolean | undefined {
241-  if (value == null) {
242-    return undefined;
243-  }
244-
245-  const normalized = value.trim().toLowerCase();
246-
247-  if (TRUE_ENV_VALUES.has(normalized)) {
248-    return true;
249-  }
250-
251-  if (["0", "false", "no", "off"].includes(normalized)) {
252-    return false;
253-  }
254-
255-  return undefined;
256-}
257-
258-function resolveServiceRole(action: string): "controller" | "worker" | null {
259-  if (
260-    action === "controllers.heartbeat" ||
261-    action === "leader.acquire" ||
262-    action === "tasks.claim" ||
263-    action === "tasks.plan"
264-  ) {
265-    return "controller";
266-  }
267-
268-  if (
269-    action === "steps.heartbeat" ||
270-    action === "steps.checkpoint" ||
271-    action === "steps.complete" ||
272-    action === "steps.fail"
273-  ) {
274-    return "worker";
275-  }
276-
277-  return null;
278-}
279-
280-function buildStaticPrincipal(role: "browser_admin" | "ops_admin" | "readonly"): AuthPrincipal {
281-  return {
282-    audience: DEFAULT_AUTH_AUDIENCE,
283-    role,
284-    sessionId: role,
285-    subject: role,
286-    tokenKind: role === "ops_admin" ? "ops_session" : "browser_session"
287-  };
288-}
289-
290-function buildServicePrincipal(
291-  role: "controller" | "worker",
292-  resource: AuthResourceOwnership | undefined
293-): AuthPrincipal {
294-  if (role === "controller") {
295-    const controllerId = resource?.controllerId;
296-
297-    return {
298-      audience: DEFAULT_AUTH_AUDIENCE,
299-      controllerId,
300-      nodeId: controllerId,
301-      role,
302-      subject: controllerId ?? "controller",
303-      tokenKind: "service_hmac"
304-    };
305-  }
306-
307-  const workerId = resource?.workerId;
308-
309-  return {
310-    audience: DEFAULT_AUTH_AUDIENCE,
311-    role,
312-    subject: workerId ?? "worker",
313-    tokenKind: "service_hmac",
314-    workerId
315-  };
316-}
D apps/control-api-worker/src/schemas.ts
+0, -407
  1@@ -1,407 +0,0 @@
  2-import type { AutomationMode, JsonObject, JsonValue, StepKind, StepStatus, TaskStatus } from "@baa-conductor/db";
  3-import type { ControlApiRouteId, ControlApiRouteSchemaDescriptor } from "./contracts.js";
  4-
  5-export interface ControllerHeartbeatRequest {
  6-  controller_id: string;
  7-  host: string;
  8-  role: string;
  9-  priority: number;
 10-  status: string;
 11-  version: string;
 12-  metadata?: JsonObject;
 13-}
 14-
 15-export interface LeaderAcquireRequest {
 16-  controller_id: string;
 17-  host: string;
 18-  preferred: boolean;
 19-  ttl_sec: number;
 20-}
 21-
 22-export interface LeaderAcquireResponseData {
 23-  holder_id: string;
 24-  term: number;
 25-  lease_expires_at: number;
 26-  is_leader: boolean;
 27-}
 28-
 29-export interface ServiceDescribeResponseData {
 30-  name: string;
 31-  version: string;
 32-  description: string;
 33-}
 34-
 35-export interface ServiceVersionResponseData {
 36-  name: string;
 37-  version: string;
 38-}
 39-
 40-export interface ServiceHealthResponseData {
 41-  name: string;
 42-  version: string;
 43-  status: "ok" | "degraded";
 44-}
 45-
 46-export interface ControllerSummaryData {
 47-  controller_id: string;
 48-  host: string;
 49-  role: string;
 50-  status: string;
 51-  version: string | null;
 52-  priority: number;
 53-  last_heartbeat_at: number;
 54-  is_leader: boolean;
 55-}
 56-
 57-export interface ControllersListResponseData {
 58-  controllers: ControllerSummaryData[];
 59-}
 60-
 61-export interface TaskCreateRequest {
 62-  repo: string;
 63-  task_type: string;
 64-  title: string;
 65-  goal: string;
 66-  priority?: number;
 67-  constraints?: JsonObject;
 68-  acceptance?: string[];
 69-  metadata?: JsonObject;
 70-}
 71-
 72-export interface TaskCreateResponseData {
 73-  task_id: string;
 74-  status: TaskStatus;
 75-  branch_name: string | null;
 76-  base_ref: string | null;
 77-}
 78-
 79-export interface TaskSummaryData {
 80-  task_id: string;
 81-  repo: string;
 82-  task_type: string;
 83-  title: string;
 84-  status: TaskStatus;
 85-  priority: number;
 86-  updated_at: number;
 87-}
 88-
 89-export interface TaskListResponseData {
 90-  tasks: TaskSummaryData[];
 91-}
 92-
 93-export interface TaskPlanStepRequest {
 94-  step_id?: string;
 95-  step_name: string;
 96-  step_kind: StepKind;
 97-  summary?: string;
 98-  input?: JsonValue;
 99-  timeout_sec?: number;
100-  retry_limit?: number;
101-}
102-
103-export interface TaskPlanRequest {
104-  controller_id: string;
105-  planner_provider?: string;
106-  reasoning?: string;
107-  steps: TaskPlanStepRequest[];
108-}
109-
110-export interface TaskClaimRequest {
111-  controller_id: string;
112-  host: string;
113-  include_planning?: boolean;
114-  worker_types?: string[];
115-}
116-
117-export interface TaskClaimResponseData {
118-  claim_type: "planning" | "step" | null;
119-  task_id: string | null;
120-  step_id: string | null;
121-  run_id: string | null;
122-}
123-
124-export interface StepHeartbeatCheckpointPayload {
125-  seq?: number;
126-  checkpoint_type?: string;
127-  summary?: string;
128-}
129-
130-export interface StepHeartbeatRequest {
131-  run_id: string;
132-  worker_id: string;
133-  lease_expires_at: number;
134-  checkpoint?: StepHeartbeatCheckpointPayload;
135-}
136-
137-export interface StepCheckpointRequest {
138-  run_id: string;
139-  worker_id: string;
140-  seq: number;
141-  checkpoint_type: string;
142-  summary?: string;
143-  content_text?: string;
144-  content_json?: JsonObject;
145-}
146-
147-export interface StepCompleteRequest {
148-  run_id: string;
149-  worker_id: string;
150-  summary?: string;
151-  result?: JsonValue;
152-}
153-
154-export interface StepFailRequest {
155-  run_id: string;
156-  worker_id: string;
157-  error: string;
158-  retryable?: boolean;
159-  result?: JsonValue;
160-}
161-
162-export interface SystemMutationRequest {
163-  reason?: string;
164-  requested_by?: string;
165-  source?: string;
166-}
167-
168-export interface ControlApiAckResponse {
169-  accepted: boolean;
170-  status: "placeholder" | "queued" | "applied";
171-  summary: string;
172-}
173-
174-export interface SystemStateResponseData {
175-  mode: AutomationMode;
176-  holder_id: string | null;
177-  term: number | null;
178-  lease_expires_at: number | null;
179-}
180-
181-export interface TaskDetailResponseData {
182-  task_id: string;
183-  title: string;
184-  status: TaskStatus;
185-  current_step_index: number;
186-}
187-
188-export interface TaskLogEntryData {
189-  seq: number;
190-  stream: string;
191-  level: string | null;
192-  message: string;
193-  created_at: number;
194-}
195-
196-export interface TaskLogsResponseData {
197-  task_id: string;
198-  run_id: string | null;
199-  entries: TaskLogEntryData[];
200-}
201-
202-export interface RunSummaryData {
203-  run_id: string;
204-  task_id: string;
205-  step_id: string;
206-  status: string;
207-  created_at: number;
208-}
209-
210-export interface RunListResponseData {
211-  runs: RunSummaryData[];
212-}
213-
214-export interface RunDetailResponseData {
215-  run_id: string;
216-  task_id: string;
217-  step_id: string;
218-  status: StepStatus;
219-  lease_expires_at: number | null;
220-  heartbeat_at: number | null;
221-}
222-
223-export const CONTROL_API_ROUTE_SCHEMAS = {
224-  "service.describe": {
225-    requestBody: null,
226-    responseBody: "ServiceDescribeResponseData",
227-    notes: [
228-      "给 AI 或手机端网页先读的完整自描述接口。",
229-      "返回当前服务模式、端点、能力、示例和说明。"
230-    ]
231-  },
232-  "service.version": {
233-    requestBody: null,
234-    responseBody: "ServiceVersionResponseData",
235-    notes: [
236-      "轻量版本查询接口。",
237-      "适合做最小探针或排查部署版本。"
238-    ]
239-  },
240-  "service.health": {
241-    requestBody: null,
242-    responseBody: "ServiceHealthResponseData",
243-    notes: [
244-      "返回服务是否可响应,以及当前 repository 绑定是否就绪。",
245-      "不执行写操作。"
246-    ]
247-  },
248-  "system.capabilities": {
249-    requestBody: null,
250-    responseBody: "JsonObject",
251-    notes: [
252-      "比 /describe 更窄,专门给 AI 做能力发现。",
253-      "重点说明只读接口、写接口、部署模式和鉴权模式。"
254-    ]
255-  },
256-  "controllers.list": {
257-    requestBody: null,
258-    responseBody: "ControllersListResponseData",
259-    notes: [
260-      "单节点 mini 模式下也会返回已注册 controller 摘要。",
261-      "可用于发现当前 active controller。"
262-    ]
263-  },
264-  "controllers.heartbeat": {
265-    requestBody: "ControllerHeartbeatRequest",
266-    responseBody: "ControlApiAckResponse",
267-    notes: [
268-      "供 mini/mac conductor 上报自身活跃状态。",
269-      "后续实现会在这里挂 controller upsert 和 request log。"
270-    ]
271-  },
272-  "leader.acquire": {
273-    requestBody: "LeaderAcquireRequest",
274-    responseBody: "LeaderAcquireResponseData",
275-    notes: [
276-      "租约 term、ttl 和冲突返回由 T-004 接入。",
277-      "当前只保留路由、鉴权和 D1 接入点。"
278-    ]
279-  },
280-  "tasks.create": {
281-    requestBody: "TaskCreateRequest",
282-    responseBody: "TaskCreateResponseData",
283-    notes: [
284-      "可见 control 会话通过此接口创建 task。",
285-      "task 归一化和写表逻辑将在后续任务接入。"
286-    ]
287-  },
288-  "tasks.list": {
289-    requestBody: null,
290-    responseBody: "TaskListResponseData",
291-    notes: [
292-      "返回最近 task 摘要,支持最小 status/limit 查询。",
293-      "适合 AI 先了解系统最近有哪些任务。"
294-    ]
295-  },
296-  "tasks.plan": {
297-    requestBody: "TaskPlanRequest",
298-    responseBody: "ControlApiAckResponse",
299-    notes: [
300-      "只接受已通过 conductor 校验的结构化 step plan。",
301-      "持久化 task_steps 的事务逻辑后续实现。"
302-    ]
303-  },
304-  "tasks.claim": {
305-    requestBody: "TaskClaimRequest",
306-    responseBody: "TaskClaimResponseData",
307-    notes: [
308-      "支持领取 planning task 或 runnable step。",
309-      "claim 的事务语义会在 lease 调度侧补齐。"
310-    ]
311-  },
312-  "steps.heartbeat": {
313-    requestBody: "StepHeartbeatRequest",
314-    responseBody: "ControlApiAckResponse",
315-    notes: [
316-      "worker 续租 step 级别心跳。",
317-      "checkpoint 摘要可以随心跳一并上报。"
318-    ]
319-  },
320-  "steps.checkpoint": {
321-    requestBody: "StepCheckpointRequest",
322-    responseBody: "ControlApiAckResponse",
323-    notes: [
324-      "checkpoint payload 结构已预留。",
325-      "写入 task_checkpoints 的逻辑后续接入。"
326-    ]
327-  },
328-  "steps.complete": {
329-    requestBody: "StepCompleteRequest",
330-    responseBody: "ControlApiAckResponse",
331-    notes: [
332-      "worker 结束 step 时写入 summary 和 result。",
333-      "状态迁移及 artifact 落盘后续实现。"
334-    ]
335-  },
336-  "steps.fail": {
337-    requestBody: "StepFailRequest",
338-    responseBody: "ControlApiAckResponse",
339-    notes: [
340-      "失败回写需要保留 retryable 和 error 语义。",
341-      "实际重试策略由 conductor 端决定。"
342-    ]
343-  },
344-  "system.pause": {
345-    requestBody: "SystemMutationRequest",
346-    responseBody: "ControlApiAckResponse",
347-    notes: [
348-      "browser_admin 调用的系统状态切换入口。",
349-      "system_state 写表与广播逻辑后续实现。"
350-    ]
351-  },
352-  "system.resume": {
353-    requestBody: "SystemMutationRequest",
354-    responseBody: "ControlApiAckResponse",
355-    notes: [
356-      "browser_admin 调用的系统状态切换入口。",
357-      "system_state 写表与广播逻辑后续实现。"
358-    ]
359-  },
360-  "system.drain": {
361-    requestBody: "SystemMutationRequest",
362-    responseBody: "ControlApiAckResponse",
363-    notes: [
364-      "browser_admin 调用的系统状态切换入口。",
365-      "system_state 写表与广播逻辑后续实现。"
366-    ]
367-  },
368-  "system.state": {
369-    requestBody: null,
370-    responseBody: "SystemStateResponseData",
371-    notes: [
372-      "供 control/status UI 查询全局 automation 状态。",
373-      "最终返回会结合 lease、queue 与运行态汇总。"
374-    ]
375-  },
376-  "tasks.read": {
377-    requestBody: null,
378-    responseBody: "TaskDetailResponseData",
379-    notes: [
380-      "读取 task 聚合详情。",
381-      "后续可扩展 steps、artifacts 和 handoff 信息。"
382-    ]
383-  },
384-  "tasks.logs.read": {
385-    requestBody: null,
386-    responseBody: "TaskLogsResponseData",
387-    notes: [
388-      "读取 task 或 run 关联日志。",
389-      "T-010 可以直接复用该接口合同。"
390-    ]
391-  },
392-  "runs.read": {
393-    requestBody: null,
394-    responseBody: "RunDetailResponseData",
395-    notes: [
396-      "返回 step 运行态与最近一次 heartbeat。",
397-      "后续可补 checkpoint/artifact 索引。"
398-    ]
399-  },
400-  "runs.list": {
401-    requestBody: null,
402-    responseBody: "RunListResponseData",
403-    notes: [
404-      "列出最近 run 摘要。",
405-      "适合 AI 或运维快速判断系统最近执行了什么。"
406-    ]
407-  }
408-} satisfies Record<ControlApiRouteId, ControlApiRouteSchemaDescriptor>;
D apps/control-api-worker/tsconfig.json
+0, -17
 1@@ -1,17 +0,0 @@
 2-{
 3-  "extends": "../../tsconfig.base.json",
 4-  "compilerOptions": {
 5-    "rootDir": "../..",
 6-    "outDir": "dist",
 7-    "baseUrl": "../..",
 8-    "paths": {
 9-      "@baa-conductor/auth": ["packages/auth/src/index.ts"],
10-      "@baa-conductor/db": ["packages/db/src/index.ts"]
11-    }
12-  },
13-  "include": [
14-    "src/**/*.ts",
15-    "../../packages/auth/src/**/*.ts",
16-    "../../packages/db/src/**/*.ts"
17-  ]
18-}
D apps/control-api-worker/wrangler.jsonc
+0, -29
 1@@ -1,29 +0,0 @@
 2-{
 3-  "name": "baa-conductor-control-api",
 4-  "main": "dist/index.js",
 5-  "compatibility_date": "2026-03-22",
 6-  "workers_dev": false,
 7-  "routes": [
 8-    {
 9-      "pattern": "control-api.makefile.so",
10-      "custom_domain": true
11-    }
12-  ],
13-  "vars": {
14-    "CONTROL_API_VERSION": "2026-03-22",
15-    "CONTROL_API_AUTH_REQUIRED": "false"
16-  },
17-  "d1_databases": [
18-    {
19-      "binding": "CONTROL_DB",
20-      "database_name": "baa-conductor-control-prod",
21-      "database_id": "e2cf2bce-a744-478c-bdcd-b159aa0645c0",
22-      "preview_database_id": "e2cf2bce-a744-478c-bdcd-b159aa0645c0",
23-      "migrations_table": "d1_migrations",
24-      "migrations_dir": "../../ops/sql/migrations"
25-    }
26-  ],
27-  "observability": {
28-    "enabled": true
29-  }
30-}
A coordination/tasks/T-C004.md
+119, -0
  1@@ -0,0 +1,119 @@
  2+---
  3+task_id: T-C004
  4+title: 删除 Worker / D1 / control-api 旧面
  5+status: review
  6+branch: feat/remove-worker-and-cutover
  7+repo: /Users/george/code/baa-conductor
  8+base_ref: main@70f8a64
  9+depends_on:
 10+  - T-C002
 11+  - T-C003
 12+write_scope:
 13+  - plugins/baa-firefox/**
 14+  - docs/**
 15+  - ops/nginx/**
 16+  - scripts/ops/**
 17+  - scripts/runtime/**
 18+  - apps/control-api-worker/**
 19+  - package.json
 20+  - pnpm-workspace.yaml
 21+updated_at: 2026-03-22
 22+---
 23+
 24+# 删除 Worker / D1 / control-api 旧面
 25+
 26+## 目标
 27+
 28+把插件、运行脚本和文档默认口径统一到:
 29+
 30+- 本地 Firefox WS
 31+- `conductor.makefile.so`
 32+
 33+并删除:
 34+
 35+- `apps/control-api-worker`
 36+- Cloudflare Worker 旧部署面
 37+- D1 作为主控制面的描述
 38+
 39+## 本任务包含
 40+
 41+- 删除 workspace 内的 `control-api-worker` 应用目录
 42+- 把运行脚本默认 `BAA_CONTROL_API_BASE` 切到 `https://conductor.makefile.so`
 43+- 去掉插件和文档里的 `control-api.makefile.so` / Worker / D1 主路径表述
 44+- 保持 `status-api` 仅为本地只读观察面
 45+
 46+## 本任务不包含
 47+
 48+- 不删除 `conductor-daemon`
 49+- 不删除 `status-api`
 50+- 不删除 `worker-runner`
 51+- 不改动 write scope 以外的残留兼容代码
 52+
 53+## 建议起始文件
 54+
 55+- `plugins/baa-firefox/controller.html`
 56+- `plugins/baa-firefox/README.md`
 57+- `docs/api/README.md`
 58+- `docs/runtime/launchd.md`
 59+- `scripts/runtime/common.sh`
 60+- `scripts/runtime/install-mini.sh`
 61+
 62+## 交付物
 63+
 64+- 已删除的 `apps/control-api-worker`
 65+- 默认走 `conductor.makefile.so` 的运行脚本
 66+- 去掉旧主控制面表述后的插件/运行/运维文档
 67+
 68+## 验收
 69+
 70+- `pnpm -r build` 通过
 71+- 文档口径与脚本默认值一致
 72+- `git diff --check` 通过
 73+
 74+## files_changed
 75+
 76+- `coordination/tasks/T-C004.md`
 77+- `apps/control-api-worker/**`(已删除)
 78+- `scripts/runtime/common.sh`
 79+- `scripts/runtime/install-mini.sh`
 80+- `plugins/baa-firefox/controller.html`
 81+- `plugins/baa-firefox/README.md`
 82+- `plugins/baa-firefox/docs/conductor-control.md`
 83+- `docs/firefox/README.md`
 84+- `docs/api/README.md`
 85+- `docs/api/business-interfaces.md`
 86+- `docs/api/hand-shell-migration.md`
 87+- `docs/api/local-host-ops.md`
 88+- `docs/auth/README.md`
 89+- `docs/ops/README.md`
 90+- `docs/runtime/README.md`
 91+- `docs/runtime/environment.md`
 92+- `docs/runtime/launchd.md`
 93+- `docs/runtime/node-verification.md`
 94+
 95+## commands_run
 96+
 97+- `git -C /Users/george/code/baa-conductor worktree add -b feat/remove-worker-and-cutover /Users/george/code/worktrees/baa-conductor-t-c004 main`
 98+- `npx --yes pnpm -r build`
 99+- `npx --yes pnpm install --frozen-lockfile`
100+- `ln -s /Users/george/code/baa-conductor/node_modules /Users/george/code/worktrees/baa-conductor-t-c004/node_modules`
101+- `npx --yes pnpm -r build`
102+- `git -C /Users/george/code/worktrees/baa-conductor-t-c004 diff --check`
103+
104+## result
105+
106+- 删除了 `apps/control-api-worker` 整个应用目录,主线 workspace 不再包含 Cloudflare Worker 兼容实现
107+- runtime 脚本默认把兼容变量 `BAA_CONTROL_API_BASE` 收口到 `https://conductor.makefile.so`
108+- `install-mini.sh` 默认改读 `runtime-secrets.env`,仅在需要时回退到 legacy `control-api-worker.secrets.env`
109+- Firefox 插件文案、运行文档和 API/运维文档已统一为“本地 WS + conductor.makefile.so”,并明确 `status-api` 只保留本地只读角色
110+- `pnpm -r build` 与 `git diff --check` 已通过
111+
112+## risks
113+
114+- 仓库 write scope 之外仍有 legacy 残留,例如根 `README.md`、`ops/cloudflare/**`、`tests/control-api/**` 和部分 `apps/status-api` / `apps/conductor-daemon` 内部命名,当前任务未触碰
115+- 本次构建验证依赖主仓库现有 `node_modules`;原因是当前 `pnpm-lock.yaml` 本身已落后于仓库 package 定义,无法在 clean worktree 里直接 `pnpm install --frozen-lockfile`
116+
117+## next_handoff
118+
119+- 如果后续继续做全量删旧,优先处理 write scope 外的 `ops/cloudflare/**`、`tests/control-api/**` 和根文档残留
120+- 如需让 clean worktree 可直接安装依赖,应先单独修正现有 `pnpm-lock.yaml` 与 workspace package 定义不一致的问题
M docs/api/README.md
+6, -6
 1@@ -21,16 +21,16 @@
 2 
 3 - `conductor-daemon` 本地 API 是这些业务接口的真相源
 4 - `status-api` 仍是只读状态视图
 5-- 不恢复 Cloudflare Worker/D1 作为这批业务接口的主真相源
 6+- Cloudflare Worker / D1 / `control-api.makefile.so` 都不再是这批业务接口的主路径
 7 
 8 ## 入口
 9 
10 | 服务 | 地址 | 说明 |
11 | --- | --- | --- |
12+| conductor public host | `https://conductor.makefile.so` | 唯一公网入口;VPS Nginx 回源到同一个 `conductor-daemon` local-api |
13 | conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/system/controllers/tasks/runs/host-ops |
14 | conductor-daemon local-firefox-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/firefox` | 本地 Firefox 插件双向 bridge;复用同一个 listener,不单独开公网端口 |
15-| status-api | `https://conductor.makefile.so` | 只读状态 JSON 和 HTML 视图 |
16-| control-api | `https://control-api.makefile.so` | 仍可保留给遗留/远端控制面合同,但不再是下列业务接口的真相源 |
17+| status-api local view | `http://127.0.0.1:4318` | 本地只读状态 JSON 和 HTML 视图,不承担公网入口角色 |
18 
19 ## Describe First
20 
21@@ -123,8 +123,8 @@ host-ops 约定:
22 当前约定:
23 
24 - 只服务本地 / loopback / 明确允许的 Tailscale `100.x` 地址
25-- 不是公网通道,不对 Cloudflare Worker 暴露
26-- Firefox 插件仍然默认走 HTTP control API;WS 只是手动启用的可选双向 bridge
27+- 不是公网通道,不单独暴露到 `conductor.makefile.so`
28+- Firefox 插件默认同时使用本地 WS 和 `https://conductor.makefile.so` 的 HTTP 控制接口
29 - `state_snapshot.system` 直接复用 `/v1/system/state` 的字段结构
30 - `action_request` 支持 `pause` / `resume` / `drain`
31 - 浏览器发来的 `credentials` / `api_endpoints` 只在服务端保存最小元数据并进入 snapshot,不回显原始 header 值
32@@ -135,7 +135,7 @@ host-ops 约定:
33 
34 ## Status API
35 
36-`status-api` 仍是只读视图服务,不拥有真相。
37+`status-api` 仍是本地只读视图服务,不拥有真相,也不承担公网入口角色。
38 
39 truth source:
40 
M docs/api/business-interfaces.md
+1, -1
1@@ -28,7 +28,7 @@
2 
3 - 本地 `4317` 是当前主真相源
4 - `conductor.makefile.so` 是公网访问入口
5-- 历史兼容面的 `control-api.makefile.so` 不再是业务接口的主入口
6+- 不再提供单独 `control-api` 域名作为业务接口主入口
7 
8 ## 给 AI 的最小使用规则
9 
M docs/api/hand-shell-migration.md
+6, -6
 1@@ -92,7 +92,7 @@
 2 - `worker`
 3 - `logs/checkpoints`
 4 
 5-`apps/control-api-worker` 只保留为 cutover 期间的兼容层,不再是长期主架构。
 6+控制面已经统一收口到 `apps/conductor-daemon`,不再保留单独 Worker 兼容层作为主架构。
 7 
 8 ### 4.3 先读后写
 9 
10@@ -141,7 +141,7 @@
11 
12 - 底层合同与最小 Node 实现已经落到 `packages/host-ops`
13 - 已经正式挂到 `conductor-daemon` 本地 HTTP 面
14-- 还没有对外暴露成 `control-api` 路由
15+- 不会再对外暴露成单独 `control-api` 路由
16 
17 说明:
18 
19@@ -195,7 +195,7 @@
20 - local: `http://100.71.210.78:4317`
21 - public: `https://conductor.makefile.so`
22 
23-`control-api.makefile.so` 在 cutover 完成前可继续兜底,但不再作为新文档和新调用方的默认目标。
24+不再保留单独 `control-api.makefile.so` 作为兼容或默认目标。
25 
26 ## 8. 实施顺序
27 
28@@ -247,10 +247,10 @@
29 
30 已经在本地 `conductor-daemon` 上线;`POST /v1/tasks` 和详情类接口仍按原计划推进。
31 
32-### 第四批
33+### 第四批(当前主线已完成)
34 
35-- 切浏览器、CLI、运维脚本和文档默认目标
36-- 删除 `control-api.makefile.so`、Cloudflare Worker、D1 与 public `status-api` 角色
37+- 浏览器、CLI、运维脚本和文档默认目标已经切到 `conductor.makefile.so`
38+- `control-api.makefile.so`、Cloudflare Worker、D1 与 public `status-api` 不再承担默认控制面角色
39 
40 ## 9. 删旧范围
41 
M docs/api/local-host-ops.md
+1, -1
1@@ -12,7 +12,7 @@
2 - 已有结构化输入输出合同
3 - 已有 package smoke / HTTP 集成测试
4 - 已挂到 `conductor-daemon` 本地 API
5-- 仍然没有挂到 `control-api-worker`
6+- 不再挂到单独 Worker / D1 兼容层
7 
8 ## Operations
9 
M docs/auth/README.md
+2, -2
 1@@ -5,7 +5,7 @@
 2 当前仓库的临时单节点运行模式已经简化为:
 3 
 4 - `mini` 单节点
 5-- Firefox 插件默认直连 `https://control-api.makefile.so`
 6+- Firefox 插件默认直连 `https://conductor.makefile.so`
 7 - `CONTROL_API_AUTH_REQUIRED=false`
 8 
 9 也就是说,这份文档现在更偏“鉴权模型预案”,不是当前 live 临时部署的硬要求。
10@@ -95,7 +95,7 @@ MVP 阶段并不要求四类 token 都立即有完整签发器;`packages/auth`
11 | `GET /v1/tasks/:task_id/logs` | `tasks.logs.read` | `browser_admin`、`ops_admin`、`readonly` | none |
12 | `GET /v1/runs/:run_id` | `runs.read` | `browser_admin`、`ops_admin`、`readonly` | none |
13 
14-`promote` / `demote` 还没有落到 `control-api-worker` 代码里,但鉴权模型先把它们单独列出来,避免后续把维护入口塞进 `browser_admin`。
15+`promote` / `demote` 还没有落到当前主控制面代码里,但鉴权模型先把它们单独列出来,避免后续把维护入口塞进 `browser_admin`。
16 
17 ## `packages/auth` 提供的骨架
18 
M docs/firefox/README.md
+5, -5
 1@@ -9,21 +9,21 @@
 2 ## 当前固定入口
 3 
 4 - Local WS bridge: `ws://127.0.0.1:4317/ws/firefox`
 5-- Public HTTP control API: `https://conductor.makefile.so`
 6+- Public HTTP host: `https://conductor.makefile.so`
 7 
 8 当前插件默认同时使用这两条链路:
 9 
10 - 本地 WS:负责 Firefox bridge 握手、浏览器元数据同步、服务端快照展示
11-- 远程 HTTP:负责控制面状态同步,以及 `pause` / `resume` / `drain` 写入
12+- 公网 HTTP:负责控制面状态同步,以及 `pause` / `resume` / `drain` 写入
13 
14 不再允许在插件管理页中手工编辑地址。
15 
16 ## 目标
17 
18 - 让 Firefox 插件自动接入 `mini` 本地 `/ws/firefox`
19-- 保留远程 HTTP control-plane 状态同步
20+- 保留 `conductor.makefile.so` 上的 HTTP 状态同步
21 - 把管理页收口成 WS 状态、HTTP 状态、控制按钮
22-- 在本地服务重启或远程 HTTP 短暂失败后自动恢复
23+- 在本地服务重启或公网 HTTP 短暂失败后自动恢复
24 
25 ## 非目标
26 
27@@ -45,7 +45,7 @@
28 当前管理页只保留:
29 
30 - 本地 WS 状态卡片
31-- 远程 HTTP 状态卡片
32+- 公网 HTTP 状态卡片
33 - `暂停` / `恢复` / `排空` 按钮
34 - `WS 状态` 原始详情面板
35 - `HTTP 状态` 原始详情面板
M docs/ops/README.md
+5, -19
 1@@ -7,7 +7,7 @@
 2 - `mini` 本地 `4317` 是唯一主接口
 3 - `mini` 本地 `4318` 只作为迁移期的只读观察面
 4 
 5-`control-api.makefile.so`、Cloudflare Worker、D1 仍在仓库和线上资产里,但不再作为默认运维路径描述。
 6+旧的 `control-api` 域名、Cloudflare Worker 和 D1 已退出当前运维口径;默认 runbook 只写 `conductor.makefile.so`。
 7 
 8 主备切换、直连 `mac` 的公网域名和历史切换 runbook 已从当前主线移除。
 9 
10@@ -50,11 +50,6 @@
11 4. `curl https://conductor.makefile.so/v1/runtime`
12 5. 如需 on-node 观察,再看 `curl http://100.71.210.78:4318/v1/status`
13 
14-legacy 兼容盘点时才需要:
15-
16-1. `curl https://control-api.makefile.so/describe`
17-2. `curl https://control-api.makefile.so/v1/system/state`
18-
19 ## 当前节点监听
20 
21 `mini`:
22@@ -126,20 +121,11 @@ ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh --relo
23 3. `conductor.makefile.so` 已有 DNS 记录
24 4. 证书路径与 inventory 一致
25 
26-## cutover 顺序
27-
28-1. 先把完整业务 API 并到 `4317`,并让 `conductor.makefile.so` 暴露同一套路由。
29-2. 再切浏览器、CLI、AI 和运维说明,停止把 `control-api.makefile.so` 当默认路径。
30-3. 最后删除 `control-api.makefile.so`、Cloudflare Worker、D1 和 public `status-api` 角色。
31-
32-## 删旧范围
33-
34-完成 local-api cutover 后,运维侧优先删除:
35+## 当前收口结果
36 
37-- `control-api.makefile.so` 的 DNS / 部署主路径职责
38-- Cloudflare Worker / D1 的默认控制面说明
39-- 所有依赖 `control-api.makefile.so` 的默认 runbook
40-- `status-api` 的公网暴露假设
41+- 浏览器、CLI、AI 和运维说明默认都只写 `conductor.makefile.so`
42+- Cloudflare Worker / D1 不再出现在默认运维 runbook
43+- `status-api` 只保留本地观察面,不承担公网暴露职责
44 
45 ## 说明
46 
M docs/runtime/README.md
+1, -1
1@@ -16,7 +16,7 @@
2 - canonical local Firefox WS: `ws://100.71.210.78:4317/ws/firefox`
3 - canonical public host: `https://conductor.makefile.so`
4 - `status-api` `http://100.71.210.78:4318` 只作为本地只读观察面
5-- `BAA_CONTROL_API_BASE` 仍在当前脚本里保留,但只是兼容变量,不是 canonical 主路径
6+- `BAA_CONTROL_API_BASE` 仍保留为兼容变量名,但默认值已经收口到 `https://conductor.makefile.so`
7 - 推荐仓库路径:`/Users/george/code/baa-conductor`
8 - repo 内的 plist 只作为模板;真正加载的是脚本渲染出来的安装副本
9 
M docs/runtime/environment.md
+5, -5
 1@@ -19,8 +19,8 @@
 2 
 3 说明:
 4 
 5-- `BAA_CONTROL_API_BASE` 是兼容变量,当前主要给 `status-api` 和遗留脚本使用
 6-- 它不代表 canonical 主接口,也不应该再被写成默认控制面
 7+- `BAA_CONTROL_API_BASE` 是兼容变量名,当前主要给 `status-api` 和遗留脚本使用
 8+- 它的默认值已经收口到 `https://conductor.makefile.so`,不再代表单独旧控制面
 9 
10 ## 节点变量
11 
12@@ -31,10 +31,10 @@ BAA_NODE_ID=mini-main
13 BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317
14 BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.71.210.78
15 BAA_STATUS_API_HOST=100.71.210.78
16-BAA_CONTROL_API_BASE=https://control-api.makefile.so
17+BAA_CONTROL_API_BASE=https://conductor.makefile.so
18 ```
19 
20-上面最后一项仍是当前兼容值;等 `status-api` 和 launchd 模板去掉 legacy 依赖后,应从默认运行路径删除。
21+上面最后一项只是兼容旧代码的变量名;默认目标已经与 canonical public host 对齐。
22 
23 Firefox WS 派生规则:
24 
25@@ -56,7 +56,7 @@ Firefox WS 派生规则:
26   --repo-dir /Users/george/code/baa-conductor \
27   --node mini \
28   --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
29-  --control-api-base https://control-api.makefile.so \
30+  --control-api-base https://conductor.makefile.so \
31   --local-api-base http://100.71.210.78:4317 \
32   --local-api-allowed-hosts 100.71.210.78 \
33   --status-api-host 100.71.210.78
M docs/runtime/launchd.md
+6, -6
 1@@ -29,14 +29,14 @@
 2 
 3 如果这个文件不存在,脚本会尝试从:
 4 
 5-- `~/.config/baa-conductor/control-api-worker.secrets.env`
 6+- `~/.config/baa-conductor/runtime-secrets.env`
 7 
 8 里提取 `BAA_SHARED_TOKEN` 并生成它。
 9 
10 说明:
11 
12-- 这里的 `control-api-worker.secrets.env` 只是当前兼容来源,不代表 canonical 控制面仍是 Worker
13-- 脚本里保留 `--control-api-base` 也是同样原因,只为兼容 `status-api`
14+- 如果新的 `runtime-secrets.env` 不存在,脚本还会回退读取 legacy `control-api-worker.secrets.env`
15+- 脚本里保留 `--control-api-base` 只是为了写入兼容变量名;默认值已经是 `https://conductor.makefile.so`
16 
17 ## 日常管理
18 
19@@ -78,7 +78,7 @@ npx --yes pnpm -r build
20 
21 ## 3. 渲染安装副本
22 
23-保留 `--control-api-base`,只是为了让当前 `status-api` 继续工作:
24+保留 `--control-api-base`,只是为了让当前 `status-api` 继续读取兼容变量名:
25 
26 ```bash
27 ./scripts/runtime/install-launchd.sh \
28@@ -88,7 +88,7 @@ npx --yes pnpm -r build
29   --service status-api \
30   --install-dir /Users/george/Library/LaunchAgents \
31   --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
32-  --control-api-base https://control-api.makefile.so \
33+  --control-api-base https://conductor.makefile.so \
34   --local-api-base http://100.71.210.78:4317 \
35   --local-api-allowed-hosts 100.71.210.78 \
36   --status-api-host 100.71.210.78
37@@ -104,7 +104,7 @@ npx --yes pnpm -r build
38   --service status-api \
39   --install-dir /Users/george/Library/LaunchAgents \
40   --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
41-  --control-api-base https://control-api.makefile.so \
42+  --control-api-base https://conductor.makefile.so \
43   --local-api-base http://100.71.210.78:4317 \
44   --local-api-allowed-hosts 100.71.210.78 \
45   --status-api-host 100.71.210.78
M docs/runtime/node-verification.md
+8, -7
 1@@ -13,7 +13,7 @@ npx --yes pnpm -r build
 2   --service status-api \
 3   --install-dir /Users/george/Library/LaunchAgents \
 4   --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
 5-  --control-api-base https://control-api.makefile.so \
 6+  --control-api-base https://conductor.makefile.so \
 7   --local-api-base http://100.71.210.78:4317 \
 8   --local-api-allowed-hosts 100.71.210.78 \
 9   --status-api-host 100.71.210.78
10@@ -21,8 +21,8 @@ npx --yes pnpm -r build
11 
12 说明:
13 
14-- `--control-api-base` 仍是当前静态检查参数,但只用于兼容 `status-api`
15-- 它不改变 `4317` 是 canonical local API 的事实
16+- `--control-api-base` 仍是当前静态检查参数,但只用于校验兼容变量 `BAA_CONTROL_API_BASE`
17+- 它的默认值已经与 `https://conductor.makefile.so` 对齐,不改变 `4317` 是 canonical local API 的事实
18 
19 ## 2. 运行态检查
20 
21@@ -65,18 +65,19 @@ curl -fsSL http://100.71.210.78:4317/v1/runtime
22 curl -fsSL http://100.71.210.78:4318/v1/status
23 ```
24 
25-如果你在做删旧前盘点,才需要额外看 legacy control plane:
26+如果你在做残留依赖排查,优先确认安装副本里的兼容变量已经收口到当前公网域名:
27 
28 ```bash
29-curl -fsSL https://control-api.makefile.so/v1/system/state
30+/usr/libexec/PlistBuddy -c 'Print :EnvironmentVariables:BAA_CONTROL_API_BASE' \
31+  /Users/george/Library/LaunchAgents/so.makefile.baa-conductor.plist
32 ```
33 
34-这个调用只用于确认还有哪些字段或调用方尚未从 legacy control plane 迁出,不是新的默认验证口径。
35+期望输出是 `https://conductor.makefile.so`;如果不是,说明节点仍残留旧口径。
36 
37 ## 常见失败点
38 
39 - `conductor /rolez` 不是 `leader`
40 - `conductor.makefile.so` 没有正确回源到 `100.71.210.78:4317`
41-- `status-api /v1/status` 仍依赖 legacy truth source,导致本地观察结果漂移
42+- `status-api /v1/status` 没有正确回源到当前 `conductor.makefile.so` / `4317`,导致本地观察结果漂移
43 - `launchctl print` 失败
44 - `logs/launchd/*.log` 没有新内容
M plugins/baa-firefox/README.md
+6, -6
 1@@ -5,12 +5,12 @@
 2 ## 当前默认连接
 3 
 4 - 本地 WS bridge:`ws://127.0.0.1:4317/ws/firefox`
 5-- 远程 HTTP control API:`https://conductor.makefile.so`
 6+- 公网 HTTP 入口:`https://conductor.makefile.so`
 7 
 8 管理页已经收口为两块状态和三颗控制按钮:
 9 
10 - 本地 WS 状态
11-- 远程 HTTP 状态
12+- 公网 HTTP 状态
13 - `Pause` / `Resume` / `Drain`
14 
15 不再允许用户手工编辑地址。
16@@ -19,7 +19,7 @@
17 
18 - keeps an always-open `controller.html`
19 - auto-connects the local Firefox bridge WS on startup
20-- keeps polling remote HTTP control state with retry/backoff
21+- keeps polling `conductor.makefile.so` over public HTTP with retry/backoff
22 - sends `hello` / `credentials` / `api_endpoints` metadata to the local WS bridge
23 - lets the operator trigger `pause` / `resume` / `drain`
24 
25@@ -30,7 +30,7 @@
26 - `manifest.json` - Firefox MV3 manifest
27 - `background.js` - keeps the controller tab alive and updates the toolbar badge
28 - `controller.html` - compact management page
29-- `controller.js` - local WS client, remote HTTP client, control actions, browser bridge logic
30+- `controller.js` - local WS client, public HTTP client, control actions, browser bridge logic
31 - `content-script.js` - bridge from page events into the extension runtime
32 - `page-interceptor.js` - MAIN world `fetch` interceptor
33 
34@@ -48,7 +48,7 @@
35 2. Make sure local `conductor-daemon` is listening on `http://127.0.0.1:4317`.
36 3. Open `controller.html` and confirm:
37    - `本地 WS` becomes `已连接`
38-   - `远程 HTTP API` reaches `已连接` or enters visible auto-retry
39+   - `公网 HTTP` reaches `已连接` or enters visible auto-retry
40 4. Click `暂停` / `恢复` / `排空` as needed.
41 5. Open Claude, ChatGPT, or Gemini normally if you want the browser bridge to report credentials and endpoints.
42 
43@@ -56,7 +56,7 @@
44 
45 - WS connect: controller page shows `本地 WS = 已连接`
46 - WS reconnect: stop local daemon and restart it; controller page should move to retrying and then recover automatically
47-- HTTP sync: `远程 HTTP API` should keep refreshing every `15` seconds
48+- HTTP sync: `公网 HTTP` should keep refreshing every `15` seconds
49 - Control buttons: after clicking `暂停` / `恢复` / `排空`, the HTTP state should reflect the new mode
50 
51 ## Limitations
M plugins/baa-firefox/controller.html
+3, -3
 1@@ -11,8 +11,8 @@
 2     <section class="topbar">
 3       <div>
 4         <p class="eyebrow">BAA Firefox 控制台</p>
 5-        <h1>本地 WS / 远程 HTTP</h1>
 6-        <p class="meta">本地 bridge 自动连接 mini,远程控制面固定走 <code>https://conductor.makefile.so</code>。</p>
 7+        <h1>本地 WS / 公网 HTTP</h1>
 8+        <p class="meta">本地 bridge 自动连接 mini,公网入口固定走 <code>https://conductor.makefile.so</code>。</p>
 9       </div>
10     </section>
11 
12@@ -30,7 +30,7 @@
13       </article>
14 
15       <article class="card">
16-        <p class="label">远程 HTTP API</p>
17+        <p class="label">公网 HTTP</p>
18         <p id="control-mode" class="value off">连接中</p>
19         <p id="control-meta" class="meta">等待同步</p>
20       </article>
M plugins/baa-firefox/docs/conductor-control.md
+6, -6
 1@@ -3,12 +3,12 @@
 2 `baa-firefox` 现在默认同时接两条固定链路:
 3 
 4 - 本地 WS bridge:`ws://127.0.0.1:4317/ws/firefox`
 5-- 远程 HTTP control API:`https://conductor.makefile.so`
 6+- 公网 HTTP 入口:`https://conductor.makefile.so`
 7 
 8 管理页已经收口,只保留:
 9 
10 - 本地 WS 状态
11-- 远程 HTTP 状态
12+- 公网 HTTP 状态
13 - `Pause` / `Resume` / `Drain` 按钮
14 
15 不再允许用户手工编辑 WS 地址或 HTTP 地址。
16@@ -29,7 +29,7 @@
17 ## 固定地址
18 
19 - Local WS: `ws://127.0.0.1:4317/ws/firefox`
20-- Remote HTTP: `https://conductor.makefile.so`
21+- Public HTTP: `https://conductor.makefile.so`
22 
23 当前实现不再从 UI 或 storage 读取用户自定义地址;旧配置会被固定地址覆盖。
24 
25@@ -63,7 +63,7 @@
26 
27 ## HTTP 侧职责
28 
29-HTTP 仍然是管理页里控制状态的同步来源,也是控制按钮的写入通道。
30+公网 HTTP 仍然是管理页里控制状态的同步来源,也是控制按钮的写入通道。
31 
32 读取:
33 
34@@ -91,7 +91,7 @@ HTTP 仍然是管理页里控制状态的同步来源,也是控制按钮的写
35 - WS 卡片:连接状态、最近快照、最近错误
36 - HTTP 卡片:连接状态、当前 mode、最近成功/失败、下次重试
37 - WS 详情:本地 bridge 的原始状态摘要
38-- HTTP 详情:control API 的原始状态摘要
39+- HTTP 详情:`conductor.makefile.so` 的原始状态摘要
40 
41 不再展示:
42 
43@@ -105,6 +105,6 @@ HTTP 仍然是管理页里控制状态的同步来源,也是控制按钮的写
44 1. 安装插件并启动 Firefox,确认 `controller.html` 自动打开。
45 2. 打开管理页,确认:
46    - `本地 WS` 最终变成 `已连接`
47-   - `远程 HTTP API` 能显示 `已连接` 或自动重试中的明确状态
48+   - `公网 HTTP` 能显示 `已连接` 或自动重试中的明确状态
49 3. 停掉本地 `conductor-daemon`,确认 WS 状态进入重连中;恢复服务后确认自动回到 `已连接`。
50 4. 点击 `暂停` / `恢复` / `排空`,确认 HTTP 状态会更新 mode,且服务端状态与按钮动作一致。
M scripts/runtime/common.sh
+1, -1
1@@ -7,7 +7,7 @@ fi
2 readonly BAA_RUNTIME_COMMON_SH_LOADED=1
3 readonly BAA_RUNTIME_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
4 readonly BAA_RUNTIME_REPO_DIR_DEFAULT="$(cd -- "${BAA_RUNTIME_SCRIPT_DIR}/../.." && pwd)"
5-readonly BAA_RUNTIME_DEFAULT_CONTROL_API_BASE="https://control-api.makefile.so"
6+readonly BAA_RUNTIME_DEFAULT_CONTROL_API_BASE="https://conductor.makefile.so"
7 readonly BAA_RUNTIME_DEFAULT_LOCAL_API="http://127.0.0.1:4317"
8 readonly BAA_RUNTIME_DEFAULT_STATUS_API="http://127.0.0.1:4318"
9 readonly BAA_RUNTIME_DEFAULT_LOCALE="en_US.UTF-8"
M scripts/runtime/install-mini.sh
+34, -8
 1@@ -16,6 +16,8 @@ Options:
 2   --install-dir PATH        Override launchd install directory.
 3   --shared-token-file PATH  Preferred shared token file. Defaults to ~/.config/baa-conductor/shared-token.txt
 4   --secrets-env PATH        Fallback env file used to extract BAA_SHARED_TOKEN.
 5+                            Defaults to ~/.config/baa-conductor/runtime-secrets.env
 6+                            and then legacy control-api-worker.secrets.env.
 7   --skip-build              Skip pnpm build.
 8   --skip-restart            Skip launchd restart.
 9   --skip-check              Skip check-launchd/check-node verification.
10@@ -40,8 +42,8 @@ require_command npx
11 repo_dir="${BAA_RUNTIME_REPO_DIR_DEFAULT}"
12 home_dir="$(default_home_dir)"
13 install_dir=""
14-shared_token_file="${HOME}/.config/baa-conductor/shared-token.txt"
15-secrets_env="${HOME}/.config/baa-conductor/control-api-worker.secrets.env"
16+shared_token_file=""
17+secrets_env=""
18 skip_build="0"
19 skip_restart="0"
20 skip_check="0"
21@@ -95,21 +97,45 @@ if [[ -z "$install_dir" ]]; then
22   install_dir="$(default_install_dir agent "$home_dir")"
23 fi
24 
25+if [[ -z "$shared_token_file" ]]; then
26+  shared_token_file="${home_dir}/.config/baa-conductor/shared-token.txt"
27+fi
28+
29+if [[ -z "$secrets_env" ]]; then
30+  secrets_env="${home_dir}/.config/baa-conductor/runtime-secrets.env"
31+fi
32+
33+legacy_secrets_env="${home_dir}/.config/baa-conductor/control-api-worker.secrets.env"
34+
35 prepare_shared_token_file() {
36   local target_path="$1"
37   local env_file="$2"
38+  local legacy_env_file="$3"
39   local token=""
40+  local candidate_env_file=""
41 
42   if [[ -f "$target_path" ]]; then
43     token="$(tr -d '\r\n' <"$target_path")"
44   fi
45 
46-  if [[ -z "$token" && -f "$env_file" ]]; then
47-    token="$(awk -F= '/^BAA_SHARED_TOKEN=/{sub(/^[^=]*=/, ""); print; exit}' "$env_file")"
48+  if [[ -z "$token" ]]; then
49+    for candidate_env_file in "$env_file" "$legacy_env_file"; do
50+      if [[ -z "$candidate_env_file" || ! -f "$candidate_env_file" ]]; then
51+        continue
52+      fi
53+
54+      token="$(awk -F= '/^BAA_SHARED_TOKEN=/{sub(/^[^=]*=/, ""); print; exit}' "$candidate_env_file")"
55+      if [[ -n "$token" ]]; then
56+        if [[ "$candidate_env_file" == "$legacy_env_file" ]]; then
57+          runtime_log "using legacy secrets env fallback: ${legacy_env_file}"
58+        fi
59+        break
60+      fi
61+    done
62   fi
63 
64   if [[ -z "$token" ]]; then
65-    die "Could not resolve BAA_SHARED_TOKEN. Provide ${target_path} or ${env_file}."
66+    die "Could not resolve BAA_SHARED_TOKEN. Provide ${target_path}, ${env_file}, or ${legacy_env_file}."
67   fi
68 
69   ensure_directory "$(dirname "$target_path")" "700"
70@@ -117,7 +143,7 @@ prepare_shared_token_file() {
71   chmod 600 "$target_path"
72 }
73 
74-prepare_shared_token_file "$shared_token_file" "$secrets_env"
75+prepare_shared_token_file "$shared_token_file" "$secrets_env" "$legacy_secrets_env"
76 
77 wait_for_http() {
78   local name="$1"
79@@ -154,7 +180,7 @@ run_or_print 0 "${SCRIPT_DIR}/install-launchd.sh" \
80   --service status-api \
81   --install-dir "$install_dir" \
82   --shared-token-file "$shared_token_file" \
83-  --control-api-base "https://control-api.makefile.so" \
84+  --control-api-base "https://conductor.makefile.so" \
85   --local-api-base "http://100.71.210.78:4317" \
86   --local-api-allowed-hosts "100.71.210.78" \
87   --status-api-host "100.71.210.78"
88@@ -177,7 +203,7 @@ if [[ "$skip_check" != "1" ]]; then
89     --service status-api \
90     --install-dir "$install_dir" \
91     --shared-token-file "$shared_token_file" \
92-    --control-api-base "https://control-api.makefile.so" \
93+    --control-api-base "https://conductor.makefile.so" \
94     --local-api-base "http://100.71.210.78:4317" \
95     --local-api-allowed-hosts "100.71.210.78" \
96     --status-api-host "100.71.210.78" \