- commit
- b2bc870
- parent
- 5fceecd
- author
- im_wower
- date
- 2026-03-22 20:43:48 +0800 CST
Merge feat/remove-worker-and-cutover
33 files changed,
+234,
-3611
+10,
-10
1@@ -71,11 +71,11 @@
2 - 负责 heartbeat、租约续约、最小调度循环和 runtime 探针
3 - 是后续统一 discovery / control / task / run 接口的目标承载面
4
5-### `apps/control-api-worker`
6+### legacy `control-api` surface(已移出当前仓库)
7
8-- Cloudflare Worker 入口
9-- 当前仍负责一部分 legacy control-plane HTTP 能力
10-- 只作为 cutover 兼容层保留,完成迁移后进入删旧范围
11+- `control-api.makefile.so`、Cloudflare Worker、D1 仍可能在线上或旧文档中出现
12+- 但它们已经不再是当前仓库中的实现组件
13+- 当前只把它们当作删旧阶段需要盘点的历史兼容面
14
15 ### `apps/status-api`
16
17@@ -83,11 +83,6 @@
18 - 当前仍依赖 `BAA_CONTROL_API_BASE`
19 - 定位是迁移期本地只读观察服务,不是主控制面
20
21-### `apps/worker-runner`
22-
23-- 本地 step 执行器
24-- 负责本地目录、日志、checkpoint 落盘
25-
26 ### `codexd`(设计中,尚未实现)
27
28 - `mini` 本地常驻的 Codex 代理/执行组件
29@@ -116,6 +111,11 @@
30 - 所以 `codexd` 现在仍是设计项,不是已落地组件
31 - 后续实现时,不再把 `exec` 当作主双工方案;`exec` 只用于简单调用、测试和兜底
32
33+### `apps/worker-runner`
34+
35+- 本地 step 执行器
36+- 负责本地目录、日志、checkpoint 落盘
37+
38 ## 4. 部署约束
39
40 - 只保留 `mini` 单节点
41@@ -207,7 +207,7 @@
42
43 - `status-api` 仍通过 `BAA_CONTROL_API_BASE` 读取 legacy truth source
44 - `conductor.makefile.so` 目前只回源 `conductor-daemon` 已有路由,尚未承接完整业务 API
45-- 仓库中仍保留 `apps/control-api-worker`、Cloudflare / D1 相关脚手架
46+- 线上和历史文档里仍可能残留 `control-api.makefile.so`、Cloudflare / D1 相关资产或表述
47 - `codexd` 尚未实现;当前仍没有“Codex 常驻代理 / Codex daemon”能力
48
49 ## 12. 历史回溯
+7,
-5
1@@ -46,7 +46,6 @@
2
3 ```text
4 apps/
5- control-api-worker/
6 conductor-daemon/
7 status-api/
8 worker-runner/
9@@ -87,7 +86,6 @@ docs/
10 - 所有新接口设计默认先落 `mini` 本地 `4317`
11 - 所有新公网说明统一写 `conductor.makefile.so`
12 - `status-api` 只作为本地只读观察面,不再作为默认对外业务接口
13-- `apps/control-api-worker` 只承担迁移期兼容职责,不再扩成主路径
14 - 运行中的浏览器插件代码以 [`plugins/baa-firefox`](./plugins/baa-firefox) 为准
15 - `codexd` 目前只是设计项,还不是已实现组件
16 - `codexd` 后续默认以 `app-server` 为主,不以 TUI 或 `exec` 作为主双工接口
17@@ -96,7 +94,7 @@ docs/
18
19 | 面 | 地址 | 定位 | 说明 |
20 | --- | --- | --- | --- |
21-| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs` 和 `pause/resume/drain` |
22+| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write` |
23 | public host | `https://conductor.makefile.so` | 唯一公网域名 | 由 VPS Nginx 回源到 `100.71.210.78:4317` |
24 | local status view | `http://100.71.210.78:4318` | 本地只读观察面 | 迁移期保留,不是主控制面 |
25
26@@ -104,7 +102,7 @@ legacy 兼容说明:
27
28 - `https://control-api.makefile.so` 只用于迁移期间兜底和识别残留依赖
29 - 业务查询和系统控制写入已经不再依赖 Cloudflare Worker / D1 真相源
30-- `apps/control-api-worker` 在 cutover 完成后进入删旧范围
31+- `apps/control-api-worker` 已从当前仓库移除;剩余 legacy 只保留在线上资产和历史文档背景里
32
33 ## 迁移顺序
34
35@@ -132,4 +130,8 @@ legacy 兼容说明:
36 - `files/read`
37 - `files/write`
38
39-这个包只负责本地 Node 能力、输入输出合同和结构化错误;当前还没有接到 `conductor-daemon` 或公开 HTTP 路由,后续 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 会基于它继续接。
40+这个包负责本地 Node 能力、输入输出合同和结构化错误;当前已经通过 `conductor-daemon` 暴露到:
41+
42+- `/v1/exec`
43+- `/v1/files/read`
44+- `/v1/files/write`
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
+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();
+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-}
+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));
+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-}
+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-}
+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-}
+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-}
+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;
+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-}
+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-}
+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>;
+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-}
+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-}
+121,
-0
1@@ -0,0 +1,121 @@
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+- `docs/runtime/codexd.md`
95+
96+## commands_run
97+
98+- `git -C /Users/george/code/baa-conductor worktree add -b feat/remove-worker-and-cutover /Users/george/code/worktrees/baa-conductor-t-c004 main`
99+- `npx --yes pnpm -r build`
100+- `npx --yes pnpm install --frozen-lockfile`
101+- `ln -s /Users/george/code/baa-conductor/node_modules /Users/george/code/worktrees/baa-conductor-t-c004/node_modules`
102+- `npx --yes pnpm -r build`
103+- `git -C /Users/george/code/worktrees/baa-conductor-t-c004 diff --check`
104+
105+## result
106+
107+- 删除了 `apps/control-api-worker` 整个应用目录,主线 workspace 不再包含 Cloudflare Worker 兼容实现
108+- runtime 脚本默认把兼容变量 `BAA_CONTROL_API_BASE` 收口到 `https://conductor.makefile.so`
109+- `install-mini.sh` 默认改读 `runtime-secrets.env`,仅在需要时回退到 legacy `control-api-worker.secrets.env`
110+- Firefox 插件文案、运行文档和 API/运维文档已统一为“本地 WS + conductor.makefile.so”,并明确 `status-api` 只保留本地只读角色
111+- 保留并恢复了 `codexd` 设计文档与入口,不再把这次删旧任务扩散成对 `codexd` 规划的回退
112+- `pnpm -r build` 与 `git diff --check` 已通过
113+
114+## risks
115+
116+- 仓库 write scope 之外仍有 legacy 残留,例如根 `README.md`、`ops/cloudflare/**`、`tests/control-api/**` 和部分 `apps/status-api` / `apps/conductor-daemon` 内部命名,当前任务未触碰
117+- 本次构建验证依赖主仓库现有 `node_modules`;原因是当前 `pnpm-lock.yaml` 本身已落后于仓库 package 定义,无法在 clean worktree 里直接 `pnpm install --frozen-lockfile`
118+
119+## next_handoff
120+
121+- 如果后续继续做全量删旧,优先处理 write scope 外的 `ops/cloudflare/**`、`tests/control-api/**` 和根文档残留
122+- 如需让 clean worktree 可直接安装依赖,应先单独修正现有 `pnpm-lock.yaml` 与 workspace package 定义不一致的问题
+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
+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
+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
+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
+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
+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 状态` 原始详情面板
+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
+1,
-1
1@@ -17,7 +17,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
+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
+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
+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` 没有新内容
+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
+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>
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,且服务端状态与按钮动作一致。
+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"
+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" \