- commit
- bbded8a
- parent
- e4e3f32
- author
- im_wower
- date
- 2026-03-22 12:09:42 +0800 CST
Remove HA and historical artifacts
86 files changed,
+333,
-10951
+99,
-2681
1@@ -1,2738 +1,156 @@
2 # BAA Conductor 实施设计说明
3
4-## 0. 文档状态
5+## 1. 当前目标
6
7-本文档是新 conductor 系统的实施基线。
8+`baa-conductor` 现在只维护一条最小可运行路径:
9
10-## 0.1 当前有效部署决策
11+- `mini` 是唯一长期运行节点
12+- `mini` 负责 conductor、status-api、自启动与本地日志
13+- `control-api.makefile.so` 负责 Cloudflare Worker + D1 控制面
14+- `conductor.makefile.so` 作为公网入口,经 VPS Nginx 回源到 `mini`
15+- 浏览器控制面来自 [`plugins/baa-firefox`](./plugins/baa-firefox)
16
17-从 `2026-03-22` 起,当前有效目标已经简化为:
18+历史的主备切换、failover、switchback、多节点公网入口已经从当前主线删除。需要回看旧设计时,使用 tag `ha-failover-archive-2026-03-22`。
19
20-- 只保留 `mini` 作为唯一中控
21-- 只保留 `mini` 的自启动、控制面、运行态检查与公网入口
22-- 不再继续推进 `mac` 备主、planned failover、emergency failover、switchback
23+## 2. 当前拓扑
24
25-下面保留的主备/failover 内容,视为历史设计记录,不再是当前执行目标。
26+### 控制面
27
28-它的细节深度是按“多个 Codex worker 读完就能并行开工”来写的。
29+- `control-api.makefile.so`
30+- Cloudflare Worker
31+- D1 作为控制平面数据库
32
33-本文档定义了:
34+控制面承担:
35
36-- 系统目标
37-- 拓扑结构
38-- 域名与转发布局
39-- 历史上的 `mini` / `mac` 主备切换行为
40-- Cloudflare D1 控制平面设计
41-- task 与 step 模型
42-- `planner`、`conductor`、`worker` 的职责边界
43-- Codex 执行约定
44-- 日志、checkpoint 与恢复机制
45-- Firefox 插件集成方式
46-- 建议的仓库结构
47-- 建议的并行开发拆分
48+- 系统模式读写
49+- controller heartbeat
50+- 任务、step、run 的元数据存储
51+- 浏览器面板和 CLI 的统一接口
52
53-如果代码与本文档不一致,要么更新代码以符合本文档,要么明确修订本文档。
54+### 执行面
55
56-## 1. 总体目标
57+- `mini`
58+- Tailscale IP: `100.71.210.78`
59+- 本地 conductor: `http://100.71.210.78:4317`
60+- 本地 status-api: `http://100.71.210.78:4318`
61
62-构建一个稳定的 AI 执行系统,具备以下特征:
63+### 公网入口
64
65-- `mini` 是唯一长期运行的 conductor。
66-- `mini` 承担自启动、控制面、本地状态面和浏览器控制集成。
67-- 如需额外开发机或临时 worker,视为辅助资源,不再纳入主备设计目标。
68-- 人类只使用一个可见的 Claude `control` 对话。
69-- 自动化使用一个隐藏的 Claude `dispatch` 通道。
70-- task 真相存储在共享数据库中。
71-- 进度恢复依赖 checkpoint,而不是试图复活已死亡的进程。
72+- `https://conductor.makefile.so`
73+- VPS Nginx 反向代理到 `100.71.210.78:4317`
74
75-整个系统围绕一个简单原则展开:
76+### 浏览器控制面
77
78-**恢复 task 进度,而不是恢复 Codex 进程内存。**
79+- Firefox 插件代码位于 [`plugins/baa-firefox`](./plugins/baa-firefox)
80+- 浏览器通过 `control-api` 读取系统状态
81+- 浏览器通过 `pause / resume / drain` 接口控制自动化
82
83-## 2. 现有基础组件
84+## 3. 组件职责
85
86-当前环境已经有三个关键基础组件。
87+### `apps/control-api-worker`
88
89-### 2.1 `baa-hand`
90+- Cloudflare Worker 入口
91+- 负责 D1 读写
92+- 对浏览器、CLI 和 conductor 暴露统一 HTTP API
93
94-当前职责:
95+### `apps/conductor-daemon`
96
97-- AI 路由
98-- `POST /ask`
99-- `POST /chat`
100-- `POST /plan`
101+- `mini` 本地常驻进程
102+- 负责 heartbeat、租约续约、最小调度循环和只读探针
103+- 对外暴露:
104+ - `/healthz`
105+ - `/readyz`
106+ - `/rolez`
107+ - `/v1/runtime`
108
109-当前优点:
110+### `apps/status-api`
111
112-- 能调 Claude
113-- 能调 Codex
114-- Codex 和 Claude 网页版都能用它
115+- `mini` 本地状态读取面
116+- 面向浏览器或人类查看
117+- 默认监听本地或显式指定的 `100.x`
118
119-当前限制:
120+### `apps/worker-runner`
121
122-- 现在的 Codex 集成是面向进程的,不是面向 task 的
123-- 它会等到进程退出后再返回
124-- 它还不是一个可持久恢复的 step worker runtime
125+- 本地 step 执行器
126+- 负责本地目录、日志、checkpoint 落盘
127
128-结论:
129+## 4. 部署约束
130
131-- 继续用 `baa-hand` 走 planner 和 review 路径
132-- 不要把当前 `baa-hand -> codex exec -> 等进程退出` 这条路径当成最终的 durable worker 模型
133+- 只保留 `mini` 单节点
134+- 不依赖 MagicDNS
135+- 内网地址直接写 Tailscale `100.x`
136+- 外网只暴露 `control-api.makefile.so` 与 `conductor.makefile.so`
137+- 当前不要求 Cloudflare proxy;DNS-only 也可接受
138
139-### 2.2 `baa-shell`
140+## 5. Runtime 目录
141
142-当前职责:
143+默认仓库路径建议:
144
145-- shell 命令执行
146-- 文件读写
147-- task 报告
148+- `/Users/george/code/baa-conductor`
149
150-结论:
151+默认 runtime 目录:
152
153-- 它仍然适合远程操作流和外部自动化
154-- conductor 在 HTTP 执行比直连 SSH 或本地 exec 更方便时,可以调用它
155+- `state/`
156+- `runs/`
157+- `worktrees/`
158+- `logs/`
159+- `tmp/`
160
161-### 2.3 Firefox 插件
162+这些目录由 [`scripts/runtime/bootstrap.sh`](./scripts/runtime/bootstrap.sh) 初始化。
163
164-当前职责:
165+## 6. 自启动
166
167-- 拦截 Claude 网页流量
168-- 已有常驻 controller 概念
169-- 可以承载可见的自动化状态与控制按钮
170-
171-结论:
172-
173-- 用它接可见的 `control` 会话
174-- 增加全局 `pause`、`resume`,可选 `drain`
175-- 不要让浏览器状态成为真相来源
176-- 浏览器按钮必须写入 conductor 控制平面
177-
178-## 3. 设计原则
179-
180-### 3.1 Durable 真相必须在 AI 进程之外
181-
182-真相不能放在:
183-
184-- 浏览器标签页
185-- Codex 进程
186-- 临时 Claude 会话
187-- Node.js 进程内存中的 map
188-
189-真相必须放在:
190-
191-- Cloudflare D1
192-- 本地 run 目录
193-
194-### 3.2 Codex 是 Worker,不是 Conductor
195-
196-Codex 可以:
197-
198-- 在被要求时参与规划任务
199-- 执行某个 step
200-- 总结结果
201-
202-Codex 不可以:
203-
204-- 持有 lease
205-- 持有队列真相
206-- 持有调度真相
207-- 直接改全局状态
208-
209-### 3.3 Planner 是抽象角色
210-
211-`planner` 是角色,不是固定实现方。
212-
213-planner 可以是:
214-
215-- 确定性的模板
216-- Claude dispatch
217-- Codex
218-- 未来单独的 planner 模块
219-
220-是否使用 planner、是否接受 planner 输出,由 conductor 决定。
221-
222-### 3.4 Conductor 必须是确定性基础设施
223-
224-conductor 是 daemon,不是聊天会话。
225-
226-conductor 必须负责:
227-
228-- task 创建归一化
229-- step 拆分结果验收
230-- lease 逻辑
231-- worker 分配
232-- heartbeat 检查
233-- timeout 处理
234-- checkpoint 写入
235-- 恢复
236-
237-### 3.5 Step 边界就是恢复边界
238-
239-每个 task 都要拆成多个 step。
240-
241-每个 step 都必须:
242-
243-- 有边界
244-- 可观察
245-- 可 checkpoint
246-- 可重试或可终止
247-
248-## 4. 系统角色
249-
250-## 4.1 Human
251-
252-人只需要做这些事:
253-
254-- 使用一个可见的 Claude `control` 对话
255-- 查看当前状态
256-- 暂停、恢复或 drain 自动化
257-- 做高层决策
258-
259-人不应该需要盯着 Codex 终端界面。
260-
261-## 4.2 `control`
262-
263-这是人类交互使用的可见 Claude 对话。
264-
265-职责:
266-
267-- 讨论策略
268-- 创建 task
269-- 查看状态
270-- 请求暂停或恢复
271-- 审查进展
272-
273-规则:
274-
275-- `control` 是交互界面
276-- `control` 不是任务队列
277-- `control` 不是 durable 状态存储
278-
279-## 4.3 `dispatch`
280-
281-这是隐藏的 Claude 自动化通道。
282-
283-职责:
284-
285-- 提供 planning 支持
286-- 提供 review 支持
287-- 输出结构化结果
288-
289-规则:
290-
291-- `dispatch` 不面向用户
292-- `dispatch` 不能做 durable 真相来源
293-- `dispatch` 只能给 proposal,不能直接做最终状态迁移
294-
295-## 4.4 `planner`
296-
297-职责:
298-
299-- 把目标转换成一个建议的 step plan
300-
301-输入:
302-
303-- task 目标
304-- repo
305-- 约束
306-- 验收条件
307-- 可选的当前 repo 状态
308-
309-输出:
310-
311-- 结构化 plan JSON
312-- 拆分理由
313-- 风险标记
314-
315-规则:
316-
317-- planner 输出只是建议
318-- conductor 在持久化之前必须校验 planner 输出
319-
320-## 4.5 `conductor`
321-
322-职责:
323-
324-- leader lease 管理
325-- task 归一化
326-- plan 验收
327-- step 调度
328-- worker 监管
329-- timeout 执行
330-- 日志索引
331-- checkpoint
332-- 故障切换与恢复
333-
334-## 4.6 `worker`
335-
336-职责:
337-
338-- 执行且只执行一个 step
339-- 输出本地日志
340-- 返回结果
341-- 退出
342-
343-默认 AI worker 是 Codex。
344-
345-Shell 和 Git 类 step 可以由 shell runner 执行,而不是由 Codex 进程执行。
346-
347-## 5. 拓扑结构
348-
349-## 5.1 节点
350-
351-### `mini`
352-
353-角色:
354-
355-- 主 conductor
356-- 本地 worker 宿主机
357-
358-运行:
359-
360-- conductor daemon
361-- worker runner
362-- 可选本地 status API
363-- 可选 Firefox 自动化栈
364-
365-### `mac`
366-
367-角色:
368-
369-- 备用 conductor
370-- 本地 worker 宿主机
371-
372-运行:
373-
374-- standby conductor daemon
375-- worker runner
376-- 可选本地 status API
377-
378-### `vps`
379-
380-角色:
381-
382-- 入口与反向代理
383-
384-运行:
385-
386-- Nginx
387-- TLS 终止,或 Cloudflare origin 代理支持
388-
389-VPS 不持有 leader 真相。
390-
391-### `cloudflare`
392-
393-角色:
394-
395-- D1 数据库
396-- 可选 Worker 形式的 control API
397-- DNS 与自定义域名
398-
399-## 5.2 数据路径
400-
401-### 控制平面
402-
403-- human -> 可见 Claude `control`
404-- `control` -> conductor control API
405-- conductors -> D1
406-- workers -> conductors
407-
408-### 执行平面
409-
410-- conductor -> 本地 worker 进程
411-- conductor -> 本地 shell 与 git 命令
412-- conductor -> `baa-hand` 做 planning 或 review
413-- conductor -> `baa-shell` 在 HTTP 远程执行更方便时使用
414-
415-### 浏览器平面
416-
417-- Firefox 插件 -> control API 做 pause 与 resume
418-- Firefox 插件 -> 可见状态 badge
419-- Firefox 插件 -> 如有需要,访问隐藏 `dispatch`
420-
421-## 6. 域名、二级域名与内网地址布局
422-
423-本节定义推荐的双通道布局:
424-
425-- 公网流量走二级域名,经 VPS Nginx 转发
426-- 内网与节点间流量直接走 Tailscale `100.x` 地址
427-
428-这里明确不依赖 MagicDNS 名称。
429-
430-原因:
431-
432-- 当前环境里 ClashX 会和 MagicDNS 产生 DNS 接管冲突
433-- 即使 tailnet 已开启 MagicDNS,也不把 `*.ts.net` 作为生产配置依赖
434-- 内网配置统一写死到 Tailscale IPv4 地址,避免本机 DNS 状态影响运行
435-
436-## 6.1 必需的公网域名
437-
438-### `conductor.makefile.so`
439-
440-用途:
441-
442-- conductor API 的统一公网入口
443-- 指向 VPS Nginx
444-- Nginx 再转发到 `mini` 主、`mac` 备
445-
446-使用方:
447-
448-- 人类工具
449-- Firefox 插件控制动作
450-- 诊断请求
451-
452-### `mini-conductor.makefile.so`
453-
454-用途:
455-
456-- 经 VPS 直达 mini,便于调试和维护
457-
458-### `mac-conductor.makefile.so`
459-
460-用途:
461-
462-- 经 VPS 直达 mac,便于调试和维护
463-
464-### `control-api.makefile.so`
465-
466-用途:
467-
468-- 绑定到 D1 的 Cloudflare Worker 自定义域
469-- durable 控制状态的规范写入口
470-
471-名字可以不同,但设计上要求有一个稳定的、前置 D1 的 HTTP API 域名。
472-
473-## 6.2 必需的内网地址
474-
475-当前推荐直接使用这些 Tailscale 地址:
476-
477-- `mini` -> `100.71.210.78`
478-- `mac` -> `100.112.239.13`
479-- `racknerd-ff37952` -> `100.68.201.85`
480-
481-用途:
482-
483-- `mini <-> mac` 的节点间控制流量
484-- conductor 到 peer conductor 的探活、状态读取、受保护节点 API
485-- worker、status、运维脚本等内部调用
486-- VPS Nginx 到 mini/mac 的 upstream 回源
487-
488-规则:
489-
490-- 生产配置中不写 `mini.tail0125d.ts.net`
491-- 生产配置中不写 `mbp.tail0125d.ts.net`
492-- 不要求业务机器开启 `accept-dns`
493-- 只要求它们加入同一个 tailnet 且 `100.x` 地址可达
494-
495-如果未来 Tailscale 地址变化:
496-
497-- 先更新中央配置
498-- 再更新 VPS Nginx upstream
499-- 最后重载相关进程
500-
501-## 6.3 现有域名
502-
503-这些域名已经存在,继续沿用:
504-
505-- `led.makefile.so` -> `baa-hand`
506-- `s.makefile.so` -> `baa-shell`
507-
508-## 6.4 推荐 DNS 策略
509-
510-- `conductor.makefile.so` -> VPS 公网 IP
511-- `mini-conductor.makefile.so` -> VPS 公网 IP
512-- `mac-conductor.makefile.so` -> VPS 公网 IP
513-- `control-api.makefile.so` -> Cloudflare Worker 自定义域
514-
515-之后由 VPS 把节点专属域名再转发到 Tailscale `100.x` 地址。
516-
517-额外说明:
518-
519-- 公网 DNS 不承载 tailnet 内部寻址
520-- tailnet 内部寻址直接用 `100.x`
521-- 不使用 MagicDNS 名称做回源或服务发现
522-
523-## 6.5 建议的 DNS 记录
524-
525-推荐 DNS 记录如下:
526-
527-| Hostname | Type | Target | Purpose |
528-| --- | --- | --- | --- |
529-| `conductor.makefile.so` | `A` 或 `AAAA` | VPS 公网 IP | conductor 统一公网入口 |
530-| `mini-conductor.makefile.so` | `A` 或 `AAAA` | VPS 公网 IP | 经 VPS 直达 mini |
531-| `mac-conductor.makefile.so` | `A` 或 `AAAA` | VPS 公网 IP | 经 VPS 直达 mac |
532-| `control-api.makefile.so` | Worker 自定义域 | Cloudflare Worker | durable 控制平面 API |
533-| `led.makefile.so` | 现有 | 现有 origin | `baa-hand` |
534-| `s.makefile.so` | 现有 | 现有 origin | `baa-shell` |
535-
536-说明:
537-
538-- 如果 VPS 走 Cloudflare 代理,所有相关 host 的代理模式要保持一致
539-- `control-api.makefile.so` 最好留在 Cloudflare 内部,让 D1 访问保持本地化
540-- mini 和 mac 不应该直接暴露在公网,节点专属域名仍然应该经过 VPS
541-- tailnet 内部机器间调用不要再绕回这些公网域名
542-
543-## 6.6 TLS 策略
544-
545-推荐 TLS 模式:
546-
547-- 公网访问的域名尽量通过 Cloudflare 代理
548-- VPS 上给 `conductor.makefile.so`、`mini-conductor.makefile.so`、`mac-conductor.makefile.so` 申请 Let’s Encrypt 证书
549-- Worker 自定义域使用 Cloudflare 托管证书
550-
551-如果 conductor 域名启用了 Cloudflare 橙云:
552-
553-- VPS 上配置 origin certificate 或普通 Let’s Encrypt 证书
554-- 尽量限制对 origin 的直接访问
555-
556-如果 conductor 域名不走 Cloudflare 代理:
557-
558-- 保持 VPS 上 Let’s Encrypt 自动续期
559-- 对直连节点域名加鉴权保护
560-
561-## 7. VPS 与 Nginx 转发
562-
563-## 7.1 为什么需要 Nginx
564-
565-VPS 上的 Nginx 提供:
566-
567-- 稳定的公网入口
568-- TCP 与 HTTP 层的机器切换
569-- 到 mini 和 mac 的直连调试路由
570-
571-它不负责决定真实 leader。
572-
573-真实 leader 由 D1 lease 决定。
574-
575-它也不承载大部分内网控制流量。
576-
577-正确分工:
578-
579-- 浏览器、Claude 网页、管理页面 UI 走公网二级域名
580-- mini/mac 之间的内部控制流量直接走 Tailscale `100.x`
581-- VPS 只作为公网入口,不作为内网总线
582-
583-## 7.2 Nginx Upstream 设计
584-
585-推荐 upstream:
586-
587-```nginx
588-upstream conductor_primary {
589- server 100.71.210.78:4317 max_fails=2 fail_timeout=5s;
590- server 100.112.239.13:4317 backup;
591- keepalive 32;
592-}
593-
594-upstream mini_conductor_direct {
595- server 100.71.210.78:4317;
596- keepalive 16;
597-}
598-
599-upstream mac_conductor_direct {
600- server 100.112.239.13:4317;
601- keepalive 16;
602-}
603-```
604-
605-本例中:
606-
607-- `100.71.210.78` 是 `mini`
608-- `100.112.239.13` 是 `mac`
609-- `4317` 是 conductor 本地 HTTP 端口
610-- upstream 使用 Tailscale IPv4 地址,不使用 `*.ts.net` 名称
611-- 这样可以绕开 ClashX 与 MagicDNS 的 DNS 接管冲突
612-
613-## 7.3 Nginx Server Block
614-
615-### 统一 conductor 入口
616-
617-```nginx
618-server {
619- listen 443 ssl http2;
620- server_name conductor.makefile.so;
621-
622- ssl_certificate /etc/letsencrypt/live/conductor.makefile.so/fullchain.pem;
623- ssl_certificate_key /etc/letsencrypt/live/conductor.makefile.so/privkey.pem;
624-
625- location / {
626- proxy_pass http://conductor_primary;
627- proxy_http_version 1.1;
628- proxy_set_header Host $host;
629- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
630- proxy_set_header X-Forwarded-Proto $scheme;
631- proxy_set_header X-Request-Id $request_id;
632- proxy_connect_timeout 3s;
633- proxy_read_timeout 60s;
634- proxy_send_timeout 60s;
635- }
636-}
637-```
638-
639-### 直达 mini 的路由
640-
641-```nginx
642-server {
643- listen 443 ssl http2;
644- server_name mini-conductor.makefile.so;
645-
646- ssl_certificate /etc/letsencrypt/live/mini-conductor.makefile.so/fullchain.pem;
647- ssl_certificate_key /etc/letsencrypt/live/mini-conductor.makefile.so/privkey.pem;
648-
649- location / {
650- proxy_pass http://mini_conductor_direct;
651- proxy_http_version 1.1;
652- proxy_set_header Host $host;
653- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
654- proxy_set_header X-Forwarded-Proto $scheme;
655- proxy_set_header X-Request-Id $request_id;
656- }
657-}
658-```
659-
660-### 直达 mac 的路由
661-
662-```nginx
663-server {
664- listen 443 ssl http2;
665- server_name mac-conductor.makefile.so;
666-
667- ssl_certificate /etc/letsencrypt/live/mac-conductor.makefile.so/fullchain.pem;
668- ssl_certificate_key /etc/letsencrypt/live/mac-conductor.makefile.so/privkey.pem;
669-
670- location / {
671- proxy_pass http://mac_conductor_direct;
672- proxy_http_version 1.1;
673- proxy_set_header Host $host;
674- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
675- proxy_set_header X-Forwarded-Proto $scheme;
676- proxy_set_header X-Request-Id $request_id;
677- }
678-}
679-```
680-
681-## 7.4 健康检查与就绪检查
682-
683-每个 conductor 都应暴露:
684-
685-- `GET /healthz`
686-- `GET /readyz`
687-- `GET /rolez`
688-
689-推荐语义:
690-
691-- `/healthz`: 进程活着
692-- `/readyz`: 节点已准备好提供 conductor 服务
693-- `/rolez`: 返回 `leader` 或 `standby`
694-
695-重要说明:
696-
697-- standby 可以健康,但不是 leader
698-- 只要节点不是 leader,写 API 仍然必须拒绝调度类操作
699-
700-## 7.5 建议的端口暴露方式
701-
702-VPS 只应对公网暴露:
703-
704-- `80/tcp`
705-- `443/tcp`
706-
707-mini 和 mac 不应该直接对公网开放 conductor 端口。
708-
709-推荐访问模型:
710-
711-- 浏览器、人类运维、Claude 网页、管理 UI -> 公网二级域名 -> VPS -> Tailscale upstream
712-- mini 和 mac 的 daemon / worker / 脚本 -> 直接访问 Tailscale `100.x`
713-- 只有需要公网暴露的入口才经过 VPS
714-- 运维人员要访问 mini/mac 时,可通过 SSH、Tailscale `100.x`,或受保护的直连节点域名
715-
716-内网建议:
717-
718-- `mini` 本机调用本机 conductor:`http://127.0.0.1:4317`
719-- `mini` 调 `mac`:`http://100.112.239.13:4317`
720-- `mac` 调 `mini`:`http://100.71.210.78:4317`
721-- VPS Nginx upstream 也直接写这两个 `100.x`
722-
723-## 7.6 完整的 Nginx 模式
724-
725-推荐整体模式:
726-
727-1. `:80` 全部跳转到 HTTPS
728-2. `conductor.makefile.so` 走主加备 upstream
729-3. `mini-conductor.makefile.so` 直转 mini upstream
730-4. `mac-conductor.makefile.so` 直转 mac upstream
731-5. 直连节点域名额外加鉴权
732-
733-### HTTP 跳转
734-
735-```nginx
736-server {
737- listen 80;
738- listen [::]:80;
739- server_name conductor.makefile.so mini-conductor.makefile.so mac-conductor.makefile.so;
740- return 301 https://$host$request_uri;
741-}
742-```
743-
744-### 直连节点域名的 Basic Auth
745-
746-对 `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so`,增加:
747-
748-```nginx
749-auth_basic "Restricted";
750-auth_basic_user_file /etc/nginx/.htpasswd-baa-conductor;
751-```
752-
753-除非你希望整个系统都放在认证后面,否则不要把这段加到统一入口域名上。
754-
755-## 7.7 Nginx 加固建议
756-
757-建议在每个 TLS server block 中增加:
758-
759-```nginx
760-proxy_set_header X-Real-IP $remote_addr;
761-proxy_set_header X-Forwarded-Host $host;
762-proxy_set_header X-Forwarded-Port $server_port;
763-proxy_buffering off;
764-client_max_body_size 10m;
765-```
766-
767-如果你需要日志串联请求:
768-
769-```nginx
770-add_header X-Request-Id $request_id always;
771-```
772-
773-如果后续要走状态流或长连接:
774-
775-```nginx
776-proxy_read_timeout 3600s;
777-proxy_send_timeout 3600s;
778-```
779-
780-## 7.8 Nginx Failover 的边界
781-
782-Nginx failover 只解决:
783-
784-- 网络路径故障
785-- TCP 连接失败
786-- 简单 upstream 不可达
787-
788-Nginx failover 不能解决:
789-
790-- split-brain
791-- 过期 leader 继续写
792-- task lease 冲突
793-- 安全恢复逻辑
794-
795-因此所有写接口仍然必须校验 D1 lease 状态。
796-
797-## 7.9 建议的 Nginx 文件布局
798-
799-建议在 VPS 上使用:
800-
801-```text
802-/etc/nginx/nginx.conf
803-/etc/nginx/sites-available/baa-conductor.conf
804-/etc/nginx/sites-enabled/baa-conductor.conf
805-/etc/nginx/.htpasswd-baa-conductor
806-```
807-
808-未来仓库中的文件应尽量镜像这个结构:
809-
810-```text
811-ops/nginx/baa-conductor.conf
812-ops/nginx/includes/common-proxy.conf
813-ops/nginx/includes/direct-node-auth.conf
814-```
815-
816-## 8. 主备与故障切换模型
817-
818-## 8.1 真相来源
819-
820-leader 真相在 D1,不在 Nginx。
821-
822-Nginx 只负责网络可达性和入口层 failover。
823-
824-## 8.2 推荐行为
825-
826-- `mini` 是首选 leader
827-- `mac` 是 standby
828-- 任意时刻只能有一个有效 leader lease
829-
830-推荐 lease 参数:
831-
832-- TTL: 30 秒
833-- 续租周期: 每 5 秒一次
834-- 连续 2 次续租失败后自我降级
835-
836-## 8.3 切换规则
837-
838-### 正常状态
839-
840-- mini 持有 lease
841-- mac 只监听,不调度
842-
843-### Mini 故障
844-
845-- mini heartbeat 停止
846-- lease 过期
847-- mac 抢到 lease
848-- mac 成为 active scheduler
849-
850-### Mini 恢复
851-
852-- mini 恢复上线
853-- mini 注册为 standby
854-- mini 不自动抢占
855-
856-### 手动切回
857-
858-管理员可以显式执行:
859-
860-- drain mac
861-- promote mini
862-- demote mac
863-
864-这样可以避免主备来回抖动。
865-
866-## 8.4 防止 Split-Brain
867-
868-规则:
869-
870-- 所有写动作都要求有效 lease
871-- claim task 与 claim step 前都要检查 lease
872-- 每条写路径都要校验当前 leader term
873-- standby 节点必须拒绝调度类写入
874-
875-## 9. Cloudflare D1 控制平面
876-
877-## 9.1 为什么使用 D1
878-
879-使用 D1 作为共享 durable 协调存储,是因为它适合承载:
880-
881-- mini 与 mac 之间的共享真相
882-- SQLite 事务语义
883-- 队列、lease、元数据与索引
884-
885-D1 不适合存储:
886-
887-- 长期保存的完整原始日志
888-- 大型二进制产物
889-- 高频逐 token 流式数据
890-
891-## 9.2 访问模式
892-
893-推荐模式:
894-
895-- 机器本身不要直接写 D1
896-- 全部通过统一的 HTTP control API
897-- control API 跑在 Cloudflare Worker 上,并绑定 D1
898-
899-这样可以集中管理:
900-
901-- 鉴权
902-- schema 访问
903-- 事务约束
904-- 版本演进
905-
906-## 9.3 读一致性
907-
908-控制路径上的读尽量使用主一致读。
909-
910-规则:
911-
912-- lease 获取与 task claim 必须保证主一致逻辑
913-- 状态查看类页面可以容忍轻微陈旧
914-
915-## 10. 数据库 Schema
916-
917-本节定义最小 D1 schema。
918-
919-## 10.1 `leader_lease`
920-
921-用途:
922-
923-- 全局唯一 active leadership lease
924-
925-```sql
926-CREATE TABLE IF NOT EXISTS leader_lease (
927- lease_name TEXT PRIMARY KEY,
928- holder_id TEXT NOT NULL,
929- holder_host TEXT NOT NULL,
930- term INTEGER NOT NULL,
931- lease_expires_at INTEGER NOT NULL,
932- renewed_at INTEGER NOT NULL,
933- preferred_holder_id TEXT,
934- metadata_json TEXT
935-);
936-```
937-
938-约定:
939-
940-- 只保留一行,`lease_name = 'global'`
941-
942-## 10.2 `controllers`
943-
944-用途:
945-
946-- 注册 conductors 及其 heartbeat
947-
948-```sql
949-CREATE TABLE IF NOT EXISTS controllers (
950- controller_id TEXT PRIMARY KEY,
951- host TEXT NOT NULL,
952- role TEXT NOT NULL,
953- priority INTEGER NOT NULL,
954- status TEXT NOT NULL,
955- version TEXT,
956- last_heartbeat_at INTEGER NOT NULL,
957- last_started_at INTEGER,
958- metadata_json TEXT
959-);
960-```
961-
962-## 10.3 `workers`
963-
964-用途:
965-
966-- 注册 worker slot
967-
968-```sql
969-CREATE TABLE IF NOT EXISTS workers (
970- worker_id TEXT PRIMARY KEY,
971- controller_id TEXT NOT NULL,
972- host TEXT NOT NULL,
973- worker_type TEXT NOT NULL,
974- status TEXT NOT NULL,
975- max_parallelism INTEGER NOT NULL DEFAULT 1,
976- current_load INTEGER NOT NULL DEFAULT 0,
977- last_heartbeat_at INTEGER NOT NULL,
978- capabilities_json TEXT,
979- metadata_json TEXT
980-);
981-```
982-
983-## 10.4 `tasks`
984-
985-用途:
986-
987-- durable task 队列
988-
989-```sql
990-CREATE TABLE IF NOT EXISTS tasks (
991- task_id TEXT PRIMARY KEY,
992- repo TEXT NOT NULL,
993- task_type TEXT NOT NULL,
994- title TEXT NOT NULL,
995- goal TEXT NOT NULL,
996- source TEXT NOT NULL,
997- priority INTEGER NOT NULL DEFAULT 100,
998- status TEXT NOT NULL,
999- planning_strategy TEXT,
1000- planner_provider TEXT,
1001- branch_name TEXT,
1002- base_ref TEXT,
1003- target_host TEXT,
1004- assigned_controller_id TEXT,
1005- current_step_index INTEGER NOT NULL DEFAULT -1,
1006- constraints_json TEXT,
1007- acceptance_json TEXT,
1008- metadata_json TEXT,
1009- result_summary TEXT,
1010- result_json TEXT,
1011- error_text TEXT,
1012- created_at INTEGER NOT NULL,
1013- updated_at INTEGER NOT NULL,
1014- started_at INTEGER,
1015- finished_at INTEGER
1016-);
1017-```
1018-
1019-索引:
1020-
1021-```sql
1022-CREATE INDEX IF NOT EXISTS idx_tasks_status_priority
1023-ON tasks(status, priority, created_at);
1024-
1025-CREATE INDEX IF NOT EXISTS idx_tasks_repo
1026-ON tasks(repo, created_at);
1027-```
1028-
1029-## 10.5 `task_steps`
1030-
1031-用途:
1032-
1033-- 持久化恢复边界
1034-
1035-```sql
1036-CREATE TABLE IF NOT EXISTS task_steps (
1037- step_id TEXT PRIMARY KEY,
1038- task_id TEXT NOT NULL,
1039- step_index INTEGER NOT NULL,
1040- step_name TEXT NOT NULL,
1041- step_kind TEXT NOT NULL,
1042- status TEXT NOT NULL,
1043- assigned_worker_id TEXT,
1044- assigned_controller_id TEXT,
1045- timeout_sec INTEGER NOT NULL,
1046- retry_limit INTEGER NOT NULL DEFAULT 0,
1047- retry_count INTEGER NOT NULL DEFAULT 0,
1048- lease_expires_at INTEGER,
1049- input_json TEXT,
1050- output_json TEXT,
1051- summary TEXT,
1052- error_text TEXT,
1053- created_at INTEGER NOT NULL,
1054- updated_at INTEGER NOT NULL,
1055- started_at INTEGER,
1056- finished_at INTEGER,
1057- UNIQUE(task_id, step_index)
1058-);
1059-```
1060-
1061-索引:
1062-
1063-```sql
1064-CREATE INDEX IF NOT EXISTS idx_task_steps_task_status
1065-ON task_steps(task_id, status, step_index);
1066-```
1067-
1068-## 10.6 `task_runs`
1069-
1070-用途:
1071-
1072-- 记录 step 的执行尝试
1073-
1074-```sql
1075-CREATE TABLE IF NOT EXISTS task_runs (
1076- run_id TEXT PRIMARY KEY,
1077- task_id TEXT NOT NULL,
1078- step_id TEXT NOT NULL,
1079- worker_id TEXT NOT NULL,
1080- controller_id TEXT NOT NULL,
1081- host TEXT NOT NULL,
1082- pid INTEGER,
1083- status TEXT NOT NULL,
1084- lease_expires_at INTEGER,
1085- heartbeat_at INTEGER,
1086- log_dir TEXT NOT NULL,
1087- stdout_path TEXT,
1088- stderr_path TEXT,
1089- worker_log_path TEXT,
1090- checkpoint_seq INTEGER NOT NULL DEFAULT 0,
1091- exit_code INTEGER,
1092- result_json TEXT,
1093- error_text TEXT,
1094- created_at INTEGER NOT NULL,
1095- started_at INTEGER,
1096- finished_at INTEGER
1097-);
1098-```
1099-
1100-索引:
1101-
1102-```sql
1103-CREATE INDEX IF NOT EXISTS idx_task_runs_task
1104-ON task_runs(task_id, created_at);
1105-
1106-CREATE INDEX IF NOT EXISTS idx_task_runs_step
1107-ON task_runs(step_id, created_at);
1108-```
1109-
1110-## 10.7 `task_checkpoints`
1111-
1112-用途:
1113-
1114-- 恢复 step 中途进度的快照
1115-
1116-这一张表很关键,因为某些 Codex step 很长,在 step 完成前就可能已经产生了有价值的部分进度。
1117-
1118-```sql
1119-CREATE TABLE IF NOT EXISTS task_checkpoints (
1120- checkpoint_id TEXT PRIMARY KEY,
1121- task_id TEXT NOT NULL,
1122- step_id TEXT NOT NULL,
1123- run_id TEXT NOT NULL,
1124- seq INTEGER NOT NULL,
1125- checkpoint_type TEXT NOT NULL,
1126- summary TEXT,
1127- content_text TEXT,
1128- content_json TEXT,
1129- created_at INTEGER NOT NULL,
1130- UNIQUE(run_id, seq)
1131-);
1132-```
1133-
1134-checkpoint 类型可以包括:
1135-
1136-- `heartbeat`
1137-- `log_tail`
1138-- `git_diff`
1139-- `summary`
1140-- `test_result`
1141-
1142-## 10.8 `task_logs`
1143-
1144-用途:
1145-
1146-- 存结构化日志尾部与生命周期事件
1147-
1148-```sql
1149-CREATE TABLE IF NOT EXISTS task_logs (
1150- log_id INTEGER PRIMARY KEY AUTOINCREMENT,
1151- task_id TEXT NOT NULL,
1152- step_id TEXT,
1153- run_id TEXT NOT NULL,
1154- seq INTEGER NOT NULL,
1155- stream TEXT NOT NULL,
1156- level TEXT,
1157- message TEXT NOT NULL,
1158- created_at INTEGER NOT NULL
1159-);
1160-```
1161-
1162-## 10.9 `system_state`
1163-
1164-用途:
1165-
1166-- 全局自动化状态
1167-
1168-```sql
1169-CREATE TABLE IF NOT EXISTS system_state (
1170- state_key TEXT PRIMARY KEY,
1171- value_json TEXT NOT NULL,
1172- updated_at INTEGER NOT NULL
1173-);
1174-```
1175-
1176-约定:
1177-
1178-- `state_key = 'automation'`
1179-- value 中包含 `mode = running | draining | paused`
1180-
1181-## 10.10 `task_artifacts`
1182-
1183-可选但推荐。
1184-
1185-用途:
1186-
1187-- 保存 durable 输出的结构化引用
1188-
1189-```sql
1190-CREATE TABLE IF NOT EXISTS task_artifacts (
1191- artifact_id TEXT PRIMARY KEY,
1192- task_id TEXT NOT NULL,
1193- step_id TEXT,
1194- run_id TEXT,
1195- artifact_type TEXT NOT NULL,
1196- path TEXT,
1197- uri TEXT,
1198- size_bytes INTEGER,
1199- sha256 TEXT,
1200- metadata_json TEXT,
1201- created_at INTEGER NOT NULL
1202-);
1203-```
1204-
1205-## 11. Control API
1206-
1207-推荐的生产设计是:
1208-
1209-- 一个 Cloudflare Worker
1210-- 一个 D1 binding
1211-- 所有 durable 状态迁移都经过这个 Worker
1212-
1213-## 11.1 Base URL
1214-
1215-推荐:
1216-
1217-- `https://control-api.makefile.so`
1218-
1219-## 11.2 必需接口
1220-
1221-### `POST /v1/controllers/heartbeat`
1222-
1223-由 mini 和 mac conductor 调用。
1224-
1225-Body:
1226-
1227-```json
1228-{
1229- "controller_id": "mini-main",
1230- "host": "mini",
1231- "role": "primary",
1232- "priority": 100,
1233- "status": "alive",
1234- "version": "0.1.0"
1235-}
1236-```
1237-
1238-### `POST /v1/leader/acquire`
1239-
1240-由 conductors 用来获取或续租 lease。
1241-
1242-Body:
1243-
1244-```json
1245-{
1246- "controller_id": "mini-main",
1247- "host": "mini",
1248- "preferred": true,
1249- "ttl_sec": 30
1250-}
1251-```
1252-
1253-Response:
1254-
1255-```json
1256-{
1257- "ok": true,
1258- "holder_id": "mini-main",
1259- "term": 7,
1260- "lease_expires_at": 1760000000000,
1261- "is_leader": true
1262-}
1263-```
1264-
1265-### `POST /v1/tasks`
1266-
1267-创建 task。
1268-
1269-Body:
1270-
1271-```json
1272-{
1273- "repo": "/Users/george/code/event-fabric",
1274- "task_type": "feature_impl",
1275- "title": "Add conductor D1 schema",
1276- "goal": "Implement the initial D1 schema and migration scripts.",
1277- "priority": 50,
1278- "constraints": {
1279- "target_host": "mini"
1280- },
1281- "acceptance": [
1282- "SQL schema committed",
1283- "migration runner stub added"
1284- ],
1285- "metadata": {
1286- "requested_by": "control",
1287- "branch_prefix": "feat"
1288- }
1289-}
1290-```
1291-
1292-### `POST /v1/tasks/:task_id/plan`
1293-
1294-持久化已通过校验的 step plan。
1295-
1296-该接口由 conductor 在完成 planner 输出校验后调用。
1297-
1298-### `POST /v1/tasks/claim`
1299-
1300-领取待规划 task 或下一个可运行 step。
1301-
1302-也可以拆成:
1303-
1304-- `POST /v1/tasks/claim-planning`
1305-- `POST /v1/steps/claim`
1306-
1307-只要事务语义清晰,这两种形式都可以。
1308-
1309-### `POST /v1/steps/:step_id/heartbeat`
1310-
1311-Body 包含:
1312-
1313-- `run_id`
1314-- `worker_id`
1315-- `lease_expires_at`
1316-- 可选 `checkpoint`
1317-
1318-### `POST /v1/steps/:step_id/checkpoint`
1319-
1320-Body:
1321-
1322-```json
1323-{
1324- "run_id": "run_001",
1325- "seq": 3,
1326- "checkpoint_type": "git_diff",
1327- "summary": "Current worktree diff after route refactor",
1328- "content_text": "diff --git ..."
1329-}
1330-```
1331-
1332-### `POST /v1/steps/:step_id/complete`
1333-
1334-### `POST /v1/steps/:step_id/fail`
1335-
1336-### `POST /v1/system/pause`
1337-
1338-### `POST /v1/system/resume`
1339-
1340-### `POST /v1/system/drain`
1341-
1342-### `GET /v1/system/state`
1343-
1344-### `GET /v1/tasks/:task_id`
1345-
1346-### `GET /v1/tasks/:task_id/logs`
1347-
1348-### `GET /v1/runs/:run_id`
1349-
1350-## 11.3 鉴权
1351-
1352-最低要求:
1353-
1354-- conductor、worker 与 control API 之间使用 HMAC token 或共享密钥
1355-- 修改系统状态的操作使用单独的 admin token
1356-
1357-推荐后续演进:
1358-
1359-- 签名 service token
1360-- 按角色拆 scope
1361-
1362-## 11.4 Control API 授权模型
1363-
1364-推荐角色:
1365-
1366-- `controller`
1367-- `worker`
1368-- `browser_admin`
1369-- `ops_admin`
1370-- `readonly`
1371-
1372-推荐权限划分:
1373-
1374-| Role | Allowed Actions |
1375-| --- | --- |
1376-| `controller` | 获取与续租 lease、claim tasks、更新 run 状态 |
1377-| `worker` | heartbeat、checkpoint、完成已分配 step |
1378-| `browser_admin` | pause、resume、drain、查看队列 |
1379-| `ops_admin` | promote、demote、维护操作 |
1380-| `readonly` | 状态查看、日志、面板 |
1381-
1382-推荐实现:
1383-
1384-- `Authorization: Bearer <token>`
1385-- token 元数据包含角色与可选主机身份
1386-- 修改接口既校验角色,也校验资源归属
1387-
1388-## 11.5 Control API 运维约束
1389-
1390-Worker 应提供:
1391-
1392-- 尽量做到幂等写入
1393-- 对陈旧 term 明确返回冲突
1394-- 记录 request id
1395-- 输出结构化错误体
1396-
1397-推荐错误格式:
1398-
1399-```json
1400-{
1401- "ok": false,
1402- "error": "not_leader",
1403- "message": "This node does not hold the active leadership lease.",
1404- "request_id": "req_123"
1405-}
1406-```
1407-
1408-## 12. Task 模型
1409-
1410-## 12.1 Task 生命周期
1411-
1412-Task 状态:
1413-
1414-- `queued`
1415-- `planning`
1416-- `running`
1417-- `paused`
1418-- `done`
1419-- `failed`
1420-- `canceled`
1421-
1422-状态流转:
1423-
1424-- `queued -> planning`
1425-- `planning -> running`
1426-- `planning -> failed`
1427-- `running -> paused`
1428-- `paused -> running`
1429-- `running -> done`
1430-- `running -> failed`
1431-- `running -> canceled`
1432-
1433-## 12.2 Task 输入 Schema
1434-
1435-推荐归一化格式:
1436-
1437-```json
1438-{
1439- "task_id": "task_001",
1440- "repo": "/Users/george/code/event-fabric",
1441- "task_type": "feature_impl",
1442- "title": "Implement task scheduler",
1443- "goal": "Add the initial scheduler and step claim logic.",
1444- "priority": 50,
1445- "constraints": {
1446- "target_host": "mini",
1447- "write_scope": [
1448- "services/conductor-daemon/**",
1449- "packages/db/**"
1450- ]
1451- },
1452- "acceptance": [
1453- "claim endpoint exists",
1454- "scheduler handles one runnable step",
1455- "tests added"
1456- ],
1457- "metadata": {
1458- "branch_prefix": "feat",
1459- "requested_by": "control"
1460- }
1461-}
1462-```
1463-
1464-## 12.3 分支策略
1465-
1466-一个 task 对应一个分支。
1467-
1468-规则:
1469-
1470-- 分支由 conductor 创建
1471-- worker 不自己发明分支名
1472-- 分支命名应尽量确定性
1473-
1474-推荐命名:
1475-
1476-- `feat/T-001-d1-schema`
1477-- `feat/T-002-step-claim`
1478-- `fix/T-003-checkpoint-recovery`
1479-
1480-## 13. Step 模型
1481-
1482-## 13.1 Step 状态
1483-
1484-- `pending`
1485-- `running`
1486-- `done`
1487-- `failed`
1488-- `timeout`
1489-
1490-## 13.2 Step 类型
1491-
1492-推荐 step kind:
1493-
1494-- `planner`
1495-- `codex`
1496-- `shell`
1497-- `git`
1498-- `review`
1499-- `finalize`
1500-
1501-## 13.3 Step 合约
1502-
1503-每个 step 都必须具备:
1504-
1505-- 明确的目标边界
1506-- 输入 payload
1507-- timeout
1508-- retry 策略
1509-- 完成后的 summary
1510-
1511-## 13.4 恢复边界
1512-
1513-step 完成是主要恢复边界。
1514-
1515-但因为 Codex step 可能很长,所以系统还必须维护 step 中途的 checkpoint。
1516-
1517-## 14. Planning 模型
1518-
1519-## 14.1 Planner 所有权
1520-
1521-task 拆 step 的所有权属于 conductor。
1522-
1523-含义是:
1524-
1525-- planner 只负责提案
1526-- conductor 决定接受并持久化
1527-- worker 只负责执行
1528-
1529-## 14.2 规划策略选择
1530-
1531-conductor 需要决定:
1532-
1533-- `template_first`
1534-- `planner_assisted`
1535-- `manual`
1536-
1537-例如:
1538-
1539-- 标准 bugfix -> `template_first`
1540-- 模糊的大范围重构 -> `planner_assisted`
1541-- 紧急运维变更 -> `manual`
1542-
1543-## 14.3 Planner 输出 Schema
1544-
1545-推荐 planner 输出:
1546-
1547-```json
1548-{
1549- "task_type": "feature_impl",
1550- "strategy": "planner_assisted",
1551- "reasoning": "The task touches scheduler, persistence, and recovery logic. Split into deterministic steps.",
1552- "steps": [
1553- {
1554- "step_name": "prepare_branch",
1555- "step_kind": "git",
1556- "timeout_sec": 120,
1557- "retry_limit": 0,
1558- "input": {}
1559- },
1560- {
1561- "step_name": "inspect_context",
1562- "step_kind": "codex",
1563- "timeout_sec": 600,
1564- "retry_limit": 1,
1565- "input": {
1566- "goal": "Read the codebase and summarize where scheduler logic belongs."
1567- }
1568- },
1569- {
1570- "step_name": "implement_scheduler",
1571- "step_kind": "codex",
1572- "timeout_sec": 1800,
1573- "retry_limit": 1,
1574- "input": {
1575- "goal": "Implement scheduler claim loop and state transition logic."
1576- }
1577- },
1578- {
1579- "step_name": "run_tests",
1580- "step_kind": "shell",
1581- "timeout_sec": 1200,
1582- "retry_limit": 1,
1583- "input": {
1584- "command": "npm test"
1585- }
1586- },
1587- {
1588- "step_name": "commit_push",
1589- "step_kind": "git",
1590- "timeout_sec": 300,
1591- "retry_limit": 0,
1592- "input": {}
1593- }
1594- ]
1595-}
1596-```
1597-
1598-## 14.4 Planner 校验规则
1599-
1600-conductor 校验规则:
1601-
1602-- step 名称必须已知,或被明确允许
1603-- step 顺序必须合法
1604-- timeout 不能无上限
1605-- step 输入不能为空缺
1606-- 不允许 planner 指令直接做状态迁移
1607-
1608-## 15. 推荐的 Task 模板
1609-
1610-## 15.1 `feature_impl`
1611-
1612-推荐默认步骤:
1613-
1614-1. `prepare_branch`
1615-2. `inspect_context`
1616-3. `implement`
1617-4. `run_tests`
1618-5. `review_fix`
1619-6. `commit_push`
1620-7. `finalize`
1621-
1622-## 15.2 `bugfix`
1623-
1624-推荐默认步骤:
1625-
1626-1. `prepare_branch`
1627-2. `reproduce`
1628-3. `implement_fix`
1629-4. `run_targeted_tests`
1630-5. `commit_push`
1631-6. `finalize`
1632-
1633-## 15.3 `review_only`
1634-
1635-推荐默认步骤:
1636-
1637-1. `prepare_context`
1638-2. `analyze_diff`
1639-3. `write_review`
1640-4. `finalize`
1641-
1642-## 15.4 `ops_change`
1643-
1644-推荐默认步骤:
1645-
1646-1. `prepare_branch`
1647-2. `inspect_ops_context`
1648-3. `edit_ops_files`
1649-4. `validate_config`
1650-5. `commit_push`
1651-6. `finalize`
1652-
1653-## 15.5 `infra_bootstrap`
1654-
1655-推荐默认步骤:
1656-
1657-1. `prepare_branch`
1658-2. `inspect_current_ops_state`
1659-3. `edit_dns_or_nginx_docs`
1660-4. `validate_nginx_config`
1661-5. `commit_push`
1662-6. `finalize`
1663-
1664-## 16. Conductor 详细职责
1665-
1666-conductor 必须承担以下职责。
1667-
1668-### 16.1 启动时
1669-
1670-- 在 `controllers` 中注册自身
1671-- 启动 heartbeat 循环
1672-- 如果有资格则尝试获取 lease
1673-- 加载本地未完成 runs
1674-- 对本地过期 runs 做对账
1675-
1676-### 16.2 Leader Loop
1677-
1678-如果是 leader:
1679-
1680-- 规划 queued tasks
1681-- claim runnable steps
1682-- 分配 workers
1683-- 监管 runs
1684-- 持久化 checkpoints
1685-
1686-如果是 standby:
1687-
1688-- 只发 heartbeat
1689-- 不调度新工作
1690-- 随时准备在当前 leader 过期后接管 lease
1691-
1692-### 16.3 Worker 分配
1693-
1694-conductor 负责选择:
1695-
1696-- host
1697-- worker slot
1698-- worktree 路径
1699-- run 目录
1700-- timeout 预算
1701-
1702-### 16.4 Timeout 执行
1703-
1704-conductor 负责:
1705-
1706-- step timeout
1707-- lease 过期
1708-- 从 `SIGTERM` 升级到 `SIGKILL`
1709-
1710-### 16.5 恢复
1711-
1712-重启后:
1713-
1714-- 扫描本地 run 目录
1715-- 标记 orphaned runs
1716-- 对比本地状态与 D1 状态
1717-- 视情况重新入队或恢复
1718-
1719-## 17. Worker 模型
1720-
1721-## 17.1 为什么 Worker 要短生命周期
1722-
1723-短生命周期 worker 更容易:
1724-
1725-- 隔离故障
1726-- 收集日志
1727-- 管理资源
1728-- 在崩溃后恢复
1729-
1730-## 17.2 Step 执行合约
1731-
1732-worker 合约是:
1733-
1734-1. 接收一个 step
1735-2. 执行一个 step
1736-3. 输出本地日志
1737-4. 输出结构化结果
1738-5. 退出
1739-
1740-worker 不负责:
1741-
1742-- 管理队列
1743-- 持有 lease
1744-- 选择下一个 step
1745-
1746-## 17.3 Worker 类型
1747-
1748-### Codex Worker
1749-
1750-用于:
1751-
1752-- 阅读上下文
1753-- 修改代码
1754-- 输出总结
1755-
1756-### Shell Worker
1757-
1758-用于:
1759-
1760-- 测试
1761-- lint
1762-- 文件检查
1763-- build 命令
1764-
1765-### Git Worker
1766-
1767-用于:
1768-
1769-- 建分支
1770-- commit
1771-- push
1772-- 重置 per-task worktree
1773-
1774-## 18. Codex Worker 合约
1775-
1776-## 18.1 关键约束
1777-
1778-Codex 不以进程恢复为目标。
1779-
1780-系统恢复的是:
1781-
1782-- task
1783-- step
1784-- 最新 checkpoint
1785-
1786-系统不恢复:
1787-
1788-- 已死亡 Codex 进程内部的精确推理状态
1789-
1790-## 18.2 Codex Step 行为
1791-
1792-对于 Codex step:
1793-
1794-- conductor 准备 prompt 与上下文文件
1795-- conductor 启动 Codex
1796-- Codex 把输出写入本地日志
1797-- conductor 周期性抓取 checkpoint
1798-- step 完成后 Codex 退出
1799-
1800-## 18.3 为什么这比当前 `baa-hand` 的 Codex 模式更好
1801-
1802-当前 `baa-hand` 的 Codex 集成会一直等到进程退出,然后才返回输出。
1803-
1804-对于 durable worker 系统,这不够,因为:
1805-
1806-- 它不会把进度持续写成 durable 状态
1807-- 它不支持 checkpoint 恢复
1808-- 它没有把 task 状态和进程状态分离
1809-
1810-因此:
1811-
1812-- 当前 `baa-hand` 的 Codex 路径继续保留给临时用途
1813-- 生产自动化要走 conductor 自己持有的 Codex worker 路径
1814-
1815-## 18.4 Codex 结果 Schema
1816-
1817-推荐最终 step 输出:
1818-
1819-```json
1820-{
1821- "ok": true,
1822- "summary": "Implemented scheduler claim loop and added tests.",
1823- "needs_human": false,
1824- "blocked": false,
1825- "artifacts": [],
1826- "metrics": {
1827- "duration_sec": 812
1828- }
1829-}
1830-```
1831-
1832-可选 hint:
1833-
1834-```json
1835-{
1836- "ok": false,
1837- "summary": "Need failing test output from package runtime.",
1838- "blocked": true,
1839- "suggested_followup": [
1840- {
1841- "step_name": "collect_runtime_test_output",
1842- "step_kind": "shell"
1843- }
1844- ]
1845-}
1846-```
1847-
1848-这些 hint 只是 proposal,不是直接状态修改。
1849-
1850-## 19. 日志模型
1851-
1852-## 19.1 本地完整日志
1853-
1854-每个 run 都有一个本地目录:
1855-
1856-```text
1857-<repo>/.baa-conductor/runs/<task-id>/<run-id>/
1858- meta.json
1859- worker.log
1860- stdout.log
1861- stderr.log
1862- checkpoints/
1863- 0001-summary.json
1864- 0002-git-diff.patch
1865-```
1866-
1867-本地完整日志是详细取证记录。
1868-
1869-## 19.2 D1 日志索引
1870-
1871-D1 中只存:
1872-
1873-- run 元数据
1874-- 生命周期事件
1875-- 周期性 tail chunk
1876-- 最新 checkpoint 指针
1877-
1878-不应该把全部字节都长期塞进 D1。
1879-
1880-## 19.3 日志事件
1881-
1882-关键生命周期事件包括:
1883-
1884-- task created
1885-- planner selected
1886-- plan accepted
1887-- step claimed
1888-- worker started
1889-- checkpoint persisted
1890-- timeout
1891-- step done
1892-- task done
1893-- failover occurred
1894-
1895-## 20. Checkpoint 模型
1896-
1897-这是稳定性的关键部分。
1898-
1899-## 20.1 为什么需要 Checkpoint
1900-
1901-如果 Codex 改了 20 分钟代码,机器突然死掉,只按 step 边界恢复,就会丢掉所有未提交进度。
1902-
1903-因此,长 AI step 需要 step 中途 checkpoint。
1904-
1905-## 20.2 Checkpoint 类型
1906-
1907-推荐类型:
1908-
1909-- `summary`
1910-- `git_diff`
1911-- `log_tail`
1912-- `test_output`
1913-
1914-## 20.3 Git Diff Checkpoint
1915-
1916-对于 Codex 编辑类 step,conductor 应周期性快照:
1917-
1918-- `git status --short`
1919-- `git diff --binary`
1920-- 可选 `git diff --stat`
1921-
1922-推荐频率:
1923-
1924-- 每 30 到 60 秒一次
1925-- 或在检测到有意义的文件变化后执行
1926-
1927-## 20.4 Checkpoint 存储策略
1928-
1929-MVP 期的策略:
1930-
1931-- 最新 checkpoint summary 存入 D1
1932-- patch 文本大小合理时,一并写入 D1
1933-- 完整 checkpoint 文件保存在本地
1934-
1935-如果 patch 太大:
1936-
1937-- D1 里只保留截断 summary
1938-- 完整 patch 只保留在本地
1939-- 后续可选接入 R2
1940-
1941-## 20.5 从 Checkpoint 恢复
1942-
1943-如果 mini 在执行 Codex step 时宕机:
1944-
1945-1. mac 成为 leader
1946-2. mac 发现 run lease 已过期
1947-3. mac 创建新的 worktree
1948-4. mac checkout task branch 或 base ref
1949-5. 如果存在 checkpointed diff,则回放最新 diff
1950-6. 从最近一个可接受 checkpoint 继续,或直接重跑该 step
1951-
1952-这不是 bit-perfect 的进程恢复。
1953-
1954-这是 durable 的 task 恢复,并支持部分 patch 回放。
1955-
1956-## 21. Timeout 与 Retry 规则
1957-
1958-## 21.1 Timeout 所有权
1959-
1960-timeout 由 conductor 负责。
1961-
1962-worker 不对整个 task 自己做 timeout 判定。
1963-
1964-## 21.2 进程处理
1965-
1966-推荐升级顺序:
1967-
1968-1. 发送 `SIGTERM`
1969-2. 等待 5 秒
1970-3. 发送 `SIGKILL`
1971-4. 标记 run 为 `timeout`
1972-5. 根据策略决定 retry 或 fail
1973-
1974-## 21.3 Retry 规则
1975-
1976-推荐默认值:
1977-
1978-- planner steps: 1 次重试
1979-- codex inspect steps: 1 次重试
1980-- codex implement steps: 1 次重试
1981-- test steps: 基础设施失败时 1 次重试,确定性失败时 0 次
1982-- git commit/push: 默认 0 次,除非 push 因瞬时网络原因失败
1983-
1984-## 21.4 失败分类
1985-
1986-需要明确区分:
1987-
1988-- `timeout`
1989-- `worker_crash`
1990-- `infra_unreachable`
1991-- `deterministic_failure`
1992-- `blocked`
1993-
1994-这个分类会直接影响 retry 策略。
1995-
1996-## 22. Pause、Drain 与 Resume
1997-
1998-## 22.1 全局模式
1999-
2000-全局自动化模式:
2001-
2002-- `running`
2003-- `draining`
2004-- `paused`
2005-
2006-## 22.2 语义
2007-
2008-### `running`
2009-
2010-- 正常运行
2011-
2012-### `draining`
2013-
2014-- 不再启动新的 step
2015-- 已启动的 run 可以自然结束
2016-- 适合计划内切换或维护
2017-
2018-### `paused`
2019-
2020-- 不再启动新的 step
2021-- conductor 调度暂停
2022-- 已运行任务是否继续或终止,由策略决定
2023-
2024-## 22.3 真相来源
2025-
2026-全局模式存储在 D1 的 `system_state` 中。
2027-
2028-浏览器 UI 只是控制面板,不是真相来源。
2029-
2030-## 23. Firefox 插件集成
2031-
2032-## 23.1 浏览器职责
2033-
2034-Firefox 插件应负责:
2035-
2036-- 展示当前全局自动化模式
2037-- 提供 `pause`、`resume`,可选 `drain`
2038-- 展示当前 leader host
2039-- 如有需要,展示队列深度与 active task 数
2040-
2041-## 23.2 浏览器控制流
2042-
2043-可见的 `control` 对话仍然只用于人机交互。
2044-
2045-插件不应混用:
2046-
2047-- 你的交互式 `control` 对话
2048-- 自动化 task dispatch
2049-
2050-推荐模型:
2051-
2052-- 一个可见 `control` 对话
2053-- 一个隐藏 `dispatch` 通道
2054-
2055-## 23.3 浏览器按钮
2056-
2057-最少需要这些按钮:
2058-
2059-- `Pause`
2060-- `Resume`
2061-- 可选 `Drain`
2062-
2063-按钮应调用:
2064-
2065-- `POST /v1/system/pause`
2066-- `POST /v1/system/resume`
2067-- `POST /v1/system/drain`
2068-
2069-## 23.4 浏览器状态显示
2070-
2071-最低显示项:
2072-
2073-- 当前 mode
2074-- 当前 leader
2075-- active runs
2076-- queued tasks
2077-
2078-## 24. `baa-hand` 与 `baa-shell` 集成
2079-
2080-## 24.1 `baa-hand`
2081-
2082-`baa-hand` 适用于:
2083-
2084-- planner 调用
2085-- review 调用
2086-- 非 durable 的临时 AI 工作
2087-
2088-推荐:
2089-
2090-- planner 与 review steps 调 `led.makefile.so`
2091-- execution steps 不依赖当前 `baa-hand` 的 Codex 模式来保证 durability
2092-
2093-## 24.2 `baa-shell`
2094-
2095-`baa-shell` 适用于:
2096-
2097-- 当 HTTP 比 SSH 更方便时,执行远程 shell
2098-- 让外部自动化读写文件
2099-- 提供状态页面
2100-
2101-对于同机操作,本地 conductor 代码依然可以直接用 local exec。
2102-
2103-## 25. mini 与 mac 上的本地目录布局
2104-
2105-推荐根路径:
2106-
2107-```text
2108-/Users/george/code/baa-conductor
2109-```
2110-
2111-推荐 runtime 目录:
2112-
2113-```text
2114-/Users/george/code/baa-conductor/state/
2115-/Users/george/code/baa-conductor/runs/
2116-/Users/george/code/baa-conductor/worktrees/
2117-/Users/george/code/baa-conductor/logs/
2118-/Users/george/code/baa-conductor/tmp/
2119-```
2120-
2121-推荐的 per-run 目录:
2122-
2123-```text
2124-/Users/george/code/baa-conductor/runs/<task-id>/<run-id>/
2125- meta.json
2126- state.json
2127- stdout.log
2128- stderr.log
2129- worker.log
2130- checkpoints/
2131- artifacts/
2132-```
2133-
2134-推荐的 worktree 目录:
2135-
2136-```text
2137-/Users/george/code/baa-conductor/worktrees/<task-id>/
2138-```
2139-
2140-规则:
2141-
2142-- 一个 task 恰好一个 worktree
2143-- worktree 路径应确定性
2144-- 陈旧 worktree 只能由 conductor 清理,worker 不得自行清理
2145-
2146-## 25.1 本地文件约定
2147-
2148-建议由 conductor 写入的文件:
2149-
2150-- `meta.json`: 不变的 run 元数据
2151-- `state.json`: 当前本地状态镜像
2152-- `worker.log`: 当前 run 的 conductor 生命周期消息
2153-- `stdout.log`: worker 原始 stdout
2154-- `stderr.log`: worker 原始 stderr
2155-
2156-建议作为 checkpoint 写入的文件:
2157-
2158-- `checkpoints/0001-summary.json`
2159-- `checkpoints/0002-git-diff.patch`
2160-- `checkpoints/0003-test-output.txt`
2161-
2162-## 26. 建议的仓库结构
2163-
2164-此仓库最终建议长成这样:
2165-
2166-```text
2167-apps/
2168- control-api-worker/
2169- conductor-daemon/
2170- status-api/
2171- worker-runner/
2172-packages/
2173- db/
2174- planner/
2175- schemas/
2176- step-templates/
2177- logging/
2178- git-tools/
2179- checkpointing/
2180-ops/
2181- nginx/
2182- sql/
2183- launchd/
2184- scripts/
2185-docs/
2186- decisions/
2187-```
2188-
2189-## 26.1 建议最先创建的文件
2190-
2191-为了方便并行开发,初始仓库至少应包含:
2192-
2193-```text
2194-README.md
2195-DESIGN.md
2196-ops/sql/schema.sql
2197-ops/sql/migrations/0001_init.sql
2198-ops/nginx/baa-conductor.conf
2199-ops/launchd/so.makefile.baa-conductor.plist
2200-ops/launchd/so.makefile.baa-worker-runner.plist
2201-apps/control-api-worker/src/index.ts
2202-apps/conductor-daemon/src/index.ts
2203-apps/worker-runner/src/index.ts
2204-packages/schemas/src/index.ts
2205-packages/db/src/index.ts
2206-packages/planner/src/index.ts
2207-packages/step-templates/src/index.ts
2208-packages/logging/src/index.ts
2209-packages/checkpointing/src/index.ts
2210-```
2211-
2212-## 27. mini 与 mac 上的 launchd
2213-
2214-因为 mini 和 mac 都是 macOS,推荐用 `launchd` 做进程守护。
2215-
2216-建议服务:
2217+当前只维护 `mini` 的 launchd 安装:
2218
2219 - `so.makefile.baa-conductor`
2220-- `so.makefile.baa-worker-runner`
2221 - 可选 `so.makefile.baa-status-api`
2222+- 可选 `so.makefile.baa-worker-runner`
2223
2224-建议行为:
2225-
2226-- 开机或登录自动启动
2227-- 失败自动拉起
2228-- 日志写文件
2229-
2230-conductor 的示例参数:
2231-
2232-```xml
2233-<array>
2234- <string>/usr/bin/env</string>
2235- <string>node</string>
2236- <string>/Users/george/code/baa-conductor/apps/conductor-daemon/dist/index.js</string>
2237- <string>--host</string>
2238- <string>mini</string>
2239- <string>--role</string>
2240- <string>primary</string>
2241-</array>
2242-```
2243-
2244-## 27.1 建议的 launchd 路径
2245-
2246-推荐安装路径:
2247-
2248-- repo 中的 plist 源文件:`ops/launchd/*.plist`
2249-- 用户级安装路径:`~/Library/LaunchAgents/`
2250-- 如果需要开机登录前启动,可改用系统级 `/Library/LaunchDaemons/`
2251-
2252-推荐 label:
2253-
2254-- `so.makefile.baa-conductor`
2255-- `so.makefile.baa-worker-runner`
2256-- `so.makefile.baa-status-api`
2257-
2258-## 27.2 环境变量
2259-
2260-建议 conductor 与 worker 服务使用:
2261-
2262-```text
2263-BAA_CONDUCTOR_HOST=mini
2264-BAA_CONDUCTOR_ROLE=primary
2265-BAA_CONTROL_API_BASE=https://control-api.makefile.so
2266-BAA_CONDUCTOR_PUBLIC_BASE=https://conductor.makefile.so
2267-BAA_CONDUCTOR_LOCAL_API=http://127.0.0.1:4317
2268-BAA_CONDUCTOR_PRIVATE_BASE=http://100.71.210.78:4317
2269-BAA_CONDUCTOR_PEER_BASE=http://100.112.239.13:4317
2270-BAA_RUNS_DIR=/Users/george/code/baa-conductor/runs
2271-BAA_WORKTREES_DIR=/Users/george/code/baa-conductor/worktrees
2272-BAA_LOGS_DIR=/Users/george/code/baa-conductor/logs
2273-BAA_TMP_DIR=/Users/george/code/baa-conductor/tmp
2274-BAA_NODE_ID=mini-main
2275-BAA_SHARED_TOKEN=replace-me
2276-```
2277-
2278-mac 上至少需要不同的变量:
2279-
2280-- `BAA_CONDUCTOR_HOST`
2281-- `BAA_CONDUCTOR_ROLE`
2282-- `BAA_NODE_ID`
2283-- `BAA_CONDUCTOR_PRIVATE_BASE`
2284-- `BAA_CONDUCTOR_PEER_BASE`
2285-
2286-说明:
2287-
2288-- `BAA_CONTROL_API_BASE` 仍然指向 Cloudflare Worker,自身不走 Tailscale
2289-- 节点间与内网调用统一使用 `BAA_CONDUCTOR_PRIVATE_BASE` / `BAA_CONDUCTOR_PEER_BASE`
2290-- 不要把 `mini.tail0125d.ts.net` 或 `mbp.tail0125d.ts.net` 写进运行配置
2291-
2292-## 28. 安全模型
2293-
2294-## 28.1 Control API
2295-
2296-- conductor 和 worker 调用必须鉴权
2297-- pause、resume、drain、promote 等操作需要更强的 admin 鉴权
2298-
2299-## 28.2 直连节点域名
2300-
2301-- `mini-conductor.makefile.so` 和 `mac-conductor.makefile.so` 必须受保护
2302-- 使用 Basic Auth、IP allowlist 或两者同时使用
2303-
2304-## 28.3 日志
2305-
2306-日志中可能包含:
2307-
2308-- prompts
2309-- diffs
2310-- 文件路径
2311-- 测试输出
2312-
2313-规则:
2314-
2315-- 尽可能做 secrets 脱敏
2316-- 不公开暴露原始日志
2317-
2318-## 29. 可观测性
2319-
2320-每个 conductor 应暴露:
2321-
2322-- `/healthz`
2323-- `/readyz`
2324-- `/rolez`
2325-- 如果后续需要,可加 `/metrics`
2326-
2327-有用的状态字段:
2328-
2329-- controller id
2330-- role
2331-- lease holder
2332-- lease expiry
2333-- active runs
2334-- queue depth
2335-- paused mode
2336-
2337-## 30. 故障场景
2338-
2339-## 30.1 Mini 在空闲状态下死亡
2340-
2341-- lease 过期
2342-- mac 抢到 lease
2343-- 队列继续运行
2344-
2345-## 30.2 Mini 在 Codex Step 中死亡
2346-
2347-- run lease 过期
2348-- mac 成为 leader
2349-- mac 读取最新 checkpoint
2350-- mac 回放 checkpoint 或直接重跑该 step
2351-
2352-## 30.3 Mac 在 Standby 状态下死亡
2353-
2354-- 不影响服务
2355-
2356-## 30.4 D1 暂时不可用
2357-
2358-leader 应:
2359-
2360-- 停止 claim 新工作
2361-- 在安全前提下继续监管本地正在运行的 worker
2362-- 持续尝试重连
2363-- 在控制面未确认前,不进行不安全写入
2364-
2365-## 30.5 浏览器 UI 失效
2366-
2367-- 自动化仍然继续
2368-- 只是人类控制面临时不可用
2369-
2370-## 30.6 VPS 死亡
2371-
2372-如果 VPS 掉了:
2373-
2374-- 公网入口失效
2375-- 公网域名无法再直接做运维操作
2376-- 只要 mini 与 mac 还能访问 `control-api.makefile.so`,内部调度仍可继续
2377-- 只要 tailnet 还通,运维仍可通过 Tailscale `100.x` 直达节点
2378-
2379-推荐处理:
2380-
2381-- 不要因为公网入口消失就主动停掉 worker 集群
2382-- 单独修复 VPS
2383-
2384-## 30.7 Cloudflare Worker 或 D1 退化
2385-
2386-如果 control API 不可用:
2387-
2388-- leaders 停止 claim 新工作
2389-- 已在本地运行的 steps 可以在安全前提下继续
2390-- 不应盲目提交不可逆的调度状态
2391-
2392-恢复规则:
2393-
2394-- control API 恢复后,conductors 要把本地 run 状态与 D1 重新对账
2395-
2396-## 31. 建议的 API 返回码
2397-
2398-- `200` 正常读取
2399-- `202` 接受异步创建
2400-- `409` 节点健康但不是 leader,不能执行写操作
2401-- `423` task 或 step 被 lease 锁住
2402-- `503` 控制平面退化
2403-
2404-## 32. 建议的初始端口
2405-
2406-以下只是建议值:
2407-
2408-- conductor 本地 HTTP API: `4317`
2409-- status API: `4318`
2410-- 可选 metrics: `4319`
2411-
2412-## 33. 时序流程
2413-
2414-## 33.1 Task 创建流程
2415-
2416-1. human 在可见 Claude `control` 中下指令
2417-2. `control` 通过 control API 创建 task
2418-3. leader conductor 取到 task
2419-4. leader 选择模板或 planner
2420-5. 持久化 plan
2421-6. steps 进入 runnable
2422-
2423-## 33.2 Step 执行流程
2424-
2425-1. leader claim 下一个 runnable step
2426-2. leader 分配 worker 与 worktree
2427-3. worker 启动
2428-4. worker 把日志持续写到本地
2429-5. conductor 写 heartbeat 与 checkpoints
2430-6. worker 退出
2431-7. conductor 把 step 标记为 complete 或 failed
2432-
2433-## 33.3 Failover 流程
2434-
2435-1. mini lease 过期
2436-2. mac 获取 leadership
2437-3. mac 扫描未完成 steps
2438-4. mac 基于 checkpoints 恢复 runnable 工作
2439-
2440-## 33.4 人工 Pause 与接管流程
2441-
2442-1. 人在 Firefox 插件或状态页点击 `Pause`
2443-2. 浏览器调用 `POST /v1/system/pause`
2444-3. control API 更新 D1 中的 `system_state`
2445-4. 当前 leader 观察到 mode 变化
2446-5. leader 停止启动新的 steps
2447-6. 人开始和可见 Claude `control` 对话
2448-7. 人点击 `Resume`
2449-8. leader 恢复调度
2450-
2451-## 34. 建议的 MVP 范围
2452-
2453-第一阶段至少要包含:
2454-
2455-1. 仓库骨架
2456-2. D1 schema 与 migration scripts
2457-3. control API Worker
2458-4. conductor lease loop
2459-5. task 与 step 持久化
2460-6. 一个带本地日志流的 Codex step runner
2461-7. 一个 shell step runner
2462-8. pause、resume、drain
2463-9. mini -> mac 的基础 failover
2464-10. Firefox 插件按钮接到 control API
2465-
2466-## 35. 建议的并行开发拆分
2467-
2468-这一节的目的就是让多个 Codex worker 立刻开始并行干活。
2469-
2470-每个 task 只能有一个分支和一个 owner。
2471-
2472-## 35.0 协作规则
2473-
2474-这些规则在多 Codex 并行时必须遵守。
2475-
2476-### 分支规则
2477-
2478-- 一个 task 等于一个 branch
2479-- 一个 branch 同时只允许一个 owner
2480-- branch 名由 conductor 或 task 创建方分配
2481-
2482-### Worktree 规则
2483-
2484-- 一个 task 等于一个 worktree
2485-- 不允许两个 worker 共用一个活跃 worktree
2486-
2487-### Write Scope 规则
2488-
2489-每个 task 都必须声明 `write_scope`。
2490-
2491-如果需要修改超出 `write_scope` 的文件:
2492-
2493-- 要么新建 task
2494-- 要么显式修订 scope
2495-
2496-### 全局真相规则
2497-
2498-- task 真相在 D1
2499-- 本地真相在 run 目录
2500-- 对话记录不是 task 真相
2501-
2502-### 热点文件规则
2503-
2504-以下内容应视为协调敏感文件:
2505-
2506-- 根级 lockfile
2507-- 全局 CI 配置
2508-- 共享 schema 文件
2509-- 一旦开始实现后,这份 `DESIGN.md` 本身
2510-
2511-对热点文件的修改应串行化,或提前明确指派。
2512-
2513-## 35.0.1 Task Card 模板
2514-
2515-未来仓库中,每个并行 task 都应该有一个任务卡,例如:
2516-
2517-```md
2518----
2519-task_id: T-004
2520-title: Conductor lease and heartbeat loop
2521-owner: codex-mini-01
2522-branch: feat/T-004-conductor-lease
2523-repo: /Users/george/code/baa-conductor
2524-base_ref: main
2525-write_scope:
2526- - apps/conductor-daemon/**
2527- - packages/db/**
2528-depends_on:
2529- - T-002
2530- - T-003
2531-acceptance:
2532- - leader lease acquisition works
2533- - standby nodes do not schedule
2534- - lease renewal tests added
2535-status: in_progress
2536----
2537-```
2538-
2539-## 35.0.2 完成报告模板
2540-
2541-task 完成后,worker 应至少回报:
2542-
2543-```md
2544-task_id: T-004
2545-branch: feat/T-004-conductor-lease
2546-base_ref: <commit>
2547-files_changed:
2548- - apps/conductor-daemon/src/index.ts
2549- - packages/db/src/lease.ts
2550-commands_run:
2551- - pnpm test
2552-result:
2553- - passed targeted tests
2554-risks:
2555- - none
2556-```
2557-
2558-## 35.1 Task A: 仓库骨架
2559-
2560-Branch:
2561-
2562-- `feat/T-001-repo-scaffold`
2563-
2564-Scope:
2565-
2566-- 仓库目录结构
2567-- package manager 配置
2568-- 根配置
2569-- 初始 README
2570-
2571-不要改:
2572-
2573-- D1 SQL
2574-- Nginx 配置
2575-- Firefox 插件
2576-
2577-## 35.2 Task B: D1 Schema 与 Migrations
2578-
2579-Branch:
2580-
2581-- `feat/T-002-d1-schema`
2582-
2583-Scope:
2584-
2585-- `ops/sql/**`
2586-- `packages/db/**`
2587-
2588-不要改:
2589-
2590-- conductor runtime
2591-- worker runner
2592-
2593-## 35.3 Task C: Control API Worker
2594-
2595-Branch:
2596-
2597-- `feat/T-003-control-api`
2598-
2599-Scope:
2600-
2601-- `apps/control-api-worker/**`
2602-
2603-依赖:
2604-
2605-- Task B
2606-
2607-## 35.4 Task D: Conductor Lease 与 Heartbeat Loop
2608-
2609-Branch:
2610-
2611-- `feat/T-004-conductor-lease`
2612-
2613-Scope:
2614-
2615-- `apps/conductor-daemon/**`
2616-- `packages/db/**`
2617-
2618-依赖:
2619-
2620-- Task B
2621-- Task C
2622-
2623-## 35.5 Task E: Worker Runner 与本地日志流
2624-
2625-Branch:
2626-
2627-- `feat/T-005-worker-runner`
2628-
2629-Scope:
2630-
2631-- `apps/worker-runner/**`
2632-- `packages/logging/**`
2633-
2634-不要改:
2635-
2636-- Nginx
2637-- Firefox 插件
2638-
2639-## 35.6 Task F: Checkpoint 与 Git Diff Snapshots
2640-
2641-Branch:
2642-
2643-- `feat/T-006-checkpointing`
2644-
2645-Scope:
2646-
2647-- `packages/checkpointing/**`
2648-- worker-runner 集成
2649-
2650-依赖:
2651-
2652-- Task E
2653-
2654-## 35.7 Task G: Planner 抽象与模板
2655-
2656-Branch:
2657-
2658-- `feat/T-007-planner`
2659-
2660-Scope:
2661-
2662-- `packages/planner/**`
2663-- `packages/step-templates/**`
2664-
2665-## 35.8 Task H: Nginx 与 VPS 运维
2666-
2667-Branch:
2668-
2669-- `feat/T-008-ops-nginx`
2670-
2671-Scope:
2672-
2673-- `ops/nginx/**`
2674-- 部署文档
2675-
2676-## 35.9 Task I: Firefox 插件 Pause 与 Resume
2677-
2678-Branch:
2679-
2680-- `feat/T-009-firefox-pause`
2681-
2682-Scope:
2683-
2684-- Firefox 插件集成文档
2685-- 浏览器状态协议
2686-
2687-这部分现在统一收口在本仓库的 Firefox 插件子目录里,但协议仍然在这里定义清楚。
2688-
2689-## 35.10 Task J: Status API 与基础 UI
2690-
2691-Branch:
2692-
2693-- `feat/T-010-status-api`
2694-
2695-Scope:
2696-
2697-- `apps/status-api/**`
2698-
2699-## 35.11 Task K: launchd 与本地 Runtime 布局
2700-
2701-Branch:
2702-
2703-- `feat/T-011-launchd-runtime`
2704-
2705-Scope:
2706-
2707-- `ops/launchd/**`
2708-- 本地 runtime 路径文档
2709-
2710-## 35.12 Task L: 鉴权与 Token 模型
2711-
2712-Branch:
2713-
2714-- `feat/T-012-auth-model`
2715-
2716-Scope:
2717-
2718-- worker 鉴权设计
2719-- token 校验中间件
2720-- 角色定义
2721-
2722-## 36. Runbook
2723-
2724-这一节故意写成运维操作说明,是真正出问题时要照着用的。
2725-
2726-## 36.1 初始部署 Runbook
2727-
2728-1. 部署 Cloudflare Worker 与 D1
2729-2. 创建 D1 schema
2730-3. 部署 `control-api.makefile.so`
2731-4. 配置 DNS 记录
2732-5. 在 VPS 安装 Nginx 配置
2733-6. 验证 `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so`
2734-7. 在 mini 安装 launchd 服务
2735-8. 在 mac 安装 launchd 服务
2736-9. 启动 mini conductor 并确认拿到 lease
2737-10. 启动 mac conductor 并确认处于 standby
2738-11. 验证浏览器 `Pause` 与 `Resume`
2739-
2740-## 36.2 计划内切换 Runbook
2741+脚本:
2742
2743-从 mini 安全切到 mac:
2744+- [`scripts/runtime/install-launchd.sh`](./scripts/runtime/install-launchd.sh)
2745+- [`scripts/runtime/check-launchd.sh`](./scripts/runtime/check-launchd.sh)
2746+- [`scripts/runtime/check-node.sh`](./scripts/runtime/check-node.sh)
2747+- [`scripts/runtime/reload-launchd.sh`](./scripts/runtime/reload-launchd.sh)
2748
2749-1. 把全局模式设为 `draining`
2750-2. 等活跃 runs 正常结束或至少完成 checkpoint
2751-3. 确认 mini 没有未收口的可变工作
2752-4. demote mini
2753-5. promote mac,或让 mac 自然获取 lease
2754-6. 确认 mac 的 `GET /rolez` 返回 `leader`
2755-7. 把全局模式改回 `running`
2756+## 7. Nginx 与 DNS
2757
2758-## 36.3 紧急故障切换 Runbook
2759+当前只维护一个公网入口:
2760
2761-如果 mini 突然挂掉:
2762+- `conductor.makefile.so`
2763
2764-1. 确认 mini heartbeat 已过期
2765-2. 等 lease 超时
2766-3. 确认 mac 已获取 lease
2767-4. 查看未完成 runs
2768-5. 基于 checkpoints 恢复或重跑当前 steps
2769+回源关系:
2770
2771-## 36.4 切回 Mini 的 Runbook
2772+- `conductor.makefile.so` -> VPS Nginx -> `100.71.210.78:4317`
2773
2774-mini 恢复后:
2775+相关文件:
2776
2777-1. 先让 mini 继续保持 standby
2778-2. 修复原始故障
2779-3. 选择一个低峰窗口
2780-4. 把系统设为 `draining`
2781-5. drain mac
2782-6. promote mini
2783-7. 确认 mini 成为 leader
2784-8. 把系统切回 `running`
2785+- [`ops/nginx/baa-conductor.conf`](./ops/nginx/baa-conductor.conf)
2786+- [`scripts/ops/baa-conductor.env.example`](./scripts/ops/baa-conductor.env.example)
2787+- [`scripts/ops/cloudflare-dns-plan.mjs`](./scripts/ops/cloudflare-dns-plan.mjs)
2788+- [`scripts/ops/nginx-sync-plan.mjs`](./scripts/ops/nginx-sync-plan.mjs)
2789
2790-## 37. 接下来立刻要做的事
2791+## 8. 保留的内部复杂度
2792
2793-下一波开发建议按这个顺序推进:
2794+当前主线仍保留一部分内部 `leader` / `lease` 语义,因为:
2795
2796-1. 创建代码骨架
2797-2. 实现 D1 schema
2798-3. 实现 control API
2799-4. 实现 conductor lease
2800-5. 实现一个 worker runner
2801-6. 实现 checkpoints
2802-7. 接上 Firefox 的 pause 与 resume
2803-8. 补上 VPS Nginx 配置
2804+- control plane 已经围绕该模型实现
2805+- 单节点下它仍能正常工作
2806+- 现在不值得为替代项目到来前再做一次大规模重构
2807
2808-做到这里,这套系统就能真正支撑多 Codex 并行执行。
2809+但这些内部语义不再代表“主备切换能力是当前目标”。
2810
2811-## 38. 总结
2812+## 9. 当前真正关心的事情
2813
2814-这份设计刻意把以下东西拆开:
2815+- `mini` 本地服务可持续启动
2816+- `control-api` 与 `conductor` 对外可达
2817+- 浏览器插件可以控制系统模式
2818+- `status-api` 的真相来源逐步收口
2819
2820-- 人类对话
2821-- 自动化对话
2822-- 规划
2823-- 编排
2824-- 执行
2825+## 10. 历史回溯
2826
2827-durable 核心是:
2828+需要查看旧的主备设计、切换脚本和历史文档时:
2829
2830-- D1 存共享真相
2831-- conductor 做确定性编排
2832-- Codex 做短生命周期 step worker
2833-- 本地日志加周期性 checkpoint
2834-- mini 做首选 leader,mac 做 standby
2835+- git tag: `ha-failover-archive-2026-03-22`
2836
2837-后续所有实现都应该以这个模型为准。
2838+当前主线不再保留这些内容。
+23,
-36
1@@ -1,23 +1,22 @@
2 # baa-conductor
3
4-`baa-conductor` 是新的 AI 执行编排仓库。
5+`baa-conductor` 现在是一个收口后的单节点执行控制仓库。
6
7-它的目标不是提供单个 AI 会话,而是提供一套以 `mini` 为唯一中控、带自启动和浏览器控制面的稳定执行基础设施。
8+当前目标只有一个:
9
10-当前仓库状态:
11+- `mini` 作为唯一中控长期运行
12+- `mini` 负责自启动、控制面、状态面和浏览器插件配合
13+- 对外公网入口保留 `control-api.makefile.so` 与 `conductor.makefile.so`
14+- 内网回源固定走 `mini` 的 Tailscale `100.x`
15
16-- 设计文档已落在 [`DESIGN.md`](./DESIGN.md)
17-- 代码目录骨架已建立
18-- 当前推荐部署模式已经收口为 `mini` 单节点
19-- 并行任务文档已放在 [`coordination/`](./coordination/)
20+主备切换、failover、switchback 和其它历史方案已经从当前主线移除;如需回溯,直接查看 tag `ha-failover-archive-2026-03-22`。
21
22 ## 先读什么
23
24-每个 Codex 实例启动后,按这个顺序读:
25-
26 1. [`DESIGN.md`](./DESIGN.md)
27 2. [`coordination/TASK_OVERVIEW.md`](./coordination/TASK_OVERVIEW.md)
28-3. 自己的任务卡,例如 `coordination/tasks/T-004-conductor-lease.md`
29+3. [`docs/runtime/README.md`](./docs/runtime/README.md)
30+4. [`docs/ops/README.md`](./docs/ops/README.md)
31
32 ## 当前目录结构
33
34@@ -42,11 +41,13 @@ ops/
35 launchd/
36 nginx/
37 sql/
38+scripts/
39+ ops/
40+ runtime/
41 coordination/
42 STATUS_SUMMARY.md
43 TASK_OVERVIEW.md
44 WORKFLOW.md
45- tasks/
46 docs/
47 auth/
48 decisions/
49@@ -57,32 +58,18 @@ docs/
50
51 ## 当前约定
52
53-- 当前只保留 `mini` 的 conductor / status-api / 自启动路径
54-- 不再继续推进 `mac` 备主与主备切换
55-- 一个任务一个分支
56-- 一个任务一个 worktree
57-- 一个任务一个任务卡
58-- 任务真相靠任务卡与 D1,不靠聊天记录
59-- 各 Codex 只更新自己的任务卡
60-- 汇总状态由整合者更新到 `coordination/STATUS_SUMMARY.md`
61-
62-## 现在可以做什么
63-
64-当前最适合的工作方式:
65-
66-1. 由整合者分配 `coordination/tasks/` 里的任务
67-2. 每个 Codex 建自己的分支与 worktree
68-3. 在声明好的 `write_scope` 内开发
69-4. 完成后更新任务卡中的状态、命令、风险和交付物
70-5. 由整合者统一汇总和集成
71+- 当前只维护 `mini` 单节点运行路径
72+- 运行中的浏览器插件代码以 [`plugins/baa-firefox`](./plugins/baa-firefox) 为准
73+- 历史主备资料不再在主线保留,靠 tag 回溯
74+- 当前仓库优先做运维、修补和最小必要的控制面维护
75
76-## 非目标
77+## 当前最重要的事
78
79-当前骨架阶段不追求:
80+- 保持 `mini` launchd、自启动和本地探针稳定
81+- 保持 `control-api.makefile.so` 与 `conductor.makefile.so` 可用
82+- 继续收口 `status-api` 的真实状态来源
83
84-- 已完成的生产功能
85-- 完整测试覆盖
86-- 完整 control API
87-- 完整的 D1 接入
88+## 当前已知问题
89
90-这些都将在后续并行任务中完成。
91+- live `status-api` 仍可能和 control plane 真相漂移
92+- runtime 目录和部署路径还需要继续 canonicalize
+0,
-43
1@@ -1,43 +0,0 @@
2-# 第五波统一开工说明
3-
4-第五波任务统一要求如下:
5-
6-- 所有任务必须从当前 `origin/main` 最新提交切分支
7-- 每个任务必须使用独立 worktree
8-- 新 worktree 第一次进入后先执行 `npx --yes pnpm install`
9-- 不允许从其他任务分支继续切分支
10-- 开始时把任务卡状态改为 `in_progress`
11-- 完成并推送后把任务卡状态改为 `review`
12-
13-推荐命令模板:
14-
15-```bash
16-git fetch origin
17-git switch main
18-git pull --ff-only origin main
19-git worktree add ../baa-conductor-TXXX -b feat/T-XXX-name main
20-cd ../baa-conductor-TXXX
21-npx --yes pnpm install
22-```
23-
24-每个实例启动后按顺序读:
25-
26-1. `DESIGN.md`
27-2. `coordination/TASK_OVERVIEW.md`
28-3. `coordination/WORKFLOW.md`
29-4. `coordination/tasks/T-XXX-*.md`
30-
31-第五波的目标不是再补单点入口,而是把这些入口串起来,推进到真正可联调的阶段:
32-
33-- control-api 的本地 D1 / smoke
34-- 多服务端到端 smoke
35-- 主备 failover rehearsal
36-- `baa-firefox` 的实际接线
37-- 节点侧 launchd / on-node 验证
38-
39-注意:
40-
41-- 当前仍然采用“内网走 Tailscale `100.x`,外网走二级域名”
42-- 当前仍不依赖 MagicDNS 名称
43-- `README.md`、`DESIGN.md`、根配置文件仍视为热点文件,非任务必要不要碰
44-- `T-026` 在当时是跨仓库任务;当前 Firefox 插件代码已经收口到本仓库子目录 `plugins/baa-firefox/`
+0,
-43
1@@ -1,43 +0,0 @@
2-# 最后收口阶段开工说明
3-
4-当前不再继续按“大波次”拆分。
5-
6-后续工作聚焦两个真实环境任务:
7-
8-- `T-028` 真实 Cloudflare / VPS 上线执行
9-- `T-029` 多节点长时间稳定性回归
10-
11-统一要求:
12-
13-- 所有任务必须从当前 `origin/main` 最新提交切分支
14-- 每个任务必须使用独立 worktree
15-- 新 worktree 第一次进入后先执行 `npx --yes pnpm install`
16-- 不允许从其他任务分支继续切分支
17-- 开始时把任务卡状态改为 `in_progress`
18-- 完成并推送后把任务卡状态改为 `review`
19-
20-推荐命令模板:
21-
22-```bash
23-git fetch origin
24-git switch main
25-git pull --ff-only origin main
26-git worktree add ../baa-conductor-TXXX -b feat/T-XXX-name main
27-cd ../baa-conductor-TXXX
28-npx --yes pnpm install
29-```
30-
31-每个实例启动后按顺序读:
32-
33-1. `DESIGN.md`
34-2. `coordination/TASK_OVERVIEW.md`
35-3. `coordination/WORKFLOW.md`
36-4. `coordination/tasks/T-XXX-*.md`
37-
38-注意:
39-
40-- 当前仍然采用“内网走 Tailscale `100.x`,外网走二级域名”
41-- 当前仍不依赖 MagicDNS 名称
42-- `README.md`、`DESIGN.md`、根配置文件仍视为热点文件,非任务必要不要碰
43-- `T-028` 允许执行真实 Cloudflare / VPS / launchd 操作,但必须把实际变更、执行命令和结果写回任务卡
44-- `T-029` 默认基于 `T-028` 已完成的环境执行;如果 `T-028` 未结束,可先准备脚本和观察项,但不要伪造真实回归结果
+0,
-47
1@@ -1,47 +0,0 @@
2-# 第四波统一开工说明
3-
4-状态:
5-
6-- 第四波任务 `T-018` 到 `T-022` 已全部完成并合入 `main`
7-- 本文件保留作第四波启动记录
8-
9-第四波任务统一要求如下:
10-
11-- 所有任务必须从当前 `origin/main` 最新提交切分支
12-- 每个任务必须使用独立 worktree
13-- 新 worktree 第一次进入后先执行 `npx --yes pnpm install`
14-- 不允许从其他任务分支继续切分支
15-- 开始时把任务卡状态改为 `in_progress`
16-- 完成并推送后把任务卡状态改为 `review`
17-
18-推荐命令模板:
19-
20-```bash
21-git fetch origin
22-git switch main
23-git pull --ff-only origin main
24-git worktree add ../baa-conductor-TXXX -b feat/T-XXX-name main
25-cd ../baa-conductor-TXXX
26-npx --yes pnpm install
27-```
28-
29-每个实例启动后按顺序读:
30-
31-1. `DESIGN.md`
32-2. `coordination/TASK_OVERVIEW.md`
33-3. `coordination/WORKFLOW.md`
34-4. `coordination/tasks/T-XXX-*.md`
35-
36-第四波的目标不是继续补纯骨架,而是把当前主线推进到“可部署、可探活、可自动化运维”的阶段:
37-
38-- Cloudflare Worker / D1 绑定配置
39-- conductor 本地 HTTP 探活入口
40-- status-api 宿主进程
41-- launchd bootstrap 脚本
42-- Nginx 与 Cloudflare DNS 自动化
43-
44-注意:
45-
46-- 这一波仍然保持“内网走 Tailscale `100.x`,外网走二级域名”
47-- 这一波不依赖 MagicDNS 名称
48-- `README.md`、`DESIGN.md`、根配置文件默认仍视为热点文件,非任务必要不要碰
+0,
-34
1@@ -1,34 +0,0 @@
2-# 第二波统一开工说明
3-
4-这份说明已完成历史使命。
5-
6-第二波任务已经全部合入 `main`。
7-
8-后续如需继续并行开发,请基于新的 `main` 提交重新生成第三波任务卡与对应启动说明。
9-
10-第二波任务统一要求如下:
11-
12-- 所有任务必须从 `main@28829de` 切分支
13-- 每个任务使用独立 worktree
14-- 新 worktree 第一次进入后先执行 `npx --yes pnpm install`
15-- 不允许从其他任务分支继续切分支
16-- 开始时把任务卡状态改为 `in_progress`
17-- 完成并推送后把任务卡状态改为 `review`
18-
19-推荐命令模板:
20-
21-```bash
22-git fetch origin
23-git switch main
24-git pull --ff-only origin main
25-git worktree add ../baa-conductor-TXXX -b feat/T-XXX-name main
26-cd ../baa-conductor-TXXX
27-npx --yes pnpm install
28-```
29-
30-每个实例启动后按顺序读:
31-
32-1. `DESIGN.md`
33-2. `coordination/TASK_OVERVIEW.md`
34-3. `coordination/WORKFLOW.md`
35-4. `coordination/tasks/T-XXX-*.md`
+20,
-68
1@@ -1,81 +1,33 @@
2 # 全局状态汇总
3
4-由整合者维护。
5-
6 ## 当前时间
7
8 - `2026-03-22`
9
10-## 总览
11-
12-- `done`: 30
13-- `in_progress`: 0
14-- `blocked`: 0
15-- `todo`: 0
16-
17-## 已完成
18-
19-- `T-001` 仓库骨架
20-- `T-002` D1 Schema 与 Migrations
21-- `T-003` Control API Worker
22-- `T-004` Conductor Lease 与 Heartbeat
23-- `T-005` Worker Runner 与本地日志流
24-- `T-006` Checkpoint 与 Git Diff Snapshots
25-- `T-007` Planner 抽象与模板
26-- `T-008` Nginx 与 VPS 运维
27-- `T-009` Firefox 插件 Pause/Resume 协议
28-- `T-010` Status API 与基础 UI
29-- `T-011` launchd 与本地 Runtime 布局
30-- `T-012` 鉴权与 Token 模型
31-- `T-013` Build 与 dist 产物
32-- `T-014` Control API 运行时接线
33-- `T-015` Conductor 运行时接线
34-- `T-016` Worker 本地持久化
35-- `T-017` Status API 运行时入口
36-- `T-018` Cloudflare Worker 与 D1 部署配置
37-- `T-019` Conductor 本地 HTTP 入口
38-- `T-020` Status API 本地宿主进程
39-- `T-021` launchd 安装脚本与 Runtime Bootstrap
40-- `T-022` Nginx 与 Cloudflare DNS 自动化
41-- `T-023` Control API 本地 D1 与 smoke
42-- `T-024` 端到端 smoke harness
43-- `T-025` Failover rehearsal 与 Runbook
44-- `T-026` `baa-firefox` 实际接线
45-- `T-027` launchd 节点验证与 On-Node 检查
46-- `T-028A` T-028 Tailscale 监听解阻
47-- `T-028` 真实 Cloudflare / VPS 上线执行
48-- `T-029` 多节点长时间稳定性回归
49-
50-这些任务卡已归档到 `coordination/tasks/done/`。
51-
52-## 当前活动任务
53-
54-- 无
55-
56-## 下一步建议
57-
58-- 当前没有活动开发任务。
59-- 后续以 `mini` 单节点运维、观察和零散修复为主。
60+## 当前状态
61
62-## 需要整合者关注的点
63+- 部署目标:`mini` 单节点
64+- 当前没有活动开发任务
65+- 历史主备资料已从主线移除
66+- 回溯 tag:`ha-failover-archive-2026-03-22`
67
68-- `README.md`、`DESIGN.md`、根配置文件应避免多人同时修改
69-- `ops/launchd/**` 已合入,当前主线已经产出 app 级 `dist/index.js`
70-- `apps/conductor-daemon/package.json` 与 `apps/status-api/package.json` 的构建脚本已在第三波整合时收口
71-- Firefox 插件代码现已收口到本仓库子目录 `plugins/baa-firefox/`
72-- 当前推荐部署模式已改成 `mini` 单节点,不再继续推进主备切换
73+## 当前在线面
74
75-## 后续汇总规则
76+- `https://control-api.makefile.so`
77+- `https://conductor.makefile.so`
78+- `mini` 本地 conductor:`http://100.71.210.78:4317`
79+- `mini` 本地 status-api:`http://100.71.210.78:4318`
80
81-每次整合时,建议至少更新:
82+## 当前保留内容
83
84-- 已完成任务
85-- 当前进行中任务
86-- 阻塞原因
87-- 下一波建议分发
88+- Cloudflare Worker + D1 控制面
89+- `mini` 的 conductor / status-api / worker-runner
90+- launchd 安装与检查脚本
91+- Firefox 插件子目录 `plugins/baa-firefox`
92+- 单节点的 Nginx / DNS 计划脚本
93
94-当前阶段不要求记录具体实例名,只看:
95+## 当前仍需关注
96
97-- 哪个 task 已开始
98-- 哪个 task 已完成
99-- 哪个 task 被阻塞
100+- live `status-api` 可能和 control plane 真相漂移
101+- runtime 路径仍值得继续 canonicalize
102+- 若继续维护,只做小范围修补,不再恢复主备方案
+18,
-91
1@@ -1,103 +1,30 @@
2 # 任务总览
3
4-这是当前 `baa-conductor` 仓库的并行任务总览。
5+当前主线已经不再按波次推进,也不再保留历史任务板。
6
7-## 1. 当前仓库基线
8+## 当前状态
9
10-当前已经完成的基础工作:
11+- 没有活动开发任务
12+- 当前仓库只维护 `mini` 单节点路径
13+- 历史主备与并行开发资料已由 tag `ha-failover-archive-2026-03-22` 承担回溯职责
14
15-- 设计文档 [`../DESIGN.md`](../DESIGN.md) 已落地
16-- 仓库目录骨架已建立
17-- `apps/`、`packages/`、`ops/`、`coordination/` 基础占位代码与文件已创建
18+## 当前建议
19
20-这意味着:
21+如果还要继续维护这个仓库,优先顺序只有三件事:
22
23-- `T-001` 视为已完成
24-- 目前 `T-001` 到 `T-029` 以及 `T-028A` 都已完成并合入 `main`
25-- 其他任务可以在当前骨架上并行开展
26-- 当前阶段不强制记录实例名,只要求各任务卡状态及时更新
27+1. 修 `status-api` 与 control plane 真相不一致的问题
28+2. 收口 `mini` 的 runtime 路径和自启动安装路径
29+3. 只在必要时做最小运维修补
30
31-## 2. 启动前必读
32-
33-每个 Codex 实例必须先读:
34+## 现在该读什么
35
36 1. [`../DESIGN.md`](../DESIGN.md)
37-2. [`WORKFLOW.md`](./WORKFLOW.md)
38-3. 自己的活动任务卡
39-
40-## 3. 已完成的第一波任务
41-
42-下面这些任务已经完成并合入 `main`:
43-
44-- `T-002` D1 Schema 与 Migrations
45-- `T-005` Worker Runner 与本地日志流
46-- `T-007` Planner 抽象与模板
47-- `T-008` Nginx 与 VPS 运维
48-- `T-011` launchd 与本地 Runtime 布局
49-- `T-012` 鉴权与 Token 模型
50-
51-这些内容现在已经是第二波任务的基线。
52-
53-对应任务卡已归档到:
54-
55-- [`tasks/done/`](./tasks/done/README.md)
56-
57-## 4. 当前状态
58-
59-当前主线任务已经全部完成,现阶段没有活动任务。
60-
61-最后收口阶段要求见:
62-
63-- [`FINAL_STAGE_START.md`](./FINAL_STAGE_START.md)
64-
65-## 5. 下一步建议
66-
67-当前优先做的是日常运维与零散修复,不再继续按波次拆大任务。
68-
69-建议重点关注:
70-
71-- live `status-api` 持续漂移问题
72-- `mini` runtime 路径 canonicalization
73-- 把运维文档和脚本继续收口到 `mini` 单节点模式
74-
75-## 6. 已归档任务
76-
77-- `T-001` 仓库骨架
78-- `T-002` D1 Schema 与 Migrations
79-- `T-003` Control API Worker
80-- `T-004` Conductor Lease 与 Heartbeat
81-- `T-005` Worker Runner 与本地日志流
82-- `T-006` Checkpoint 与 Git Diff Snapshots
83-- `T-007` Planner 抽象与模板
84-- `T-008` Nginx 与 VPS 运维
85-- `T-009` Firefox 插件 Pause/Resume 协议
86-- `T-010` Status API 与基础 UI
87-- `T-011` launchd 与本地 Runtime 布局
88-- `T-012` 鉴权与 Token 模型
89-- `T-013` Build 与 dist 产物
90-- `T-014` Control API 运行时接线
91-- `T-015` Conductor 运行时接线
92-- `T-016` Worker 本地持久化
93-- `T-017` Status API 运行时入口
94-- `T-018` Cloudflare Worker 与 D1 部署配置
95-- `T-019` Conductor 本地 HTTP 入口
96-- `T-020` Status API 本地宿主进程
97-- `T-021` launchd 安装脚本与 Runtime Bootstrap
98-- `T-022` Nginx 与 Cloudflare DNS 自动化
99-- `T-023` Control API 本地 D1 与 smoke
100-- `T-024` 端到端 smoke harness
101-- `T-025` Failover rehearsal 与 Runbook
102-- `T-026` `baa-firefox` 实际接线
103-- `T-027` launchd 节点验证与 On-Node 检查
104-- `T-028A` T-028 Tailscale 监听解阻
105-- `T-028` 真实 Cloudflare / VPS 上线执行
106-- `T-029` 多节点长时间稳定性回归
107+2. [`../docs/runtime/README.md`](../docs/runtime/README.md)
108+3. [`../docs/ops/README.md`](../docs/ops/README.md)
109+4. [`STATUS_SUMMARY.md`](./STATUS_SUMMARY.md)
110
111-## 7. 汇总方式
112+## 如需新任务
113
114-- 每个任务的详细状态在对应任务卡中
115-- 全局汇总在 [`STATUS_SUMMARY.md`](./STATUS_SUMMARY.md)
116-- 如需新任务,按现有模板新增到 `coordination/tasks/`
117-- 开始做任务时,Codex 自己把任务卡改成 `in_progress`
118-- 做完并推送后,Codex 自己把任务卡改成 `review` 或 `done`
119-- 已合入 `main` 的任务卡会被移到 `coordination/tasks/done/`
120+- 直接在 `coordination/tasks/` 新建任务卡
121+- 不再恢复旧 wave 文档
122+- 历史任务内容靠 tag 回溯,不在当前主线保留
+0,
-41
1@@ -1,41 +0,0 @@
2-# 第三波统一开工说明
3-
4-状态:
5-
6-- 第三波任务 `T-013` 到 `T-017` 已全部完成并合入 `main`
7-- 本文件保留作第三波启动记录
8-
9-第三波任务统一要求如下:
10-
11-- 所有任务必须从当前 `origin/main` 最新提交切分支
12-- 每个任务必须使用独立 worktree
13-- 新 worktree 第一次进入后先执行 `npx --yes pnpm install`
14-- 不允许从其他任务分支继续切分支
15-- 开始时把任务卡状态改为 `in_progress`
16-- 完成并推送后把任务卡状态改为 `review`
17-
18-推荐命令模板:
19-
20-```bash
21-git fetch origin
22-git switch main
23-git pull --ff-only origin main
24-git worktree add ../baa-conductor-TXXX -b feat/T-XXX-name main
25-cd ../baa-conductor-TXXX
26-npx --yes pnpm install
27-```
28-
29-每个实例启动后按顺序读:
30-
31-1. `DESIGN.md`
32-2. `coordination/TASK_OVERVIEW.md`
33-3. `coordination/WORKFLOW.md`
34-4. `coordination/tasks/T-XXX-*.md`
35-
36-第三波的目标不是继续铺骨架,而是把关键运行链路接实:
37-
38-- 真实 `build` 产物
39-- control-api 运行时接线
40-- conductor 运行时接线
41-- worker 本地持久化
42-- status API 真正可挂载的入口
+3,
-3
1@@ -1,6 +1,6 @@
2 # 协作工作流
3
4-这份文档定义多个 Codex 实例同时开发时的最小协作规则。
5+这份文档保留最小协作规则,供以后需要临时并行开发时复用。
6
7 ## 1. 每个实例启动后要读什么
8
9@@ -8,7 +8,7 @@
10
11 1. [`../DESIGN.md`](../DESIGN.md)
12 2. [`TASK_OVERVIEW.md`](./TASK_OVERVIEW.md)
13-3. 自己的活动任务卡,例如 `tasks/T-004-conductor-lease.md`
14+3. 自己的活动任务卡
15
16 ## 2. 每个实例开始前要做什么
17
18@@ -51,7 +51,7 @@
19 - 各个 worker 只更新自己的任务卡
20 - 全局状态板由整合者更新
21 - 如果出现冲突,以任务卡和代码为准
22-- 已合入 `main` 的任务卡会被移到 `coordination/tasks/done/`
23+- 当前主线不再保留历史任务归档;需要历史上下文时,直接查 git tag
24
25 ## 6. 推荐的分支与 worktree 模式
26
+0,
-14
1@@ -1,14 +0,0 @@
2-# 已完成任务归档
3-
4-这个目录用于存放已经合入 `main` 的任务卡。
5-
6-规则:
7-
8-- 已完成并合入 `main` 的任务卡从 `coordination/tasks/` 移到这里
9-- 活动任务区只保留:
10- - 未开始任务
11- - 进行中任务
12- - 阻塞任务
13- - 等待 review 的任务
14-
15-当前已归档的是第一波、第二波、第三波、第四波与第五波任务。
1@@ -1,87 +0,0 @@
2----
3-task_id: T-001
4-title: 仓库骨架
5-status: done
6-branch: feat/T-001-repo-scaffold
7-repo: /Users/george/code/baa-conductor
8-base_ref: main
9-depends_on: []
10-write_scope:
11- - .gitignore
12- - README.md
13- - package.json
14- - pnpm-workspace.yaml
15- - tsconfig.base.json
16- - apps/**
17- - packages/**
18- - ops/**
19- - coordination/**
20- - docs/**
21-updated_at: 2026-03-21
22----
23-
24-# T-001 仓库骨架
25-
26-## 目标
27-
28-建立 `baa-conductor` 的基础目录、根配置、占位代码与协作文档结构,让后续多个任务可以并行展开。
29-
30-## 本任务包含
31-
32-- 创建根级工作区配置
33-- 创建 `apps/`、`packages/`、`ops/`、`docs/`、`coordination/` 基础目录
34-- 创建最小占位代码文件
35-- 创建任务总览与协作规则
36-
37-## 本任务不包含
38-
39-- 真实 D1 逻辑
40-- 真实 conductor 调度逻辑
41-- 真实 worker 执行逻辑
42-- 真实 Nginx 部署配置
43-
44-## 建议起始文件
45-
46-- 已完成
47-
48-## 交付物
49-
50-- 初始仓库骨架
51-- 协作任务体系
52-
53-## 验收
54-
55-- 目录结构完整
56-- 协作文档可读
57-- 后续任务可以直接接着写
58-
59-## files_changed
60-
61-- 根配置文件
62-- `apps/**`
63-- `packages/**`
64-- `ops/**`
65-- `coordination/**`
66-- `docs/decisions/README.md`
67-
68-## commands_run
69-
70-- 未执行功能构建
71-
72-## result
73-
74-- 骨架已建立,可供并行任务继续开发
75-
76-## risks
77-
78-- 目前大部分代码是占位实现
79-
80-## next_handoff
81-
82-- 开始分发第一波并行任务
83-
84-## notes
85-
86-- `2026-03-21`: 初始化新仓库
87-- `2026-03-21`: 写入设计文档
88-- `2026-03-21`: 创建代码骨架与协作文档结构
1@@ -1,94 +0,0 @@
2----
3-task_id: T-002
4-title: D1 Schema 与 Migrations
5-status: done
6-branch: feat/T-002-d1-schema
7-repo: /Users/george/code/baa-conductor
8-base_ref: main
9-depends_on:
10- - T-001
11-write_scope:
12- - ops/sql/**
13- - packages/db/**
14-updated_at: 2026-03-21
15----
16-
17-# T-002 D1 Schema 与 Migrations
18-
19-## 目标
20-
21-把 `DESIGN.md` 第 10 节中的 D1 schema 真正落成可执行 SQL,并建立 migration 约定与基础数据库访问层。
22-
23-## 本任务包含
24-
25-- 完成 `ops/sql/schema.sql`
26-- 完成 `ops/sql/migrations/0001_init.sql`
27-- 在 `packages/db/` 建立最小数据库访问抽象
28-- 明确表、索引、初始化状态写法
29-
30-## 本任务不包含
31-
32-- Cloudflare Worker 路由逻辑
33-- conductor lease loop
34-- worker runtime
35-
36-## 建议起始文件
37-
38-- `ops/sql/schema.sql`
39-- `ops/sql/migrations/0001_init.sql`
40-- `packages/db/src/index.ts`
41-- `DESIGN.md`
42-
43-## 交付物
44-
45-- 完整 schema SQL
46-- 初始 migration
47-- `packages/db` 的最小接口和模型定义
48-
49-## 验收
50-
51-- 包含 `leader_lease`
52-- 包含 `controllers`
53-- 包含 `workers`
54-- 包含 `tasks`
55-- 包含 `task_steps`
56-- 包含 `task_runs`
57-- 包含 `task_checkpoints`
58-- 包含 `task_logs`
59-- 包含 `system_state`
60-- 包含 `task_artifacts`
61-
62-## files_changed
63-
64-- `coordination/tasks/T-002-d1-schema.md`
65-- `ops/sql/schema.sql`
66-- `ops/sql/migrations/0001_init.sql`
67-- `packages/db/src/index.ts`
68-
69-## commands_run
70-
71-- `sqlite3 ':memory:' < /Users/george/code/baa-conductor/ops/sql/schema.sql`
72-- `sqlite3 ':memory:' < /Users/george/code/baa-conductor/ops/sql/migrations/0001_init.sql`
73-- `sqlite3 ':memory:' ".read /Users/george/code/baa-conductor/ops/sql/schema.sql" "SELECT state_key || ':' || value_json FROM system_state;"`
74-- `/Users/george/code/baa-conductor/node_modules/.bin/tsc --noEmit -p /Users/george/code/baa-conductor/packages/db/tsconfig.json`
75-- `git diff --check -- /Users/george/code/baa-conductor/ops/sql/schema.sql /Users/george/code/baa-conductor/ops/sql/migrations/0001_init.sql /Users/george/code/baa-conductor/packages/db/src/index.ts /Users/george/code/baa-conductor/coordination/tasks/T-002-d1-schema.md`
76-
77-## result
78-
79-- 按 `DESIGN.md` 第 10 节完成了 10 张 D1 表的可执行 schema 与首个 migration,并补上基础外键、恢复/查询需要的索引、数值约束,以及 `system_state` 中默认的 `automation=running` 初始化行。
80-- `packages/db/src/index.ts` 现提供完整表模型、D1 兼容接口、snake_case 到 camelCase 的 row mapper、常用 SQL 语句,以及面向 control plane 的低层 repository 骨架。
81-
82-## risks
83-
84-- `leader_lease` 目前只提供低层 upsert 读写,真正的抢锁/CAS 续租语义仍需在 `T-004` 中基于当前 schema 实现。
85-- `controllers`、`workers`、`task_runs` 的状态值在设计文档中尚未完全收敛,因此当前模型保持 `string`,后续任务需要在业务层补齐约束或进一步收紧类型。
86-- 本次 SQL 只在本地 SQLite 语义下做了烟测;接入真实 Cloudflare D1 binding 后仍应补一次端到端迁移与读写验证。
87-
88-## next_handoff
89-
90-- `T-003` 可以直接复用 `D1ControlPlaneRepository` 的 `system_state`、controller/worker heartbeat、task/task_step/task_log 持久化骨架来接 control API。
91-- `T-004` 可以在当前 schema/index 基线上补 `leader_lease` 原子获取/续租逻辑,以及 runnable step claim 查询。
92-
93-## notes
94-
95-- `2026-03-21`: 创建任务卡
1@@ -1,91 +0,0 @@
2----
3-task_id: T-003
4-title: Control API Worker
5-status: done
6-branch: feat/T-003-control-api
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@56e9a4f
9-depends_on:
10- - T-002
11-write_scope:
12- - apps/control-api-worker/**
13-updated_at: 2026-03-21
14----
15-
16-# T-003 Control API Worker
17-
18-## 目标
19-
20-实现 Cloudflare Worker 风格的 control API 骨架,把设计中的关键接口落成代码结构。
21-
22-## 统一开工要求
23-
24-- 必须从 `main@56e9a4f` 切出该分支
25-- 新 worktree 进入后先执行 `npx --yes pnpm install`
26-- 不允许从其他任务分支切分支
27-
28-## 本任务包含
29-
30-- 明确路由注册方式
31-- 为核心接口建立 handler 骨架
32-- 整理 request/response schema
33-- 预留 D1 绑定与鉴权挂载点
34-
35-## 本任务不包含
36-
37-- 完整 lease 算法
38-- 真实 worker 调度
39-- 前端页面
40-
41-## 建议起始文件
42-
43-- `apps/control-api-worker/src/index.ts`
44-- `DESIGN.md` 第 11 节
45-
46-## 交付物
47-
48-- 可扩展的 Worker 路由骨架
49-- 核心接口清单与占位 handler
50-
51-## 验收
52-
53-- 至少包含 heartbeat、acquire、tasks、steps、system state 路由骨架
54-- 结构上便于后续接 D1
55-
56-## files_changed
57-
58-- `apps/control-api-worker/src/index.ts`
59-- `apps/control-api-worker/src/contracts.ts`
60-- `apps/control-api-worker/src/schemas.ts`
61-- `apps/control-api-worker/src/handlers.ts`
62-- `apps/control-api-worker/src/router.ts`
63-- `apps/control-api-worker/tsconfig.json`
64-- `coordination/tasks/T-003-control-api.md`
65-
66-## commands_run
67-
68-- `npx --yes pnpm install`
69-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
70-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
71-
72-## result
73-
74-- 已将 `control-api-worker` 重构为 Cloudflare Worker 风格入口,提供默认 `fetch` 导出、请求 ID、统一 JSON 错误包和基础 404/405/400 处理。
75-- 已注册设计第 11 节要求的核心路由骨架,覆盖 controller heartbeat、leader acquire、tasks、steps、system state,以及 task/log/run 查询接口。
76-- 已整理每条路由的 request/response schema 描述,并预留 D1 repository 注入与 Bearer token 鉴权挂载点,便于 `T-004` 与 `T-010` 继续接线。
77-
78-## risks
79-
80-- 所有业务 handler 目前仍返回 `501 not_implemented`,真实 lease、claim、checkpoint、task 读写逻辑尚未接入 D1。
81-- 当前只做 JSON 解析与授权挂点,没有做字段级 runtime 校验;后续接真实写路径时需要补请求校验或约束收口。
82-- 未注入 `tokenVerifier` 时会跳过实际鉴权,仅保留授权模型上下文;生产接入前必须补齐 verifier 装配。
83-
84-## next_handoff
85-
86-- `T-004` 可直接在现有 route registry 上接 leader/task/step 写路径,复用 ownership resolver、auth hook 和 repository 注入点。
87-- `T-010` 可直接复用 `GET /v1/system/state`、`GET /v1/tasks/:task_id`、`GET /v1/tasks/:task_id/logs`、`GET /v1/runs/:run_id` 的接口合同与统一响应结构。
88-
89-## notes
90-
91-- `2026-03-21`: 创建任务卡
92-- `2026-03-21`: 实际按指令从 `main@56e9a4f` 切出,任务卡已同步该基线。
1@@ -1,91 +0,0 @@
2----
3-task_id: T-004
4-title: Conductor Lease 与 Heartbeat
5-status: done
6-branch: feat/T-004-conductor-lease
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@56e9a4f
9-depends_on:
10- - T-002
11- - T-003
12-write_scope:
13- - apps/conductor-daemon/**
14- - packages/db/**
15-updated_at: 2026-03-21
16----
17-
18-# T-004 Conductor Lease 与 Heartbeat
19-
20-## 目标
21-
22-实现 conductor 的主备基础行为:注册、heartbeat、lease 获取与续租、standby 拒绝调度。
23-
24-## 统一开工要求
25-
26-- 必须从 `main@56e9a4f` 切出该分支
27-- 新 worktree 进入后先执行 `npx --yes pnpm install`
28-- 不允许从其他任务分支切分支
29-
30-## 本任务包含
31-
32-- conductor startup checklist 落代码
33-- leader/standby 状态骨架
34-- heartbeat loop 骨架
35-- acquire/renew lease 调用路径
36-
37-## 本任务不包含
38-
39-- 完整 step 调度
40-- worker 实际执行
41-- checkpoint 恢复
42-
43-## 建议起始文件
44-
45-- `apps/conductor-daemon/src/index.ts`
46-- `packages/db/src/index.ts`
47-- `DESIGN.md` 第 8、16 节
48-
49-## 交付物
50-
51-- conductor 基础状态机骨架
52-- leader 与 standby 的最小分支逻辑
53-
54-## 验收
55-
56-- 能表达 leader/standby/degraded
57-- 能区分 acquire 与 renew
58-- 结构上支持后续 scheduler 接入
59-
60-## files_changed
61-
62-- `apps/conductor-daemon/src/index.ts`
63-- `apps/conductor-daemon/src/index.test.js`
64-- `packages/db/src/index.ts`
65-- `packages/db/src/index.test.js`
66-
67-## commands_run
68-
69-- `npx --yes pnpm install`
70-- `npx --yes pnpm --filter @baa-conductor/db typecheck`
71-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
72-- `node --test --experimental-strip-types packages/db/src/index.test.js`
73-- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
74-
75-## result
76-
77-- `packages/db` 新增 controller heartbeat helper、lease acquire/renew 数据模型,以及基于 `leader_lease` 的最小抢占/续租仓储方法。
78-- `apps/conductor-daemon` 新增 startup checklist、control API client、heartbeat/lease loop、leader-only scheduler gate,并补了针对性的状态机测试。
79-
80-## risks
81-
82-- `apps/conductor-daemon` 当前内置了一份 lease/heartbeat payload 类型,后续适合和 `T-003` 的 control API 契约收敛到共享定义。
83-- 当前 lease 时间字段按 epoch seconds 处理,后续接入真实 control API 时需要和响应约定保持一致。
84-
85-## next_handoff
86-
87-- `T-003` 可直接把 `/v1/controllers/heartbeat` 与 `/v1/leader/acquire` 接到 `packages/db` 新增的 helper 与仓储方法。
88-- 后续 scheduler 与 status API 可直接复用 `ConductorDaemon.getStatusSnapshot()`、`canSchedule()`、`runSchedulerPass()` 作为主备门禁基线。
89-
90-## notes
91-
92-- `2026-03-21`: 创建任务卡
1@@ -1,88 +0,0 @@
2----
3-task_id: T-005
4-title: Worker Runner 与本地日志流
5-status: done
6-branch: feat/T-005-worker-runner
7-repo: /Users/george/code/baa-conductor
8-base_ref: main
9-depends_on:
10- - T-001
11-write_scope:
12- - apps/worker-runner/**
13- - packages/logging/**
14-updated_at: 2026-03-21T19:41:39+0800
15----
16-
17-# T-005 Worker Runner 与本地日志流
18-
19-## 目标
20-
21-建立 worker-runner 的最小执行框架,使其能围绕一个 step 管理本地日志、结果结构和执行骨架。
22-
23-## 本任务包含
24-
25-- `worker-runner` 的 step request/result 结构
26-- 本地 stdout/stderr/worker.log 组织方式
27-- logging 包中的基础数据结构
28-
29-## 本任务不包含
30-
31-- checkpoint diff 逻辑
32-- 真实 Codex 集成细节
33-- control API 上报
34-
35-## 建议起始文件
36-
37-- `apps/worker-runner/src/index.ts`
38-- `packages/logging/src/index.ts`
39-- `DESIGN.md` 第 17、19 节
40-
41-## 交付物
42-
43-- worker-runner 框架骨架
44-- logging 包基础结构
45-
46-## 验收
47-
48-- 能表达 step request/result
49-- 能表达本地日志目录与事件模型
50-
51-## files_changed
52-
53-- `apps/worker-runner/src/index.ts`
54-- `apps/worker-runner/src/contracts.ts`
55-- `apps/worker-runner/src/runner.ts`
56-- `apps/worker-runner/tsconfig.json`
57-- `packages/logging/src/index.ts`
58-- `packages/logging/src/contracts.ts`
59-- `packages/logging/src/paths.ts`
60-- `packages/logging/src/session.ts`
61-- `packages/logging/src/state.ts`
62-- `coordination/tasks/T-005-worker-runner.md`
63-
64-## commands_run
65-
66-- `npx -p typescript tsc --version`
67-- `npx -p typescript tsc --noEmit -p /Users/george/code/baa-conductor-t005/packages/logging/tsconfig.json`
68-- `npx -p typescript tsc --noEmit -p /Users/george/code/baa-conductor-t005/apps/worker-runner/tsconfig.json`
69-- `git -C /Users/george/code/baa-conductor-t005 diff --check`
70-
71-## result
72-
73-- 建立了 `packages/logging` 的本地 run 路径约定、`meta/state` 数据结构、生命周期事件模型,以及 `worker.log/stdout.log/stderr.log` 的内存态抽象。
74-- 建立了 `apps/worker-runner` 的 step request/result、prepared run、默认占位 executor 与生命周期编排,明确区分 `prepared/completed/failed/blocked` 结果。
75-- 为 `T-006` 预留了 checkpoint 序号与目录接入点,但没有实现 checkpoint diff 或任何 checkpoint 内容落盘。
76-
77-## risks
78-
79-- 当前实现只定义路径、事件和内存态会话;还没有真实文件写入,因此 `meta.json`、`state.json`、各类 log 仍未持久化。
80-- 当前 executor 仍是占位实现,没有接真实 Codex、Shell 或 Git worker,后续接入时还需要补真实进程生命周期与输出采集。
81-
82-## next_handoff
83-
84-- `T-006` 可以直接基于 `PreparedStepRun`、`StepCheckpointState` 和 `logPaths` 接入 checkpoint 目录写入与恢复逻辑。
85-- 后续 worker 执行接入时应复用当前 `StepExecutionResult` 和 logging session 结构,把真实 stdout/stderr/worker 事件落到同一套路径与结果模型。
86-
87-## notes
88-
89-- `2026-03-21`: 创建任务卡
1@@ -1,91 +0,0 @@
2----
3-task_id: T-006
4-title: Checkpoint 与 Git Diff Snapshots
5-status: done
6-branch: feat/T-006-checkpointing
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@56e9a4f
9-depends_on:
10- - T-005
11-write_scope:
12- - packages/checkpointing/**
13- - apps/worker-runner/**
14-updated_at: 2026-03-21
15----
16-
17-# T-006 Checkpoint 与 Git Diff Snapshots
18-
19-## 目标
20-
21-把设计中的 checkpoint 概念落到代码结构里,尤其是 `summary`、`git_diff`、`log_tail` 这些中途恢复能力。
22-
23-## 统一开工要求
24-
25-- 必须从 `main@56e9a4f` 切出该分支
26-- 新 worktree 进入后先执行 `npx --yes pnpm install`
27-- 不允许从其他任务分支切分支
28-
29-## 本任务包含
30-
31-- checkpoint 类型定义
32-- checkpoint 文件命名
33-- worker-runner 的 checkpoint 接口预留
34-- git diff checkpoint 的骨架接口
35-
36-## 本任务不包含
37-
38-- 完整 Git 命令执行器
39-- 完整 D1 上报细节
40-
41-## 建议起始文件
42-
43-- `packages/checkpointing/src/index.ts`
44-- `apps/worker-runner/src/index.ts`
45-- `DESIGN.md` 第 20 节
46-
47-## 交付物
48-
49-- checkpoint 数据结构与最小接口
50-- worker-runner 中的 checkpoint 接口位置
51-
52-## 验收
53-
54-- 至少支持 `summary`、`git_diff`、`log_tail`
55-- 结构上支持未来的 patch 回放
56-
57-## files_changed
58-
59-- `packages/checkpointing/src/index.ts`
60-- `apps/worker-runner/src/contracts.ts`
61-- `apps/worker-runner/src/checkpoints.ts`
62-- `apps/worker-runner/src/runner.ts`
63-- `apps/worker-runner/src/index.ts`
64-- `apps/worker-runner/tsconfig.json`
65-- `coordination/tasks/T-006-checkpointing.md`
66-
67-## commands_run
68-
69-- `npx --yes pnpm install`
70-- `npx --yes pnpm --filter @baa-conductor/checkpointing typecheck`
71-- `npx --yes pnpm --filter @baa-conductor/worker-runner typecheck`
72-- `npx --yes pnpm typecheck`
73-
74-## result
75-
76-- 已实现 `summary`、`git_diff`、`log_tail` 的 checkpoint 类型、序号管理、文件命名约定与渲染接口。
77-- 已为 `git_diff` 增加快照命令计划与未来 `git apply --binary` 回放提示,但未接入真实 Git 执行。
78-- `worker-runner` 现已默认初始化 checkpoint manager,准备阶段写入 `summary` checkpoint,结束阶段生成 `log_tail` checkpoint,并把状态/计划暴露给后续 executor 与恢复流程。
79-
80-## risks
81-
82-- 当前实现只落到类型、计划和内存态记录,尚未把 checkpoint 文件真正写入本地目录。
83-- `git_diff` 仍是骨架接口;真实 diff 捕获、patch 持久化与 D1 上报需要后续任务接入。
84-
85-## next_handoff
86-
87-- 把 checkpoint manager 接到真实 worker executor、Git 命令执行与本地/D1 持久化链路,为 failover 恢复提供可回放 diff。
88-
89-## notes
90-
91-- `2026-03-21`: 创建任务卡
92-- `2026-03-21`: 按最新协调要求将基线修正为 `main@56e9a4f`
+0,
-79
1@@ -1,79 +0,0 @@
2----
3-task_id: T-007
4-title: Planner 抽象与模板
5-status: done
6-branch: feat/T-007-planner
7-repo: /Users/george/code/baa-conductor
8-base_ref: main
9-depends_on:
10- - T-001
11-write_scope:
12- - packages/planner/**
13- - packages/step-templates/**
14-updated_at: 2026-03-21
15----
16-
17-# T-007 Planner 抽象与模板
18-
19-## 目标
20-
21-把 `planner` 作为抽象角色落到代码骨架里,并把设计中的模板任务拆分成可复用的 step template。
22-
23-## 本任务包含
24-
25-- `Planner` 接口整理
26-- `ProposedPlan` 与 `ProposedStep` 数据结构
27-- `feature_impl`、`bugfix` 等模板的完善
28-
29-## 本任务不包含
30-
31-- 调真实 Claude 或 Codex
32-- conductor 的最终 plan 验收逻辑
33-
34-## 建议起始文件
35-
36-- `packages/planner/src/index.ts`
37-- `packages/step-templates/src/index.ts`
38-- `DESIGN.md` 第 14、15 节
39-
40-## 交付物
41-
42-- planner 抽象层
43-- step template 定义
44-
45-## 验收
46-
47-- 模板命名清晰
48-- step kind 与设计文档一致
49-- 后续可直接被 conductor 调用
50-
51-## files_changed
52-
53-- `coordination/tasks/T-007-planner.md`
54-- `packages/planner/src/index.ts`
55-- `packages/step-templates/src/index.ts`
56-
57-## commands_run
58-
59-- `./node_modules/.bin/tsc --noEmit -p packages/planner/tsconfig.json`
60-- `./node_modules/.bin/tsc --noEmit -p packages/step-templates/tsconfig.json`
61-
62-## result
63-
64-- 补齐了 `planner` 抽象:策略、provider kind、step status、planner 输入上下文、`ProposedPlan` / `ProposedStep`、risk flags 建议值与 plan 校验 helper。
65-- 把常见任务模板整理成稳定数据结构,覆盖 `feature_impl`、`bugfix`、`review_only`、`ops_change`、`infra_bootstrap`。
66-- 提供了模板查询与 `buildTemplatePlan` helper,后续 conductor 可以直接据此产出结构化 plan。
67-
68-## risks
69-
70-- `review_only`、`ops_change`、`infra_bootstrap` 的 step kind 与输入 payload 需要靠设计文本推断,后续若设计补充更细契约,可能要同步收窄。
71-- conductor 侧还未接入这些类型与模板;真正落计划验收时,需要决定不同 task 何时走 `template_first`、何时走 `planner_assisted`。
72-
73-## next_handoff
74-
75-- conductor 可直接消费 `packages/planner` 的类型与 `validateProposedPlan`,并从 `packages/step-templates` 读取模板或调用 `buildTemplatePlan`。
76-
77-## notes
78-
79-- `2026-03-21`: 创建任务卡
80-- `2026-03-21`: 完成 planner 抽象与模板实现,等待 review
1@@ -1,91 +0,0 @@
2----
3-task_id: T-008
4-title: Nginx 与 VPS 运维
5-status: done
6-branch: feat/T-008-ops-nginx
7-repo: /Users/george/code/baa-conductor
8-base_ref: main
9-depends_on:
10- - T-001
11-write_scope:
12- - ops/nginx/**
13- - docs/ops/**
14-updated_at: 2026-03-21
15----
16-
17-# T-008 Nginx 与 VPS 运维
18-
19-## 目标
20-
21-把设计里的二级域名、Nginx 转发、直连节点域名、Basic Auth 与运维说明落到可执行配置和文档中。
22-
23-## 本任务包含
24-
25-- 完成 `ops/nginx/baa-conductor.conf`
26-- 补充 `includes/` 公共片段
27-- 编写 VPS 部署说明
28-- 明确 `conductor.makefile.so`、`mini-conductor.makefile.so`、`mac-conductor.makefile.so` 的配置关系
29-
30-## 本任务不包含
31-
32-- Cloudflare Worker 代码
33-- launchd
34-
35-## 建议起始文件
36-
37-- `ops/nginx/baa-conductor.conf`
38-- `ops/nginx/includes/common-proxy.conf`
39-- `ops/nginx/includes/direct-node-auth.conf`
40-- `docs/ops/README.md`
41-- `DESIGN.md` 第 6、7 节
42-
43-## 交付物
44-
45-- 可执行或接近可执行的 Nginx 配置
46-- VPS 运维说明
47-
48-## 验收
49-
50-- 包含统一入口
51-- 包含直达 mini/mac
52-- 包含 80 -> 443
53-- 包含鉴权或保护建议
54-
55-## files_changed
56-
57-- `coordination/tasks/T-008-ops-nginx.md`
58-- `docs/ops/README.md`
59-- `ops/nginx/baa-conductor.conf`
60-- `ops/nginx/includes/common-proxy.conf`
61-- `ops/nginx/includes/direct-node-auth.conf`
62-
63-## commands_run
64-
65-- `git worktree add ../baa-conductor-T008 -b feat/T-008-ops-nginx main`
66-- `command -v nginx`
67-- `git diff --check -- ops/nginx docs/ops coordination/tasks/T-008-ops-nginx.md`
68-- `rg -n "upstream conductor_primary|upstream mini_conductor_direct|upstream mac_conductor_direct|return 301 https://\\$host\\$request_uri|server_name conductor\\.makefile\\.so|server_name mini-conductor\\.makefile\\.so|server_name mac-conductor\\.makefile\\.so|ssl_certificate|auth_basic" ops/nginx/baa-conductor.conf ops/nginx/includes/direct-node-auth.conf`
69-- `rg -n "conductor\\.makefile\\.so|mini-conductor\\.makefile\\.so|mac-conductor\\.makefile\\.so|nginx -t|htpasswd|80/tcp|443/tcp|4317" docs/ops/README.md`
70-
71-## result
72-
73-- `ops/nginx/baa-conductor.conf` 已补齐 3 个 upstream、统一 `80 -> 443` 跳转、3 个 TLS server block,以及统一入口与直连 mini/mac 的转发关系
74-- `ops/nginx/includes/common-proxy.conf` 已补齐常用代理头、超时、request id、buffer 和 retry 配置
75-- `ops/nginx/includes/direct-node-auth.conf` 已默认启用 Basic Auth,并补充可选 IP allowlist 的使用说明
76-- `docs/ops/README.md` 已写明 VPS 部署、目录映射、证书准备、启用步骤、上线验证与保护建议
77-
78-## risks
79-
80-- 本机未安装 `nginx`,本次只完成静态合理性检查;上线前仍需在 VPS 上以真实证书和真实路径执行 `sudo nginx -t`
81-- `100.71.210.78`、`100.112.239.13` 与 `4317` 端口来自当前设计文档,真实部署前应再次确认 Tailscale 地址和端口未变化
82-- 若三域名经过 Cloudflare 代理,直连域名的 IP allowlist 需要先配置真实客户端 IP 恢复,否则 `allow/deny` 会基于 Cloudflare 出口 IP 生效
83-
84-## next_handoff
85-
86-- 把仓库中的 `ops/nginx/**` 同步到 VPS 的 `/etc/nginx/sites-available/` 与 `/etc/nginx/includes/baa-conductor/`
87-- 准备 `.htpasswd` 与 TLS 证书后,在 VPS 上执行 `sudo nginx -t && sudo systemctl reload nginx`
88-- 用 `curl` 验证统一入口的 `301/200`,以及 `mini-conductor.makefile.so`、`mac-conductor.makefile.so` 的 `401/200`
89-
90-## notes
91-
92-- `2026-03-21`: 创建任务卡
1@@ -1,81 +0,0 @@
2----
3-task_id: T-009
4-title: Firefox 插件 Pause 与 Resume 协议
5-status: done
6-branch: feat/T-009-firefox-pause
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@56e9a4f
9-depends_on:
10- - T-001
11-write_scope:
12- - docs/firefox/**
13-updated_at: 2026-03-21
14----
15-
16-# T-009 Firefox 插件 Pause 与 Resume 协议
17-
18-## 目标
19-
20-先在本仓库中明确 Firefox 插件与 conductor control API 之间的协议,方便后续在插件仓库同步实现。
21-
22-## 统一开工要求
23-
24-- 必须从 `main@56e9a4f` 切出该分支
25-- 新 worktree 进入后先执行 `npx --yes pnpm install`
26-- 不允许从其他任务分支切分支
27-
28-## 本任务包含
29-
30-- 定义浏览器按钮行为
31-- 定义状态读取字段
32-- 定义 `pause`、`resume`、`drain` 的请求格式
33-- 定义可见 `control` 与隐藏 `dispatch` 的职责边界
34-
35-## 本任务不包含
36-
37-- 直接修改 Firefox 插件仓库代码
38-- 完整 UI 实现
39-
40-## 建议起始文件
41-
42-- `DESIGN.md` 第 22、23 节
43-- `docs/firefox/README.md`
44-
45-## 交付物
46-
47-- 一份清晰的浏览器协议说明文档
48-
49-## 验收
50-
51-- 能明确告诉插件仓库该怎么接 control API
52-- 能明确区分 control 与 dispatch
53-
54-## files_changed
55-
56-- `coordination/tasks/T-009-firefox-pause.md`
57-- `docs/firefox/README.md`
58-
59-## commands_run
60-
61-- `git worktree add /Users/george/code/baa-conductor-T009 -b feat/T-009-firefox-pause main`
62-- `npx --yes pnpm install`
63-- `rg -n "Firefox|pause|resume|dispatch|control" DESIGN.md docs/firefox/README.md`
64-- `git diff --stat`
65-
66-## result
67-
68-- 已在 `docs/firefox/README.md` 定义 Firefox 插件与 control API 的协议,包括状态读取字段、`pause/resume/drain` 请求体、按钮启用规则、成功/失败响应,以及 `control` 与 `dispatch` 的职责边界。
69-- 已把任务卡基线修正为本次实际要求的 `main@56e9a4f`。
70-
71-## risks
72-
73-- `docs/firefox/README.md` 当前定义的是协议契约,仍需要后续 `T-003` / `baa-firefox` 实现方按该契约落地接口与 UI。
74-- `coordination/SECOND_WAVE_START.md` 在当前仓库树中不存在;本任务实际依据 `DESIGN.md`、`TASK_OVERVIEW.md`、`WORKFLOW.md` 与任务卡执行。
75-
76-## next_handoff
77-
78-- `baa-firefox` 仓库按 `docs/firefox/README.md` 接入 control API,并与 `T-003` 对齐返回字段和错误码。
79-
80-## notes
81-
82-- `2026-03-21`: 创建任务卡
1@@ -1,87 +0,0 @@
2----
3-task_id: T-010
4-title: Status API 与基础 UI
5-status: done
6-branch: feat/T-010-status-api
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@56e9a4f
9-depends_on:
10- - T-003
11- - T-004
12-write_scope:
13- - apps/status-api/**
14-updated_at: 2026-03-21
15----
16-
17-# T-010 Status API 与基础 UI
18-
19-## 目标
20-
21-实现最小状态读取层,让人和浏览器能读取 leader、mode、queue depth、active runs 等关键信息。
22-
23-## 统一开工要求
24-
25-- 必须从 `main@56e9a4f` 切出该分支
26-- 新 worktree 进入后先执行 `npx --yes pnpm install`
27-- 不允许从其他任务分支切分支
28-
29-## 本任务包含
30-
31-- status snapshot 结构完善
32-- 状态读取接口骨架
33-- 基础 UI 或最小渲染接口预留
34-
35-## 本任务不包含
36-
37-- 完整管理后台
38-- 真实图表
39-
40-## 建议起始文件
41-
42-- `apps/status-api/src/index.ts`
43-- `DESIGN.md` 第 23、29 节
44-
45-## 交付物
46-
47-- status API 骨架
48-- 最小状态结构
49-
50-## 验收
51-
52-- 至少能表达 mode、leader、queue depth、active runs
53-
54-## files_changed
55-
56-- `apps/status-api/src/contracts.ts`
57-- `apps/status-api/src/data-source.ts`
58-- `apps/status-api/src/render.ts`
59-- `apps/status-api/src/service.ts`
60-- `apps/status-api/src/index.ts`
61-- `apps/status-api/tsconfig.json`
62-- `coordination/tasks/T-010-status-api.md`
63-
64-## commands_run
65-
66-- `npx --yes pnpm install`
67-- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
68-- `npx --yes tsx -e '...'`
69-
70-## result
71-
72-- 已实现最小 status snapshot 契约,包含 `mode`、`leader`、`queueDepth`、`activeRuns`、lease 元数据和观测时间。
73-- 已实现基于 D1 的只读快照加载器,读取 automation state、leader lease、queued task 数、active run 数。
74-- 已实现最小状态 API/渲染骨架,支持 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui` 与 `/` HTML 面板。
75-
76-## risks
77-
78-- 目前只交付包内 handler 与渲染层,尚未绑定真实 HTTP server;后续需要由整合者或依赖任务接入运行时。
79-- `queueDepth` 当前按 `tasks.status = 'queued'` 统计,`activeRuns` 按 `task_runs.started_at IS NOT NULL AND finished_at IS NULL` 统计;如果后续运行态枚举收敛,需要一起校准。
80-
81-## next_handoff
82-
83-- 将 `D1StatusSnapshotLoader` 接到真实 `D1DatabaseLike`,再把 `createStatusApiHandler()` 挂到 status-api 运行时或 control/browse 面板入口。
84-
85-## notes
86-
87-- `2026-03-21`: 创建任务卡
88-- `2026-03-21`: 从 `main@56e9a4f` 建立独立 worktree,完成状态 API 与最小 HTML 面板骨架并进入 review。
1@@ -1,84 +0,0 @@
2----
3-task_id: T-011
4-title: launchd 与本地 Runtime 布局
5-status: done
6-branch: feat/T-011-launchd-runtime
7-repo: /Users/george/code/baa-conductor
8-base_ref: main
9-depends_on:
10- - T-001
11-write_scope:
12- - ops/launchd/**
13- - docs/runtime/**
14-updated_at: 2026-03-21T19:41:24+0800
15----
16-
17-# T-011 launchd 与本地 Runtime 布局
18-
19-## 目标
20-
21-把 mini 与 mac 上的 launchd 配置、运行目录创建方式、环境变量与安装步骤整理清楚。
22-
23-## 本任务包含
24-
25-- 完善 `ops/launchd/*.plist`
26-- 说明 `~/Library/LaunchAgents` 与 `/Library/LaunchDaemons` 的差异
27-- 说明 `runs/`、`worktrees/`、`logs/`、`tmp/` 的初始化方式
28-
29-## 本任务不包含
30-
31-- 真实业务逻辑
32-- D1 schema
33-
34-## 建议起始文件
35-
36-- `ops/launchd/*.plist`
37-- `docs/runtime/README.md`
38-- `DESIGN.md` 第 25、27 节
39-
40-## 交付物
41-
42-- launchd 配置模板
43-- 本地 runtime 初始化说明
44-
45-## 验收
46-
47-- 能明确 mini 与 mac 分别怎么安装
48-- 能明确需要哪些环境变量
49-
50-## files_changed
51-
52-- `coordination/tasks/T-011-launchd-runtime.md`
53-- `docs/runtime/README.md`
54-- `docs/runtime/layout.md`
55-- `docs/runtime/environment.md`
56-- `docs/runtime/launchd.md`
57-- `ops/launchd/so.makefile.baa-conductor.plist`
58-- `ops/launchd/so.makefile.baa-worker-runner.plist`
59-- `ops/launchd/so.makefile.baa-status-api.plist`
60-
61-## commands_run
62-
63-- `git worktree add /Users/george/code/baa-conductor-T011 -b feat/T-011-launchd-runtime main`
64-- `plutil -lint ops/launchd/so.makefile.baa-conductor.plist`
65-- `plutil -lint ops/launchd/so.makefile.baa-worker-runner.plist`
66-- `plutil -lint ops/launchd/so.makefile.baa-status-api.plist`
67-- `git diff --check`
68-
69-## result
70-
71-- 已补全 `launchd` plist 模板,加入 runtime 目录、日志路径、环境变量和 mini 默认值
72-- 已新增 runtime 文档,明确 `mini` / `mac` 的安装方式、`LaunchAgents` / `LaunchDaemons` 差异,以及 `runs/`、`worktrees/`、`logs/`、`tmp/` 的初始化规则
73-
74-## risks
75-
76-- 当前仓库的 app 构建脚本仍不产出真实 `dist/index.js`,因此本任务只能静态校验 plist 和文档,不能验证真实 `launchctl bootstrap` 启动成功
77-- `BAA_SHARED_TOKEN` 仍需在安装副本中手工替换,真实部署时如果漏改会直接导致鉴权失败
78-
79-## next_handoff
80-
81-- 后续接入真实服务产物后,按 `docs/runtime/launchd.md` 在 `mini` 与 `mac` 上分别复制、改写并加载 plist,再做一次端到端启动验证
82-
83-## notes
84-
85-- `2026-03-21`: 创建任务卡
1@@ -1,89 +0,0 @@
2----
3-task_id: T-012
4-title: 鉴权与 Token 模型
5-status: done
6-branch: feat/T-012-auth-model
7-repo: /Users/george/code/baa-conductor
8-base_ref: main
9-depends_on:
10- - T-001
11-write_scope:
12- - packages/auth/**
13- - docs/auth/**
14-updated_at: 2026-03-21T19:41:56+0800
15----
16-
17-# T-012 鉴权与 Token 模型
18-
19-## 目标
20-
21-把设计中的角色、token、权限边界落实成清晰的鉴权模型和代码挂载点。
22-
23-## 本任务包含
24-
25-- 角色定义
26-- token 形式说明
27-- middleware 或校验器骨架
28-- `controller`、`worker`、`browser_admin`、`ops_admin`、`readonly` 的权限矩阵
29-
30-## 本任务不包含
31-
32-- 完整生产级 secrets 管理
33-- 外部身份供应商接入
34-
35-## 建议起始文件
36-
37-- `packages/auth/src/index.ts`
38-- `docs/auth/README.md`
39-- `DESIGN.md` 第 11.3、11.4、28 节
40-
41-## 交付物
42-
43-- 鉴权设计文档
44-- 代码侧鉴权挂载点
45-
46-## 验收
47-
48-- 角色边界清晰
49-- 写接口与读接口权限可区分
50-- 浏览器控制与 controller 调度权限可区分
51-
52-## files_changed
53-
54-- `coordination/tasks/T-012-auth-model.md`
55-- `docs/auth/README.md`
56-- `packages/auth/package.json`
57-- `packages/auth/src/index.ts`
58-- `packages/auth/src/actions.ts`
59-- `packages/auth/src/model.ts`
60-- `packages/auth/src/policy.ts`
61-- `packages/auth/src/control-api.ts`
62-
63-## commands_run
64-
65-- `git switch -c feat/T-012-auth-model main`
66-- `pnpm --filter @baa-conductor/auth typecheck` (`pnpm` 不在当前环境 PATH 中,未执行成功)
67-- `./node_modules/.bin/tsc --noEmit -p /Users/george/code/baa-conductor/packages/auth/tsconfig.json`
68-
69-## result
70-
71-- 明确了 `controller`、`worker`、`browser_admin`、`ops_admin`、`readonly` 的角色边界与允许动作
72-- 定义了 `service_hmac`、`service_signed`、`browser_session`、`ops_session` 四类 token 模型与 principal 形状
73-- 在 `packages/auth` 下建立了 action、token/principal、授权策略、Control API route 授权映射四层骨架
74-- 在 `docs/auth/README.md` 中整理了权限矩阵、资源归属规则和 `T-003` 接入方式
75-
76-## risks
77-
78-- 还没有真实 token 签发、签名校验与密钥轮换实现
79-- 资源归属目前只约束到 `controllerId` / `workerId`,后续可能需要细化到 `run_id` / `step_id`
80-- `control-api-worker` 还未接入本包,`T-003` 仍需补 verifier 注入和 DB 归属校验
81-
82-## next_handoff
83-
84-- `T-003` 先把 `@baa-conductor/auth` 作为 workspace 依赖接入,并复用 `CONTROL_API_AUTH_RULES` 与 `authorizeControlApiRoute(...)`
85-- step 写接口在鉴权通过后继续结合数据库中的 `assigned_worker_id` 做最终归属校验
86-- controller 写接口在鉴权通过后继续结合 leader term、owner 字段做冲突与幂等检查
87-
88-## notes
89-
90-- `2026-03-21`: 创建任务卡
1@@ -1,113 +0,0 @@
2----
3-task_id: T-013
4-title: Build 与 dist 产物
5-status: done
6-branch: feat/T-013-build-runtime
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@c5e007b
9-depends_on:
10- - T-011
11-write_scope:
12- - package.json
13- - tsconfig.base.json
14- - apps/conductor-daemon/package.json
15- - apps/conductor-daemon/tsconfig.json
16- - apps/control-api-worker/package.json
17- - apps/control-api-worker/tsconfig.json
18- - apps/status-api/package.json
19- - apps/status-api/tsconfig.json
20- - apps/worker-runner/package.json
21- - apps/worker-runner/tsconfig.json
22- - docs/runtime/**
23-updated_at: 2026-03-22
24----
25-
26-# T-013 Build 与 dist 产物
27-
28-## 目标
29-
30-把当前仓库从“只有 typecheck,没有真实产物”推进到“至少 apps 能产出 `dist/index.js`”,让 launchd 模板不再只是静态占位。
31-
32-## 统一开工要求
33-
34-- 必须从当前最新 `origin/main` 切出该分支
35-- 新 worktree 进入后先执行 `npx --yes pnpm install`
36-- 不允许从其他任务分支切分支
37-
38-## 本任务包含
39-
40-- 修正根 `build` 脚本
41-- 让 `apps/*` 的 `build` 真正输出 JS
42-- 调整必要的 tsconfig,使 `dist/` 可生成
43-- 如果需要,补最小的 build 使用说明到 `docs/runtime/**`
44-
45-## 本任务不包含
46-
47-- 真正运行 launchd
48-- 真实业务逻辑接线
49-- 包级发布配置
50-
51-## 建议起始文件
52-
53-- `package.json`
54-- `tsconfig.base.json`
55-- `apps/*/package.json`
56-- `apps/*/tsconfig.json`
57-- `docs/runtime/launchd.md`
58-
59-## 交付物
60-
61-- `pnpm -r build` 可用
62-- apps 产出 `dist/index.js`
63-
64-## 验收
65-
66-- `npx --yes pnpm -r build` 通过
67-- `apps/conductor-daemon/dist/index.js` 存在
68-- `apps/worker-runner/dist/index.js` 存在
69-- `apps/status-api/dist/index.js` 存在
70-
71-## files_changed
72-
73-- `coordination/tasks/T-013-build-runtime.md`
74-- `package.json`
75-- `apps/conductor-daemon/package.json`
76-- `apps/control-api-worker/package.json`
77-- `apps/status-api/package.json`
78-- `apps/worker-runner/package.json`
79-- `docs/runtime/README.md`
80-- `docs/runtime/launchd.md`
81-
82-## commands_run
83-
84-- `git worktree add /Users/george/code/baa-conductor-T013 -b feat/T-013-build-runtime c5e007b082772d085a030217691f6b88da9b3ee4`
85-- `npx --yes pnpm install`
86-- `npx --yes pnpm -r build`
87-- `node --input-type=module -e "await import('./apps/conductor-daemon/dist/index.js'); console.log('ok conductor-daemon');"`
88-- `node --input-type=module -e "await import('./apps/control-api-worker/dist/index.js'); console.log('ok control-api-worker');"`
89-- `node --input-type=module -e "await import('./apps/status-api/dist/index.js'); console.log('ok status-api');"`
90-- `node --input-type=module -e "await import('./apps/worker-runner/dist/index.js'); console.log('ok worker-runner');"`
91-
92-## result
93-
94-- 四个 app 的 `build` 已从 `tsc --noEmit` 改成真实 emit,其中 `conductor-daemon` 直接输出 `dist/index.js`
95-- 为 `control-api-worker`、`status-api`、`worker-runner` 增加了统一 postbuild 处理:固定根入口到 `dist/index.js`,并在需要时改写编译后 import specifier
96-- `control-api-worker` 与 `worker-runner` 的编译产物已改写为引用同一 `dist/` 下同步生成的 package JS 文件,`worker-runner` 产物里的相对 import 也已补齐 `.js` 扩展
97-- `npx --yes pnpm -r build` 通过,四个 `apps/*/dist/index.js` 均存在,且可被 `node` 直接 `import(...)`
98-- `docs/runtime` 已更新为“先 build 再 launchd bootstrap”的当前约定,不再声明仓库缺少 `dist/index.js`
99-
100-## risks
101-
102-- 目前 package 自身仍然只做 typecheck,没有形成独立发布级 `dist/`;`control-api-worker` 与 `worker-runner` 依赖 app build 阶段的产物改写
103-- 根 `package.json` 中的 postbuild helper 目前以内联脚本维护,后续如果构建规则继续变复杂,最好拆成独立脚本文件
104-- 本任务解决的是“产物路径稳定且可加载”,不是“服务运行时逻辑全部接好”;`T-014`、`T-015`、`T-017` 仍需补真正启动流程
105-
106-## next_handoff
107-
108-- `T-014` 可以直接把 `apps/control-api-worker/dist/index.js` 当作稳定入口做运行时接线,不必再解决 bare workspace import 问题
109-- `T-015` 可以基于 `apps/conductor-daemon/dist/index.js` 继续补 CLI/daemon 启动逻辑,并复用文档里“先 build 再 bootstrap”的流程
110-- `T-017` 可以直接围绕 `apps/status-api/dist/index.js` 挂 HTTP 启动入口;若后续需要 package 级独立 dist,可另拆构建任务
111-
112-## notes
113-
114-- `2026-03-21`: 创建第三波任务卡
1@@ -1,99 +0,0 @@
2----
3-task_id: T-014
4-title: Control API 运行时接线
5-status: done
6-branch: feat/T-014-control-api-runtime
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@c5e007b
9-depends_on:
10- - T-003
11- - T-012
12-write_scope:
13- - apps/control-api-worker/**
14-updated_at: 2026-03-22
15----
16-
17-# T-014 Control API 运行时接线
18-
19-## 目标
20-
21-把第二波已经做好的 control-api 骨架推进到“可真正承载运行时接线”的程度,包括 env、auth 注入、repository 注入和基础 handler 落点。
22-
23-## 统一开工要求
24-
25-- 必须从当前最新 `origin/main` 切出该分支
26-- 新 worktree 进入后先执行 `npx --yes pnpm install`
27-- 不允许从其他任务分支切分支
28-
29-## 本任务包含
30-
31-- 明确 Worker `Env` 结构
32-- 把 auth hook 真正接入 route 执行流
33-- 整理 repository 注入点
34-- 尽量实现可安全落地的 read-only handler 或 mutation glue 代码
35-
36-## 本任务不包含
37-
38-- 修改 `packages/auth/**`
39-- 修改 `packages/db/**`
40-- 修改 conductor 代码
41-
42-## 建议起始文件
43-
44-- `apps/control-api-worker/src/index.ts`
45-- `apps/control-api-worker/src/router.ts`
46-- `apps/control-api-worker/src/handlers.ts`
47-- `apps/control-api-worker/src/contracts.ts`
48-
49-## 交付物
50-
51-- 更像真实 Worker 的运行时入口
52-- auth 与 repository 挂载方式
53-- 至少部分 handler 不再只是纯占位
54-
55-## 验收
56-
57-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
58-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
59-- 路由、auth、env、repository 关系清晰
60-
61-## files_changed
62-
63-- `apps/control-api-worker/src/contracts.ts`
64-- `apps/control-api-worker/src/handlers.ts`
65-- `apps/control-api-worker/src/index.ts`
66-- `apps/control-api-worker/src/router.ts`
67-- `apps/control-api-worker/src/runtime.ts`
68-- `apps/control-api-worker/src/schemas.ts`
69-- `coordination/tasks/T-014-control-api-runtime.md`
70-
71-## commands_run
72-
73-- `git worktree add /Users/george/code/baa-conductor-T014 -b feat/T-014-control-api-runtime c5e007b082772d085a030217691f6b88da9b3ee4`
74-- `npx --yes pnpm install`
75-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
76-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
77-
78-## result
79-
80-- 明确了 `ControlApiEnv` 与运行时服务结构,新增 env 驱动的 auth/repository 装配层,并支持显式注入 `authHook`、`tokenVerifier`、`repository` 与 `now`。
81-- 把 auth hook 真正接入请求执行流;当配置了 runtime token 或外部 verifier 时,路由会执行 Bearer 提取、principal 解析和 `authorizeControlApiRoute(...)` 授权。
82-- 落地了可安全运行的 handler:`controllers.heartbeat`、`leader.acquire`、`tasks.create`、`system.pause` / `resume` / `drain`、`system.state`、`tasks.read` 已接到真实 repository 调用;其余未完成路由仍保持显式 `501`。
83-- 为已接线路由补了最小 request 校验和 `repository_not_configured` / `invalid_request` / `*_not_found` 失败路径,使运行时错误更明确。
84-
85-## risks
86-
87-- `BAA_SHARED_TOKEN` 模式仍是最小实现:controller / worker 的身份会从当前请求资源推断,尚未做到签名 claim 或强身份绑定。
88-- `tasks.plan`、`tasks.claim`、step 回写、task logs、run detail 仍是占位实现;完整 durable 调度链路仍依赖后续任务继续接入。
89-- 如果没有配置 runtime token / verifier 且未显式打开 `CONTROL_API_AUTH_REQUIRED`,auth 仍会退化为跳过模式;生产部署时应显式配置鉴权输入。
90-
91-## next_handoff
92-
93-- `T-015` / conductor 运行时可以直接复用新的 `leader.acquire`、`controllers.heartbeat` 路由和 env auth wiring。
94-- 后续任务可沿当前 `runtime.ts` 的装配点继续接 `tasks.plan`、`tasks.claim`、`steps.heartbeat`、`steps.checkpoint`、`steps.complete`、`steps.fail` 的真实持久化逻辑。
95-- `T-017` 可直接消费 `GET /v1/system/state` 与 `GET /v1/tasks/:task_id`;如需日志与 run 详情,需要补本地 query/repository 读取路径。
96-
97-## notes
98-
99-- `2026-03-21`: 创建第三波任务卡
100-- `2026-03-22`: 完成 control-api 运行时接线并通过目标包 `typecheck` / `build`
1@@ -1,92 +0,0 @@
2----
3-task_id: T-015
4-title: Conductor 运行时接线
5-status: done
6-branch: feat/T-015-conductor-runtime
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@c5e007b
9-depends_on:
10- - T-004
11-write_scope:
12- - apps/conductor-daemon/**
13-updated_at: 2026-03-22
14----
15-
16-# T-015 Conductor 运行时接线
17-
18-## 目标
19-
20-把 conductor 从“状态机骨架”推进到“有可启动入口、有 env/CLI 配置、有 control-api client 接线”的程度。
21-
22-## 统一开工要求
23-
24-- 必须从当前最新 `origin/main` 切出该分支
25-- 新 worktree 进入后先执行 `npx --yes pnpm install`
26-- 不允许从其他任务分支切分支
27-
28-## 本任务包含
29-
30-- env / CLI 配置解析
31-- 构造可启动的 daemon 入口
32-- 组织 heartbeat / lease loop 启动流程
33-- 暴露最小状态快照或运行时接口
34-
35-## 本任务不包含
36-
37-- 修改 `packages/db/**`
38-- 修改 control-api-worker
39-- 完整调度器
40-
41-## 建议起始文件
42-
43-- `apps/conductor-daemon/src/index.ts`
44-- `apps/conductor-daemon/src/index.test.js`
45-
46-## 交付物
47-
48-- 可以启动的 conductor 运行时入口
49-- 更明确的主备启动流程
50-
51-## 验收
52-
53-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
54-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
55-- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
56-
57-## files_changed
58-
59-- `apps/conductor-daemon/package.json`
60-- `apps/conductor-daemon/src/index.ts`
61-- `apps/conductor-daemon/src/index.test.js`
62-
63-## commands_run
64-
65-- `npx --yes pnpm install`
66-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
67-- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
68-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
69-- `node apps/conductor-daemon/dist/index.js start --node-id mini-main --host mini --role primary --control-api-base http://127.0.0.1:9 --run-once --json`
70-- `git diff --check`
71-
72-## result
73-
74-- `apps/conductor-daemon` 现在有可执行的 CLI/runtime 入口,支持 `start`、`config`、`checklist`,并能从 env/CLI 解析节点身份、control-api 地址、共享 token 与本地目录配置。
75-- control-api client 已按第三波 contract 补上 Bearer 认证、统一 envelope 解包,以及 `snake_case` 请求/响应兼容,能对接 `/v1/controllers/heartbeat` 和 `/v1/leader/acquire`。
76-- runtime 侧新增最小快照接口 `ConductorRuntime.getRuntimeSnapshot()`,并把启动流程整理为:初始 heartbeat、初始 lease cycle、本地 runs hook、后台 loop 启动;同时补了 CLI/config/client/runtime 相关测试。
77-- `conductor-daemon` 自身 `build` 脚本现在会产出 `dist/index.js`,验证过构建后的入口可以直接启动并在 control-api 不可达时稳定落到 `degraded` 快照。
78-
79-## risks
80-
81-- `apps/control-api-worker` 当前主线仍以占位 handler 为主;虽然 client 侧协议已对齐,但真实 heartbeat / lease 持久化效果仍依赖 `T-014` 把 handler 接到 repository。
82-- 最小 runtime 接口当前是进程内快照与 CLI 输出,还没有单独的 loopback HTTP 服务;如果后续需要让 status-api 或 launchd 直接探活,可能还要在 `apps/conductor-daemon/**` 内补本地只读端点。
83-- `conductor-daemon` 的 app 级 build 现已可产出 dist,但根级统一构建链路仍需 `T-013` 做最终收口,避免后续在脚本层重复调整。
84-
85-## next_handoff
86-
87-- `T-014` 可以直接用当前 client 期望的 Bearer + envelope + `snake_case` 约定实现真实 handler,并把 `/v1/controllers/heartbeat`、`/v1/leader/acquire` 返回值收敛到已测试的兼容形状。
88-- 后续 scheduler / status 相关任务可以直接复用 `ConductorRuntime` 与 `getRuntimeSnapshot()` 作为启动态、主备态和配置态的最小读取接口。
89-- `T-013` 如需统一根级 build/launchd 行为,应保留 `apps/conductor-daemon/dist/index.js` 作为稳定入口路径。
90-
91-## notes
92-
93-- `2026-03-21`: 创建第三波任务卡
1@@ -1,98 +0,0 @@
2----
3-task_id: T-016
4-title: Worker 本地持久化
5-status: done
6-branch: feat/T-016-worker-persistence
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@c5e007b
9-depends_on:
10- - T-005
11- - T-006
12-write_scope:
13- - apps/worker-runner/**
14- - packages/checkpointing/**
15- - packages/logging/**
16-updated_at: 2026-03-22
17----
18-
19-# T-016 Worker 本地持久化
20-
21-## 目标
22-
23-把 worker-runner 从“内存态结构”推进到“会把 meta/state/log/checkpoint 真正写入本地目录”的程度。
24-
25-## 统一开工要求
26-
27-- 必须从当前最新 `origin/main` 切出该分支
28-- 新 worktree 进入后先执行 `npx --yes pnpm install`
29-- 不允许从其他任务分支切分支
30-
31-## 本任务包含
32-
33-- 本地目录创建
34-- `meta.json`、`state.json` 写入
35-- `worker.log`、`stdout.log`、`stderr.log` 落盘
36-- checkpoint 文件真正写入 `checkpoints/`
37-
38-## 本任务不包含
39-
40-- 真实 Codex 子进程接入
41-- D1 checkpoint 上报
42-- failover 恢复全链路
43-
44-## 建议起始文件
45-
46-- `apps/worker-runner/src/runner.ts`
47-- `apps/worker-runner/src/checkpoints.ts`
48-- `packages/checkpointing/src/index.ts`
49-- `packages/logging/src/*`
50-
51-## 交付物
52-
53-- 本地持久化 runner 骨架
54-- checkpoint 文件真正落盘
55-
56-## 验收
57-
58-- `npx --yes pnpm --filter @baa-conductor/worker-runner typecheck`
59-- `npx --yes pnpm --filter @baa-conductor/checkpointing typecheck`
60-- 能明确说明哪些文件会落到本地 runtime 目录
61-
62-## files_changed
63-
64-- `apps/worker-runner/src/runner.ts`
65-- `packages/checkpointing/src/index.ts`
66-- `packages/checkpointing/src/node-shims.ts`
67-- `packages/logging/src/index.ts`
68-- `packages/logging/src/node-shims.ts`
69-- `packages/logging/src/persistence.ts`
70-- `coordination/tasks/T-016-worker-persistence.md`
71-
72-## commands_run
73-
74-- `npx --yes pnpm install`
75-- `npx --yes pnpm --filter @baa-conductor/logging typecheck`
76-- `npx --yes pnpm --filter @baa-conductor/checkpointing typecheck`
77-- `npx --yes pnpm --filter @baa-conductor/worker-runner typecheck`
78-- `npx --yes pnpm exec tsc -p apps/worker-runner/tsconfig.json --outDir <tmp> --module commonjs`
79-- `DIST_ROOT=<tmp> RUNTIME_ROOT=<tmp> node <<'EOF' ... EOF`
80-
81-## result
82-
83-- `worker-runner` 现在会创建本地 run 目录,并在初始化时写入 `meta.json`、`state.json`、空的 `worker.log` / `stdout.log` / `stderr.log`。
84-- 生命周期事件、stdout/stderr chunk、checkpoint 写入后都会同步刷新本地 `state.json`,不再只停留在内存态。
85-- `summary` 与 `log_tail` checkpoint 会真实落到 `checkpoints/`,并已通过临时 CommonJS 编译验证生成 `0001-summary.json`、`0002-log-tail.txt`。
86-
87-## risks
88-
89-- 真实 Codex 子进程尚未接入,当前只验证了 placeholder/custom executor 路径下的本地持久化。
90-- `git_diff`、`test_output` 等更大 checkpoint 负载还没有实际生成端,当前仅补齐了文件写入基础设施。
91-
92-## next_handoff
93-
94-- 将真实 worker 执行器接到 `appendStreamChunkPersisted` / `recordLifecycleEventPersisted`,保持 stdout/stderr 与状态文件持续落盘。
95-- 在后续 checkpoint 任务里直接复用 `persistCheckpointRecord` 扩展 `git_diff`、`test_output` 的实际产出。
96-
97-## notes
98-
99-- `2026-03-21`: 创建第三波任务卡
1@@ -1,93 +0,0 @@
2----
3-task_id: T-017
4-title: Status API 运行时入口
5-status: done
6-branch: feat/T-017-status-runtime
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@c5e007b
9-depends_on:
10- - T-010
11-write_scope:
12- - apps/status-api/**
13-updated_at: 2026-03-22
14----
15-
16-# T-017 Status API 运行时入口
17-
18-## 目标
19-
20-把 status-api 从“包内 handler 骨架”推进到“有可挂载运行时入口”的程度。
21-
22-## 统一开工要求
23-
24-- 必须从当前最新 `origin/main` 切出该分支
25-- 新 worktree 进入后先执行 `npx --yes pnpm install`
26-- 不允许从其他任务分支切分支
27-
28-## 本任务包含
29-
30-- 明确 status-api 对外入口
31-- 整理 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui`
32-- 提供最小 fetch handler 或 node server adapter
33-
34-## 本任务不包含
35-
36-- 修改 control-api-worker
37-- 修改 conductor
38-- 真实部署配置
39-
40-## 建议起始文件
41-
42-- `apps/status-api/src/index.ts`
43-- `apps/status-api/src/service.ts`
44-- `apps/status-api/src/render.ts`
45-
46-## 交付物
47-
48-- 可挂载的 status-api 入口
49-- 更完整的 HTML/JSON 响应骨架
50-
51-## 验收
52-
53-- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
54-- `npx --yes pnpm --filter @baa-conductor/status-api build`
55-- 入口结构清晰,后续可直接接入 launchd 或本地 HTTP server
56-
57-## files_changed
58-
59-- `apps/status-api/package.json`
60-- `apps/status-api/tsconfig.json`
61-- `apps/status-api/src/contracts.ts`
62-- `apps/status-api/src/index.ts`
63-- `apps/status-api/src/runtime.ts`
64-- `apps/status-api/src/service.ts`
65-- `coordination/tasks/T-017-status-runtime.md`
66-
67-## commands_run
68-
69-- `npx --yes pnpm install`
70-- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
71-- `npx --yes pnpm --filter @baa-conductor/status-api build`
72-- `node --input-type=module -e "import { createStatusApiRuntime } from './apps/status-api/dist/apps/status-api/src/index.js'; ..."`
73-
74-## result
75-
76-- 已将 status-api 从包内 handler 扩展为可挂载的 fetch 运行时入口,新增 `createStatusApiRuntime()` 与 `createStatusApiFetchHandler()`,可直接对接标准 `Request`/`Response`。
77-- 已整理对外路由面,明确以 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui` 为 canonical surface,并把 `/`、`/ui` 保留为 UI 别名。
78-- 已将 `build` 改为真实 `tsc` 发射,确认生成 `apps/status-api/dist/**` 产物,并用构建后的运行时代码冒烟验证三条 GET 路由。
79-
80-## risks
81-
82-- 默认运行时仍使用 `StaticStatusSnapshotLoader`;真正接入 D1 或本地控制平面数据库仍需由后续整合步骤注入 `D1StatusSnapshotLoader`。
83-- 当前 dist 入口路径受 `rootDir: ../..` 影响为 `dist/apps/status-api/src/*.js`;如果后续统一构建任务收敛到平铺的 `dist/index.js`,需要同步调整 `package.json` 的 `main`/`exports`。
84-- fetch 运行时假设宿主环境提供标准 Fetch API;若后续必须在更旧的 Node 版本运行,需要额外补 polyfill 或改为 node server adapter。
85-
86-## next_handoff
87-
88-- 在 status-api 的宿主进程中注入真实 `D1StatusSnapshotLoader`,把 `createStatusApiRuntime()` 挂到本地 HTTP server、launchd 进程或上层 router。
89-- 若仓库后续统一 dist 布局,顺手把 `@baa-conductor/status-api` 的导出路径更新到新的 build 产物位置。
90-
91-## notes
92-
93-- `2026-03-21`: 创建第三波任务卡
94-- `2026-03-22`: 从 `main@c5e007b` 建立独立 worktree,补齐 status-api fetch 运行时入口与 canonical route surface,完成验证并进入 review。
1@@ -1,111 +0,0 @@
2----
3-task_id: T-018
4-title: Cloudflare Worker 与 D1 部署配置
5-status: done
6-branch: feat/T-018-control-api-deploy
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@458d7cf
9-depends_on:
10- - T-014
11-write_scope:
12- - apps/control-api-worker/**
13- - ops/cloudflare/**
14-updated_at: 2026-03-22
15----
16-
17-# T-018 Cloudflare Worker 与 D1 部署配置
18-
19-## 目标
20-
21-把 `control-api-worker` 从“可构建的 Worker 代码”推进到“具备明确 Cloudflare Worker / D1 绑定配置、可部署模板和最小运维说明”的程度。
22-
23-## 本任务包含
24-
25-- 为 `apps/control-api-worker` 补部署配置文件,例如 `wrangler.jsonc`
26-- 明确 D1 binding、环境变量和 Worker 入口约定
27-- 在 `ops/cloudflare/**` 下补部署示例、变量模板或辅助脚本
28-- 把当前 runtime 代码与部署配置对齐,保证入口和 bindings 命名一致
29-
30-## 本任务不包含
31-
32-- 实际上线部署到 Cloudflare
33-- 修改 `packages/db/**`
34-- 修改 `packages/auth/**`
35-- 修改 conductor 或 status-api 代码
36-
37-## 建议起始文件
38-
39-- `apps/control-api-worker/src/index.ts`
40-- `apps/control-api-worker/src/contracts.ts`
41-- `apps/control-api-worker/package.json`
42-- `ops/cloudflare/`
43-
44-## 交付物
45-
46-- 可提交到仓库的 Worker / D1 配置骨架
47-- 清晰的 binding 与 secret 约定
48-- 最小部署说明或辅助脚本
49-
50-## 验收
51-
52-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
53-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
54-- Worker 配置文件与代码里的 env/binding 名称一致
55-
56-## 更新要求
57-
58-完成时更新 frontmatter 的:
59-
60-- `status`
61-- `base_ref`
62-- `updated_at`
63-
64-并补充下面这些内容:
65-
66-## files_changed
67-
68-- `apps/control-api-worker/.dev.vars.example`
69-- `apps/control-api-worker/.gitignore`
70-- `apps/control-api-worker/package.json`
71-- `apps/control-api-worker/src/contracts.ts`
72-- `apps/control-api-worker/src/handlers.ts`
73-- `apps/control-api-worker/src/runtime.ts`
74-- `apps/control-api-worker/wrangler.jsonc`
75-- `ops/cloudflare/README.md`
76-- `ops/cloudflare/apply-control-api-d1-migrations.sh`
77-- `ops/cloudflare/control-api-worker.secrets.example.env`
78-- `ops/cloudflare/deploy-control-api-worker.sh`
79-- `coordination/tasks/T-018-control-api-deploy.md`
80-
81-## commands_run
82-
83-- `git worktree add /Users/george/code/baa-conductor-T018 -b feat/T-018-control-api-deploy 458d7cf`
84-- `npx --yes pnpm install`
85-- `bash -n ops/cloudflare/deploy-control-api-worker.sh ops/cloudflare/apply-control-api-d1-migrations.sh`
86-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
87-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
88-
89-## result
90-
91-- 为 `control-api-worker` 增加了可提交的 `wrangler.jsonc`,固定了 Worker 名称、入口 `dist/index.js`、自定义域 `control-api.makefile.so`、D1 binding `CONTROL_DB` 和最小运行时变量。
92-- 在 `contracts.ts` / `runtime.ts` / `handlers.ts` 中把 D1 binding 与 token/env 名称抽成常量并接入运行时,减少代码和部署配置之间的漂移。
93-- 在 `ops/cloudflare/` 下补了最小运维材料:部署说明、secret 模板、远端 D1 migration 脚本、构建后部署脚本;同时在包脚本中暴露了 Cloudflare 相关入口。
94-
95-## risks
96-
97-- `apps/control-api-worker/wrangler.jsonc` 中的 `database_id` 与 `preview_database_id` 仍是占位值;实际部署前必须替换为真实 D1 UUID。
98-- 本任务只提供 secret 模板和 `wrangler secret put` 约定,真实 secret 仍需在目标 Cloudflare 账号中手动注入。
99-- 未执行真实 Cloudflare deploy / D1 远端 migration,因此自定义域、账号权限和线上资源绑定仍需落地验证。
100-
101-## next_handoff
102-
103-- 先填写 `wrangler.jsonc` 的真实 D1 UUID,再执行 `./ops/cloudflare/apply-control-api-d1-migrations.sh`。
104-- 按 `ops/cloudflare/control-api-worker.secrets.example.env` 的键名把生产 secret 写入 Cloudflare Worker。
105-- 完成后执行 `./ops/cloudflare/deploy-control-api-worker.sh`,并验证 `https://control-api.makefile.so` 是否能正常返回 Worker 响应。
106-
107-开始时建议直接把 `status` 改为 `in_progress`。
108-
109-做完并推送后:
110-
111-- 如果等待整合,改为 `review`
112-- 如果确认结束,改为 `done`
1@@ -1,100 +0,0 @@
2----
3-task_id: T-019
4-title: Conductor 本地 HTTP 入口
5-status: done
6-branch: feat/T-019-conductor-http
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@458d7cf
9-depends_on:
10- - T-015
11-write_scope:
12- - apps/conductor-daemon/**
13-updated_at: 2026-03-22
14----
15-
16-# T-019 Conductor 本地 HTTP 入口
17-
18-## 目标
19-
20-把 `conductor-daemon` 从“只有 CLI/runtime 快照”推进到“具备本地只读 HTTP 探活入口”的程度,至少覆盖 `GET /healthz`、`GET /readyz`、`GET /rolez`。
21-
22-## 本任务包含
23-
24-- 在 `apps/conductor-daemon/**` 内实现最小本地 HTTP server
25-- 暴露 `healthz` / `readyz` / `rolez`,必要时补 `GET /v1/runtime`
26-- 把现有 runtime snapshot 接到这些只读接口
27-- 为本地 HTTP server 补最小测试或冒烟验证
28-
29-## 本任务不包含
30-
31-- 修改 `packages/db/**`
32-- 修改 control-api-worker
33-- 修改 status-api
34-- 完整任务调度器
35-
36-## 建议起始文件
37-
38-- `apps/conductor-daemon/src/index.ts`
39-- `apps/conductor-daemon/src/index.test.js`
40-
41-## 交付物
42-
43-- 本地 loopback HTTP 探活入口
44-- 清晰的 `healthz` / `readyz` / `rolez` 语义
45-- launchd 或 Nginx 后续可复用的读取面
46-
47-## 验收
48-
49-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
50-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
51-- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
52-
53-## 更新要求
54-
55-完成时更新 frontmatter 的:
56-
57-- `status`
58-- `base_ref`
59-- `updated_at`
60-
61-并补充下面这些内容:
62-
63-## files_changed
64-
65-- `apps/conductor-daemon/src/index.ts`
66-- `apps/conductor-daemon/src/index.test.js`
67-- `apps/conductor-daemon/src/node-shims.d.ts`
68-- `apps/conductor-daemon/tsconfig.json`
69-- `coordination/tasks/T-019-conductor-http.md`
70-
71-## commands_run
72-
73-- `git worktree add /Users/george/code/baa-conductor-T019 -b feat/T-019-conductor-http 458d7cf`
74-- `npx --yes pnpm install`
75-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
76-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
77-- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
78-- `git diff --check`
79-
80-## result
81-
82-- 已在 `apps/conductor-daemon` 内补上最小本地只读 HTTP server,基于 `BAA_CONDUCTOR_LOCAL_API` 暴露 `GET /healthz`、`GET /readyz`、`GET /rolez` 与 `GET /v1/runtime`,并限制为 loopback `http://` 地址。
83-- 已把现有 runtime snapshot 接到 HTTP 读取面,`ConductorRuntime` 现在会随启动/停止一起管理本地 server 生命周期,并在快照中回填实际监听地址。
84-- 已补测试覆盖降级态 `readyz/rolez` 语义与真实本地 HTTP 冒烟请求,`typecheck`、`build`、`node --test` 均通过。
85-
86-## risks
87-
88-- 当前 `/readyz` 的语义是“runtime 已启动且 lease 未降级”;后续如果要把本地 runs 恢复、调度器预热或更多依赖也纳入 readiness,需要再扩展判定。
89-- 为避免越出本任务 `write_scope`,Node 内建模块类型声明目前以 `apps/conductor-daemon/src/node-shims.d.ts` 本地 shim 形式维护;如果仓库后续统一引入 `@types/node`,应顺手收敛这里的声明。
90-
91-## next_handoff
92-
93-- launchd、Nginx 或后续 status 相关整合可以直接复用 `http://127.0.0.1:4317/healthz`、`/readyz`、`/rolez`、`/v1/runtime` 作为 conductor 的本地读取面。
94-- 如果后续需要更严格的运维判断,可在不改外部路由的前提下继续细化 `/readyz` 与 `/v1/runtime` 的字段。
95-
96-开始时建议直接把 `status` 改为 `in_progress`。
97-
98-做完并推送后:
99-
100-- 如果等待整合,改为 `review`
101-- 如果确认结束,改为 `done`
+0,
-107
1@@ -1,107 +0,0 @@
2----
3-task_id: T-020
4-title: Status API 本地宿主进程
5-status: done
6-branch: feat/T-020-status-host
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@458d7cf
9-depends_on:
10- - T-017
11-write_scope:
12- - apps/status-api/**
13-updated_at: 2026-03-22
14----
15-
16-# T-020 Status API 本地宿主进程
17-
18-## 目标
19-
20-把 `status-api` 从“可挂载运行时入口”推进到“带本地宿主进程或 node server adapter,可直接启动监听”的程度。
21-
22-## 本任务包含
23-
24-- 在 `apps/status-api/**` 内实现本地宿主进程或 node HTTP adapter
25-- 把 `createStatusApiRuntime()` 真正挂到监听端口
26-- 整理 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui` 的本地启动方式
27-- 补最小启动/冒烟验证
28-
29-## 本任务不包含
30-
31-- 修改 conductor-daemon
32-- 修改 control-api-worker
33-- 真实部署到 launchd
34-- D1 线上接入
35-
36-## 建议起始文件
37-
38-- `apps/status-api/src/index.ts`
39-- `apps/status-api/src/runtime.ts`
40-- `apps/status-api/src/service.ts`
41-
42-## 交付物
43-
44-- 可直接启动的 status-api 本地宿主进程
45-- 明确的本地端口和启动接口
46-- 后续 launchd 可直接复用的入口
47-
48-## 验收
49-
50-- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
51-- `npx --yes pnpm --filter @baa-conductor/status-api build`
52-- 能说明本地如何启动和访问 `healthz` / `status` / `ui`
53-
54-## 更新要求
55-
56-完成时更新 frontmatter 的:
57-
58-- `status`
59-- `base_ref`
60-- `updated_at`
61-
62-并补充下面这些内容:
63-
64-## files_changed
65-
66-- `apps/status-api/package.json`
67-- `apps/status-api/tsconfig.json`
68-- `apps/status-api/src/index.ts`
69-- `apps/status-api/src/cli.ts`
70-- `apps/status-api/src/host.ts`
71-- `apps/status-api/src/node-shims.d.ts`
72-- `coordination/tasks/T-020-status-host.md`
73-
74-## commands_run
75-
76-- `npx --yes pnpm install`
77-- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
78-- `npx --yes pnpm --filter @baa-conductor/status-api build`
79-- `npx --yes pnpm --filter @baa-conductor/status-api smoke`
80-- `node apps/status-api/dist/index.js --host 127.0.0.1 --port 4328`
81-- `curl --silent --show-error http://127.0.0.1:4328/healthz`
82-- `curl --silent --show-error http://127.0.0.1:4328/v1/status`
83-- `curl --silent --show-error http://127.0.0.1:4328/v1/status/ui | sed -n '1,8p'`
84-
85-## result
86-
87-- 已在 `apps/status-api/**` 内补齐 Node 宿主层,新增本地 HTTP request listener 与 `startStatusApiServer()`,把 `createStatusApiRuntime()` 真正挂到监听端口。
88-- 已补 `runStatusApiCli()` 与主入口直启逻辑,`node apps/status-api/dist/index.js` 现在默认监听 `127.0.0.1:4318`,可直接给 launchd 复用。
89-- 已新增包内脚本:`pnpm --filter @baa-conductor/status-api serve`、`start`、`smoke`;本地可访问 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui`。
90-- 已补最小真实 HTTP 冒烟验证:`smoke` 会拉起临时端口并验证三条 GET 路由,另外也手动确认了 `node apps/status-api/dist/index.js --host 127.0.0.1 --port 4328` 的实际监听响应。
91-
92-## risks
93-
94-- 当前宿主进程默认仍使用 `StaticStatusSnapshotLoader`,返回的是本地空快照;真实 D1 / 控制平面接线仍需后续整合任务注入。
95-- `dist/index.js` 的直启能力依赖当前统一 build 产物布局;如果后续全仓库再调整 `BAA_DIST_ENTRY` 或 shim 结构,需要同步验证直启检测逻辑。
96-
97-## next_handoff
98-
99-- 在后续整合中,把真实 snapshot loader 注入 `startStatusApiServer()` 或 CLI 启动路径,让 `/v1/status` 返回真实控制平面状态。
100-- 视 launchd/bootstrap 任务需要,在安装副本里补 `BAA_STATUS_API_HOST` / `BAA_STATUS_API_PORT` 等环境变量,或继续沿用默认 `127.0.0.1:4318`。
101-- 如果后续希望把 status-api 嵌入到更大的 Node 宿主里,可直接复用本任务新增的 request listener / server adapter,而不必再改 service/runtime 层。
102-
103-开始时建议直接把 `status` 改为 `in_progress`。
104-
105-做完并推送后:
106-
107-- 如果等待整合,改为 `review`
108-- 如果确认结束,改为 `done`
1@@ -1,119 +0,0 @@
2----
3-task_id: T-021
4-title: launchd 安装脚本与 Runtime Bootstrap
5-status: done
6-branch: feat/T-021-runtime-bootstrap
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@458d7cf
9-depends_on:
10- - T-011
11- - T-013
12- - T-015
13- - T-017
14-write_scope:
15- - ops/launchd/**
16- - docs/runtime/**
17- - scripts/runtime/**
18-updated_at: 2026-03-22T01:08:59+0800
19----
20-
21-# T-021 launchd 安装脚本与 Runtime Bootstrap
22-
23-## 目标
24-
25-把现有 launchd 模板和 runtime 文档推进到“有实际 bootstrap/install/check 脚本可执行”的程度,减少手工部署步骤。
26-
27-## 本任务包含
28-
29-- 在 `scripts/runtime/**` 下补 runtime 目录初始化脚本
30-- 补 launchd 安装、重载、检查的辅助脚本
31-- 让 `docs/runtime/**` 与脚本行为保持一致
32-- 如有必要,小幅修正 `ops/launchd/**` 模板中的占位项说明
33-
34-## 本任务不包含
35-
36-- 修改 app 业务代码
37-- 实际在本机加载 launchd 服务
38-- 修改 Nginx 或 Cloudflare DNS
39-
40-## 建议起始文件
41-
42-- `docs/runtime/README.md`
43-- `docs/runtime/launchd.md`
44-- `docs/runtime/layout.md`
45-- `ops/launchd/*.plist`
46-- `scripts/runtime/`
47-
48-## 交付物
49-
50-- 可执行的 bootstrap/install/check 脚本
51-- 与脚本一致的 runtime / launchd 文档
52-- 更低摩擦的本地部署流程
53-
54-## 验收
55-
56-- `plutil -lint ops/launchd/so.makefile.baa-conductor.plist`
57-- `plutil -lint ops/launchd/so.makefile.baa-worker-runner.plist`
58-- `plutil -lint ops/launchd/so.makefile.baa-status-api.plist`
59-- 如果新增 shell 脚本:`bash -n scripts/runtime/*.sh`
60-
61-## 更新要求
62-
63-完成时更新 frontmatter 的:
64-
65-- `status`
66-- `base_ref`
67-- `updated_at`
68-
69-并补充下面这些内容:
70-
71-## files_changed
72-
73-- `coordination/tasks/T-021-runtime-bootstrap.md`
74-- `docs/runtime/README.md`
75-- `docs/runtime/environment.md`
76-- `docs/runtime/launchd.md`
77-- `docs/runtime/layout.md`
78-- `ops/launchd/so.makefile.baa-conductor.plist`
79-- `ops/launchd/so.makefile.baa-worker-runner.plist`
80-- `ops/launchd/so.makefile.baa-status-api.plist`
81-- `scripts/runtime/bootstrap.sh`
82-- `scripts/runtime/check-launchd.sh`
83-- `scripts/runtime/common.sh`
84-- `scripts/runtime/install-launchd.sh`
85-- `scripts/runtime/reload-launchd.sh`
86-
87-## commands_run
88-
89-- `npx --yes pnpm install`
90-- `chmod +x scripts/runtime/bootstrap.sh scripts/runtime/install-launchd.sh scripts/runtime/reload-launchd.sh scripts/runtime/check-launchd.sh`
91-- `bash -n scripts/runtime/*.sh`
92-- `plutil -lint ops/launchd/so.makefile.baa-conductor.plist ops/launchd/so.makefile.baa-worker-runner.plist ops/launchd/so.makefile.baa-status-api.plist`
93-- `git diff --check`
94-- `./scripts/runtime/bootstrap.sh --repo-dir "$TMP_REPO"`
95-- `./scripts/runtime/install-launchd.sh --repo-dir "$TMP_REPO" --home-dir "$TMP_HOME" --install-dir "$TMP_INSTALL" --node mini --all-services --shared-token test-token`
96-- `./scripts/runtime/check-launchd.sh --repo-dir "$TMP_REPO" --home-dir "$TMP_HOME" --install-dir "$TMP_INSTALL" --node mini --all-services --shared-token test-token`
97-- `./scripts/runtime/reload-launchd.sh --install-dir "$TMP_INSTALL" --all-services --dry-run`
98-
99-## result
100-
101-- 已新增 `scripts/runtime/bootstrap.sh`、`install-launchd.sh`、`check-launchd.sh`、`reload-launchd.sh` 与共享 helper,使 runtime 目录初始化、plist 安装副本渲染、静态校验、`launchctl` 重载都有可执行脚本。
102-- 已把 `docs/runtime/**` 改为以脚本驱动流程为准,补充 `--node`、`--repo-dir`、`--shared-token`、`--scope` 等输入约定,并明确默认只安装 `conductor`、其它模板需显式 opt-in。
103-- 已小幅修正 `ops/launchd/*.plist` 说明并补入 `BAA_STATE_DIR`,同时在临时 repo 上完成 bootstrap -> install -> check -> reload(dry-run) 的整链验证。
104-
105-## risks
106-
107-- 本任务只验证了 plist 渲染、静态检查和 dry-run `launchctl` 命令,没有在本机真实 `bootstrap` 任何 launchd 服务;实际目标机上仍需再做一次权限和 domain 验证。
108-- `worker-runner` 与 `status-api` 模板现在可以被脚本渲染和校验,但是否作为常驻服务启用仍取决于各自宿主进程接线进度;默认流程因此只安装 `conductor`。
109-
110-## next_handoff
111-
112-- 在真实 `mini` / `mac` 节点上按文档顺序执行 `bootstrap.sh`、`npx --yes pnpm -r build`、`install-launchd.sh`、`check-launchd.sh`,确认安装副本和 runtime 目录都落在预期路径。
113-- 真正准备加载服务时,先运行 `reload-launchd.sh --dry-run` 复核命令;确认无误后再去掉 `--dry-run`。若后续 runner/status 宿主进程落地,可再显式加 `--service worker-runner` 或 `--service status-api`。
114-
115-开始时建议直接把 `status` 改为 `in_progress`。
116-
117-做完并推送后:
118-
119-- 如果等待整合,改为 `review`
120-- 如果确认结束,改为 `done`
1@@ -1,117 +0,0 @@
2----
3-task_id: T-022
4-title: Nginx 与 Cloudflare DNS 自动化
5-status: done
6-branch: feat/T-022-ops-automation
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@458d7cf
9-depends_on:
10- - T-008
11-write_scope:
12- - ops/nginx/**
13- - docs/ops/**
14- - scripts/ops/**
15-updated_at: 2026-03-22
16----
17-
18-# T-022 Nginx 与 Cloudflare DNS 自动化
19-
20-## 目标
21-
22-把当前的 Nginx 配置和 Cloudflare DNS 管理推进到“有脚本辅助同步和部署”的程度,减少手工改记录、手工改配置的步骤。
23-
24-## 本任务包含
25-
26-- 在 `scripts/ops/**` 下补 Cloudflare DNS 管理脚本
27-- 把当前公网域名与内网 Tailscale `100.x` 的关系写成可执行模板
28-- 补 Nginx 部署/校验辅助脚本或分发模板
29-- 收口 `docs/ops/**` 中的操作步骤
30-
31-## 本任务不包含
32-
33-- 实际修改线上 DNS 记录
34-- 实际重载线上 Nginx
35-- 修改 control-api-worker、conductor-daemon、status-api 代码
36-
37-## 建议起始文件
38-
39-- `ops/nginx/baa-conductor.conf`
40-- `ops/nginx/includes/*.conf`
41-- `docs/ops/README.md`
42-- `scripts/ops/`
43-
44-## 交付物
45-
46-- DNS 辅助脚本
47-- Nginx 校验/部署辅助脚本或模板
48-- 对应运维文档收口
49-
50-## 验收
51-
52-- 如果新增 shell 脚本:`bash -n scripts/ops/*.sh`
53-- `git diff --check`
54-- 文档与脚本中的域名 / Tailscale `100.x` 方案一致
55-
56-## 更新要求
57-
58-完成时更新 frontmatter 的:
59-
60-- `status`
61-- `base_ref`
62-- `updated_at`
63-
64-并补充下面这些内容:
65-
66-## files_changed
67-
68-- `coordination/tasks/T-022-ops-automation.md`
69-- `docs/ops/README.md`
70-- `ops/nginx/templates/baa-conductor.conf.template`
71-- `ops/nginx/templates/includes/direct-node-auth.conf.template`
72-- `scripts/ops/baa-conductor.env.example`
73-- `scripts/ops/cloudflare-dns-plan.mjs`
74-- `scripts/ops/cloudflare-dns-plan.sh`
75-- `scripts/ops/lib/ops-config.mjs`
76-- `scripts/ops/nginx-sync-plan.mjs`
77-- `scripts/ops/nginx-sync-plan.sh`
78-
79-## commands_run
80-
81-- `git worktree add /Users/george/code/baa-conductor-T022 -b feat/T-022-ops-automation 458d7cf`
82-- `npx --yes pnpm install`
83-- `chmod +x scripts/ops/*.sh scripts/ops/*.mjs`
84-- `bash -n scripts/ops/*.sh`
85-- `node --check scripts/ops/cloudflare-dns-plan.mjs`
86-- `node --check scripts/ops/nginx-sync-plan.mjs`
87-- `node --check scripts/ops/lib/ops-config.mjs`
88-- `scripts/ops/cloudflare-dns-plan.sh --env scripts/ops/baa-conductor.env.example`
89-- `scripts/ops/nginx-sync-plan.sh --env scripts/ops/baa-conductor.env.example --check-repo --bundle-dir .tmp/ops/baa-conductor-nginx`
90-- `git diff --check`
91-- `git commit -m "feat(ops): add nginx and cloudflare dns automation helpers"`
92-- `git push -u origin feat/T-022-ops-automation`
93-
94-## result
95-
96-- 新增 `scripts/ops/baa-conductor.env.example`,把公网域名、VPS 公网 IP、Tailscale `100.x` 回源和 Nginx 安装路径收口到一份可执行 inventory 模板
97-- 新增 `scripts/ops/cloudflare-dns-plan.{mjs,sh}`,可渲染目标 DNS 记录、可选用 Cloudflare GET API 对比现网,并输出预览用的 `curl` shell 脚本,但默认不写线上 DNS
98-- 新增 `scripts/ops/nginx-sync-plan.{mjs,sh}` 与 `ops/nginx/templates/**`,可按 inventory 渲染 Nginx 配置、检查仓库默认配置是否漂移,并打出包含 `deploy-on-vps.sh` 的部署 bundle
99-- `docs/ops/README.md` 已改为 inventory 驱动的运维流程说明,覆盖 DNS 计划、Nginx bundle、VPS 分发、证书与验证步骤
100-
101-## risks
102-
103-- 本次没有连接真实 Cloudflare zone;`--fetch-current` 和预览 shell 仍需在填入真实 Zone ID / token 后做一次人工核对
104-- 本次没有在真实 VPS 上执行 `deploy-on-vps.sh`、`nginx -t` 或 `systemctl reload nginx`;证书路径、权限和 systemd 服务名仍需线上确认
105-- 真实 inventory 预计放在仓库外,若运维人员绕开 inventory 手工改 Nginx 或 DNS,模板与线上状态仍可能漂移
106-
107-## next_handoff
108-
109-- 在仓库外复制并填写 `scripts/ops/baa-conductor.env.example`,补上真实 VPS 公网 IP、Cloudflare Zone ID 与证书路径
110-- 导出 `CLOUDFLARE_API_TOKEN` 后运行 `scripts/ops/cloudflare-dns-plan.sh --fetch-current --emit-shell ...`,审阅差异和预览脚本
111-- 运行 `scripts/ops/nginx-sync-plan.sh --env <real-env> --bundle-dir <dir>`,把 bundle 分发到 VPS,先执行 `sudo ./deploy-on-vps.sh`,确认 `nginx -t` 后再显式执行 `sudo ./deploy-on-vps.sh --reload`
112-
113-开始时建议直接把 `status` 改为 `in_progress`。
114-
115-做完并推送后:
116-
117-- 如果等待整合,改为 `review`
118-- 如果确认结束,改为 `done`
1@@ -1,112 +0,0 @@
2----
3-task_id: T-023
4-title: Control API 本地 D1 与 smoke
5-status: done
6-branch: feat/T-023-control-api-smoke
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@6505a31
9-depends_on:
10- - T-018
11-write_scope:
12- - apps/control-api-worker/**
13- - tests/control-api/**
14- - scripts/cloudflare/**
15-updated_at: 2026-03-22T02:16:15+0800
16----
17-
18-# T-023 Control API 本地 D1 与 smoke
19-
20-## 目标
21-
22-把 `control-api-worker` 从“有 Worker / D1 配置模板”推进到“能在本地做最小 D1 绑定与 smoke 验证”的程度。
23-
24-## 本任务包含
25-
26-- 在 `apps/control-api-worker/**` 内补本地 dev / smoke 入口
27-- 在 `tests/control-api/**` 下补最小 smoke 或集成测试
28-- 如有需要,在 `scripts/cloudflare/**` 下补本地准备脚本
29-- 验证 control-api 在本地 D1 / mock D1 条件下的最小读写闭环
30-
31-## 本任务不包含
32-
33-- 修改 conductor-daemon
34-- 修改 status-api
35-- 真实线上 Cloudflare deploy
36-- 修改 `packages/db/**`
37-
38-## 建议起始文件
39-
40-- `apps/control-api-worker/wrangler.jsonc`
41-- `apps/control-api-worker/src/index.ts`
42-- `apps/control-api-worker/src/runtime.ts`
43-- `tests/control-api/`
44-
45-## 交付物
46-
47-- 本地 smoke 路径
48-- 最小 control-api 集成测试
49-- 更明确的本地 D1 准备方式
50-
51-## 验收
52-
53-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
54-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
55-- 能说明如何在本地跑 control-api smoke
56-
57-## 更新要求
58-
59-完成时更新 frontmatter 的:
60-
61-- `status`
62-- `base_ref`
63-- `updated_at`
64-
65-并补充下面这些内容:
66-
67-## files_changed
68-
69-- `coordination/tasks/T-023-control-api-smoke.md`
70-- `apps/control-api-worker/.dev.vars.example`
71-- `apps/control-api-worker/package.json`
72-- `apps/control-api-worker/local/sqlite-d1.mjs`
73-- `apps/control-api-worker/local/harness.mjs`
74-- `apps/control-api-worker/local/dev.mjs`
75-- `apps/control-api-worker/local/smoke.mjs`
76-- `scripts/cloudflare/prepare-control-api-local-db.mjs`
77-- `tests/control-api/control-api-smoke.test.mjs`
78-
79-## commands_run
80-
81-- `git worktree add /Users/george/code/baa-conductor-T023 -b feat/T-023-control-api-smoke 6505a31`
82-- `npx --yes pnpm install`
83-- `node --check apps/control-api-worker/local/sqlite-d1.mjs`
84-- `node --check apps/control-api-worker/local/harness.mjs`
85-- `node --check apps/control-api-worker/local/dev.mjs`
86-- `node --check apps/control-api-worker/local/smoke.mjs`
87-- `node --check scripts/cloudflare/prepare-control-api-local-db.mjs`
88-- `node --check tests/control-api/control-api-smoke.test.mjs`
89-- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
90-- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
91-- `npx --yes pnpm --filter @baa-conductor/control-api-worker db:prepare:local`
92-- `npx --yes pnpm --filter @baa-conductor/control-api-worker smoke -- --db .wrangler/state/local-control-api.sqlite --resetDb`
93-- `npx --yes pnpm --filter @baa-conductor/control-api-worker test:integration`
94-- `node local/dev.mjs --smoke --resetDb`
95-- `git diff --check`
96-
97-## result
98-
99-- 在 `apps/control-api-worker/local/**` 新增了 SQLite-backed D1 shim、本地 harness、`dev` server 和 `smoke` runner;`pnpm --filter @baa-conductor/control-api-worker dev` 现在会先构建,再在本地起一个可直接用 Bearer token 访问的 control-api。
100-- 在 `tests/control-api/control-api-smoke.test.mjs` 增加了最小集成 smoke,覆盖 `system.pause -> system.state`、`leader.acquire -> system.state`、`tasks.create -> tasks.read` 三条最小读写闭环。
101-- 在 `scripts/cloudflare/prepare-control-api-local-db.mjs` 增加了本地 DB 准备脚本,并在包脚本中暴露 `db:prepare:local`、`smoke`、`test:integration` 入口,方便后续联调复用。
102-
103-## risks
104-
105-- 当前本地验证使用的是 Node `node:sqlite` 驱动实现的 D1-compatible shim,而不是 `wrangler dev` 内建的真实本地 D1 运行时;SQL 行为与当前仓库 schema 一致,但仍可能与 Cloudflare 边缘环境存在细小差异。
106-- 本次 smoke 聚焦已经实现的读写路由;`tasks.plan`、`tasks.claim`、`steps.*`、`tasks.logs.read`、`runs.read` 这些仍保持占位或未在测试中覆盖。
107-- `dev` 入口是 Node HTTP wrapper,不包含 Cloudflare 特有的 request metadata 或 Worker 生命周期细节;如果后续要验证这些差异,仍需单独补 wrangler-local smoke。
108-
109-## next_handoff
110-
111-- 后续如果要把 control-api 纳入 `T-024` 端到端 smoke,可直接复用 `pnpm --filter @baa-conductor/control-api-worker dev` 和 `tests/control-api/control-api-smoke.test.mjs` 里的本地 harness。
112-- 如果需要更贴近 Cloudflare 本地运行时,再补一层 `wrangler dev --local` / `wrangler d1` 路径,把现在的 SQLite shim smoke 与 wrangler smoke 并排保留。
113-- 若后续开始实现 `tasks.plan`、`tasks.claim` 或 `steps.*` 的 durable 写入,应沿用当前 harness,把新增路由接到同一套本地 D1 smoke 里继续扩展。
+0,
-110
1@@ -1,110 +0,0 @@
2----
3-task_id: T-024
4-title: 端到端 smoke harness
5-status: done
6-branch: feat/T-024-e2e-smoke
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@288c748
9-depends_on:
10- - T-019
11- - T-020
12- - T-021
13- - T-022
14-write_scope:
15- - tests/e2e/**
16- - scripts/smoke/**
17-updated_at: 2026-03-22T02:16:15+0800
18----
19-
20-# T-024 端到端 smoke harness
21-
22-## 目标
23-
24-补一套可重复运行的端到端 smoke harness,把本地起服务、探活、状态读取和最小主备流程串起来。
25-
26-## 本任务包含
27-
28-- 在 `tests/e2e/**` 下补最小端到端 smoke
29-- 在 `scripts/smoke/**` 下补启动 / 检查辅助脚本
30-- 把 conductor、status-api、control-api 的最小读取面串起来
31-- 输出可重复执行的 smoke 流程
32-
33-## 本任务不包含
34-
35-- 修改 `apps/control-api-worker/**`
36-- 修改 `apps/conductor-daemon/**`
37-- 修改 `apps/status-api/**`
38-- 真实线上部署
39-
40-## 建议起始文件
41-
42-- `tests/e2e/`
43-- `scripts/smoke/`
44-- `docs/ops/README.md`
45-
46-## 交付物
47-
48-- 本地 smoke harness
49-- 一套明确的启动与验证步骤
50-- 可供后续 failover 演练复用的基础流程
51-
52-## 验收
53-
54-- 如果新增 shell 脚本:`bash -n scripts/smoke/*.sh`
55-- `git diff --check`
56-- 能说明如何一键或半自动执行 smoke
57-
58-## 更新要求
59-
60-完成时更新 frontmatter 的:
61-
62-- `status`
63-- `base_ref`
64-- `updated_at`
65-
66-并补充下面这些内容:
67-
68-## files_changed
69-
70-- coordination/tasks/T-024-e2e-smoke.md
71-- scripts/smoke/README.md
72-- scripts/smoke/check-stack.sh
73-- scripts/smoke/control-api-local.mjs
74-- scripts/smoke/d1-sqlite.mjs
75-- scripts/smoke/run-e2e.sh
76-- scripts/smoke/stack-cli.mjs
77-- scripts/smoke/start-stack.sh
78-- scripts/smoke/status-api-local.mjs
79-- scripts/smoke/stop-stack.sh
80-- tests/e2e/smoke.test.mjs
81-
82-## commands_run
83-
84-- npx --yes pnpm install
85-- node --check scripts/smoke/d1-sqlite.mjs
86-- node --check scripts/smoke/control-api-local.mjs
87-- node --check scripts/smoke/status-api-local.mjs
88-- node --check scripts/smoke/stack-cli.mjs
89-- bash -n scripts/smoke/*.sh
90-- bash scripts/smoke/run-e2e.sh --json
91-- node --test tests/e2e/smoke.test.mjs
92-- bash scripts/smoke/start-stack.sh --json --state-dir /Users/george/code/baa-conductor-T024-e2e-smoke/tmp/manual-smoke-check
93-- bash scripts/smoke/check-stack.sh --json --state-dir /Users/george/code/baa-conductor-T024-e2e-smoke/tmp/manual-smoke-check --expected-leader smoke-mini
94-- bash scripts/smoke/stop-stack.sh --json --state-dir /Users/george/code/baa-conductor-T024-e2e-smoke/tmp/manual-smoke-check
95-- git diff --check
96-
97-## result
98-
99-- 新增本地 smoke stack CLI 和 shell 包装脚本,可一键或分步启动、检查、停止 control-api、主备 conductor、status-api。
100-- 新增基于 SQLite 的 smoke-only D1 适配层,用于本地共享 control-api 和 status-api 的最小 durable 视图。
101-- 新增端到端 smoke 测试,覆盖探活、`/v1/system/state`、`/v1/status`、queued task 可见性和主备 failover。
102-
103-## risks
104-
105-- `scripts/smoke/d1-sqlite.mjs` 是本地 smoke 适配层,不等价于真实 Cloudflare D1 / Wrangler 运行时语义。
106-- 当前 smoke 聚焦最小读取面和 lease failover,不覆盖 task claim、worker 执行和真实线上部署链路。
107-
108-## next_handoff
109-
110-- 后续任务可直接复用 `bash scripts/smoke/run-e2e.sh --json` 作为本地回归入口。
111-- T-025 可复用 `start-stack.sh` / `check-stack.sh` / `stop-stack.sh` 和 `stack-cli.mjs` 的状态目录结构继续扩展 failover rehearsal。
1@@ -1,100 +0,0 @@
2----
3-task_id: T-025
4-title: Failover rehearsal 与 Runbook
5-status: done
6-branch: feat/T-025-failover-rehearsal
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@6505a31
9-depends_on:
10- - T-019
11- - T-021
12- - T-022
13-write_scope:
14- - docs/ops/**
15- - scripts/failover/**
16-updated_at: 2026-03-22T02:16:15+0800
17----
18-
19-# T-025 Failover rehearsal 与 Runbook
20-
21-## 目标
22-
23-把当前主备设计推进到“有可执行 rehearsal 步骤和清晰运维 runbook”的程度。
24-
25-## 本任务包含
26-
27-- 在 `scripts/failover/**` 下补 failover rehearsal 辅助脚本
28-- 在 `docs/ops/**` 下补 planned failover / emergency failover / switchback runbook
29-- 把 Tailscale `100.x`、公网域名、launchd、Nginx 的配合关系写清楚
30-
31-## 本任务不包含
32-
33-- 修改 app 业务代码
34-- 真实执行线上 failover
35-- 修改 `baa-firefox`
36-
37-## 建议起始文件
38-
39-- `docs/ops/README.md`
40-- `scripts/failover/`
41-
42-## 交付物
43-
44-- 可执行或半自动的 rehearsal 辅助脚本
45-- 清晰的 failover 运维手册
46-
47-## 验收
48-
49-- 如果新增 shell 脚本:`bash -n scripts/failover/*.sh`
50-- `git diff --check`
51-- 文档里明确 planned / emergency / switchback 三种路径
52-
53-## 更新要求
54-
55-完成时更新 frontmatter 的:
56-
57-- `status`
58-- `base_ref`
59-- `updated_at`
60-
61-并补充下面这些内容:
62-
63-## files_changed
64-
65-- `coordination/tasks/T-025-failover-rehearsal.md`
66-- `docs/ops/README.md`
67-- `docs/ops/failover-topology.md`
68-- `docs/ops/planned-failover.md`
69-- `docs/ops/emergency-failover.md`
70-- `docs/ops/switchback.md`
71-- `scripts/failover/common.sh`
72-- `scripts/failover/print-topology.sh`
73-- `scripts/failover/rehearsal-check.sh`
74-- `scripts/failover/print-checklist.sh`
75-
76-## commands_run
77-
78-- `npx --yes pnpm install`
79-- `chmod +x scripts/failover/*.sh`
80-- `bash -n scripts/failover/*.sh`
81-- `./scripts/failover/print-topology.sh --env scripts/ops/baa-conductor.env.example`
82-- `./scripts/failover/print-checklist.sh --scenario planned --env scripts/ops/baa-conductor.env.example`
83-- `./scripts/failover/rehearsal-check.sh --env scripts/ops/baa-conductor.env.example --skip-public --skip-control-api`
84-- `git diff --check`
85-
86-## result
87-
88-- 新增 `scripts/failover` 只读辅助脚本,用于输出主备拓扑、生成场景化 checklist,并对 public/direct/control-api 做 rehearsal 校验。
89-- 在 `docs/ops` 下补齐 failover topology、planned failover、emergency failover、switchback 四份文档。
90-- runbook 明确写清 Cloudflare DNS 固定到 VPS、VPS Nginx 回源 Tailscale `100.x`、节点 `launchd` 控制本地 conductor 存活,以及当前 Nginx 只按连通性而非 lease 感知切换。
91-
92-## risks
93-
94-- 当前 `GET /v1/system/state` 仍只返回扁平 `holder_id/mode/term/lease_expires_at`,脚本只能通过 `holder_id` 前缀推断 leader 节点。
95-- 当前没有真正的 `promote/demote` 维护接口;planned failover 和 switchback 仍依赖 `pause/drain` 加本机 `launchctl bootout/reload`。
96-- 如果 mini 仍可达但逻辑上不该接流量,公网入口可能仍命中 mini;emergency runbook 已记录这种情况下需要 VPS Nginx 热修。
97-
98-## next_handoff
99-
100-- 在真实节点或 staging 上按 runbook 做一次 dry-run / rehearsal,确认 `mini`、`mac`、VPS 三侧命令、权限和路径与文档一致。
101-- 后续可补 `system.state` 的 leader host 字段,以及真正的 maintenance `promote/demote` API,减少对人工 `launchctl` 与 Nginx 热修的依赖。
1@@ -1,107 +0,0 @@
2----
3-task_id: T-026
4-title: baa-firefox 实际接线
5-status: done
6-branch: feat/T-026-firefox-integration
7-repo: /Users/george/code/baa/baa-firefox
8-base_ref: main@045b1a9
9-depends_on:
10- - T-018
11- - T-019
12- - T-020
13-write_scope:
14- - background.js
15- - controller.html
16- - controller.js
17- - content-script.js
18- - page-interceptor.js
19- - package.json
20- - package-lock.json
21- - docs/**
22-updated_at: 2026-03-22T02:16:15+0800
23----
24-
25-# T-026 baa-firefox 实际接线
26-
27-## 目标
28-
29-把 `baa-firefox` 从“已有协议与大部分基础能力”推进到“真正能读取 conductor 状态并执行 pause / resume / drain”的程度。
30-
31-## 本任务包含
32-
33-- 在 `baa-firefox` 仓库里把插件接到 `baa-conductor` 的控制面
34-- 读取 system state、leader、queue 基本信息
35-- 提供 pause / resume / drain 的实际调用
36-- 给可见 control 面板或 Claude 页面加最小可见入口
37-
38-## 本任务不包含
39-
40-- 修改 `baa-conductor` 仓库代码
41-- 重做整个插件架构
42-- 自动化完整浏览器工作流
43-
44-## 建议起始文件
45-
46-- `/Users/george/code/baa/baa-firefox/background.js`
47-- `/Users/george/code/baa/baa-firefox/controller.html`
48-- `/Users/george/code/baa/baa-firefox/controller.js`
49-- `/Users/george/code/baa/baa-firefox/content-script.js`
50-
51-## 交付物
52-
53-- 插件真实读取 `system/state`
54-- 插件真实发送 `pause` / `resume` / `drain`
55-- 最小 UI 与状态文案
56-
57-## 验收
58-
59-- `baa-firefox` 仓库内的最小本地构建或静态校验通过
60-- 能说明如何在浏览器里验证控制动作
61-
62-## 更新要求
63-
64-完成时更新 frontmatter 的:
65-
66-- `status`
67-- `base_ref`
68-- `updated_at`
69-
70-并补充下面这些内容:
71-
72-## files_changed
73-
74-- `background.js`
75-- `content-script.js`
76-- `controller.html`
77-- `controller.js`
78-- `docs/conductor-control.md`
79-- `package.json`
80-- `package-lock.json`
81-- `page-interceptor.js`
82-- `.gitignore`
83-
84-## commands_run
85-
86-- `npm install`
87-- `node --check background.js`
88-- `node --check controller.js`
89-- `node --check content-script.js`
90-- `node --check page-interceptor.js`
91-
92-## result
93-
94-- Firefox 插件已经接上 `baa-conductor` control plane,可读取 `/v1/system/state`,并实际发送 `pause` / `resume` / `drain`。
95-- `controller.html` / `controller.js` 新增了 control API base URL、bearer token、状态快照展示和动作按钮。
96-- `background.js` 会根据最新 control snapshot 更新 badge;`content-script.js` 在 Claude 页面右下角提供最小浮层入口。
97-- 新增 [`docs/conductor-control.md`](/Users/george/code/baa/baa-firefox/docs/conductor-control.md) 说明接线方式、状态字段和当前限制。
98-
99-## risks
100-
101-- 本次没有修改 `manifest.json`,因此 Control API 仍需要落在当前扩展 CSP 允许的地址范围内。
102-- 当前最稳妥的部署方式仍是把 conductor control API 暴露为与现有本地桥接地址同源的入口,而不是任意远端地址。
103-- 这次只做了静态 JS 校验,没有做完整浏览器内交互回归。
104-
105-## next_handoff
106-
107-- 待 `T-024` 端到端 smoke 补齐后,把 Firefox 控制动作纳入 smoke / runbook。
108-- 合并 `baa-firefox` 分支前,建议再做一次真实浏览器点击验证,确认 badge、浮层和 controller 页面状态同步正常。
1@@ -1,94 +0,0 @@
2----
3-task_id: T-027
4-title: launchd 节点验证与 On-Node 检查
5-status: done
6-branch: feat/T-027-node-verification
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@6505a31
9-depends_on:
10- - T-019
11- - T-020
12- - T-021
13-write_scope:
14- - docs/runtime/**
15- - scripts/runtime/**
16-updated_at: 2026-03-22T02:16:15+0800
17----
18-
19-# T-027 launchd 节点验证与 On-Node 检查
20-
21-## 目标
22-
23-把当前 launchd/bootstrap 脚本推进到“有更贴近真实节点的 on-node 检查和验证流程”的程度。
24-
25-## 本任务包含
26-
27-- 在 `scripts/runtime/**` 下补 on-node 检查脚本
28-- 在 `docs/runtime/**` 下补 mini / mac 节点验证步骤
29-- 覆盖本地端口、`healthz/readyz/rolez`、status-api 宿主进程、日志路径等检查点
30-
31-## 本任务不包含
32-
33-- 修改 app 业务代码
34-- 真实加载 launchd 服务
35-- 修改 Nginx / DNS
36-
37-## 建议起始文件
38-
39-- `docs/runtime/launchd.md`
40-- `docs/runtime/README.md`
41-- `scripts/runtime/check-launchd.sh`
42-
43-## 交付物
44-
45-- 更接近真实节点操作的验证脚本
46-- 明确的 mini / mac 验证清单
47-
48-## 验收
49-
50-- 如果新增 shell 脚本:`bash -n scripts/runtime/*.sh`
51-- `git diff --check`
52-- 文档里明确 mini 与 mac 的 on-node 检查顺序
53-
54-## 更新要求
55-
56-完成时更新 frontmatter 的:
57-
58-- `status`
59-- `base_ref`
60-- `updated_at`
61-
62-并补充下面这些内容:
63-
64-## files_changed
65-
66-- `coordination/tasks/T-027-node-verification.md`
67-- `docs/runtime/README.md`
68-- `docs/runtime/launchd.md`
69-- `docs/runtime/node-verification.md`
70-- `scripts/runtime/common.sh`
71-- `scripts/runtime/check-node.sh`
72-
73-## commands_run
74-
75-- `npx --yes pnpm install`
76-- `bash -n scripts/runtime/*.sh`
77-- `git diff --check`
78-- `scripts/runtime/check-node.sh --help`
79-- `scripts/runtime/check-node.sh --repo-dir <tmp_repo> --node mini --service conductor --service status-api --skip-static-check --local-api-base http://127.0.0.1:4417 --status-api-base http://127.0.0.1:4418 --expected-rolez leader`
80-
81-## result
82-
83-- 新增 `scripts/runtime/check-node.sh`,把节点验证推进到运行态层:覆盖 runtime 静态校验复用、本地端口、conductor `/healthz` `/readyz` `/rolez`、status-api 宿主进程与 `/healthz` `/v1/status` `/v1/status/ui`、以及 launchd 日志文件存在性。
84-- `scripts/runtime/common.sh` 补了 status-api 默认地址、on-node 默认服务集合和进程匹配辅助逻辑,避免节点约定散落在多个脚本里。
85-- `docs/runtime/README.md`、`docs/runtime/launchd.md`、`docs/runtime/node-verification.md` 明确了 mini/mac 的静态检查顺序、on-node 检查顺序,以及 steady-state 下 `mini=leader`、`mac=standby` 的 `rolez` 预期。
86-
87-## risks
88-
89-- 没有在真实 `mini` / `mac` 节点上执行 `--check-loaded`,因此 `launchctl print gui/...` / `launchctl print system/...` 只完成了脚本级接线,没有做实机验证。
90-- `status-api /v1/status` 目前按返回体中包含 `"ok": true` 做断言;如果后续响应格式改成不同 JSON 序列化风格,脚本可能需要同步微调。
91-
92-## next_handoff
93-
94-- 在真实 `mini` 与 `mac` 节点上各跑一次 `scripts/runtime/check-node.sh --check-loaded ...`,确认 launchd 域、日志文件、端口和探针都与文档一致。
95-- 如果某个节点不常驻 `status-api`,在落地时显式使用 `--service conductor`,并确认运维文档是否需要额外区分“仅 conductor 节点”和“conductor + status-api 节点”。
1@@ -1,154 +0,0 @@
2----
3-task_id: T-028
4-title: 真实 Cloudflare / VPS 上线执行
5-status: done
6-branch: feat/T-028-real-rollout-v2
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@be74d58
9-depends_on:
10- - T-018
11- - T-019
12- - T-020
13- - T-021
14- - T-022
15- - T-023
16- - T-024
17- - T-025
18- - T-027
19-write_scope:
20- - docs/ops/**
21- - docs/runtime/**
22- - ops/nginx/**
23- - apps/control-api-worker/**
24- - scripts/ops/**
25- - scripts/runtime/**
26-updated_at: 2026-03-22T03:32:57+0800
27----
28-
29-# T-028 真实 Cloudflare / VPS 上线执行
30-
31-## 目标
32-
33-把当前已经完成的本地联调骨架真正部署到 Cloudflare、VPS、mini、mac,并形成一套可复查的上线记录。
34-
35-## 本任务包含
36-
37-- 使用当前 Cloudflare token / wrangler / D1 配置完成 control-api 真实部署
38-- 使用当前 DNS 脚本或命令把二级域名切到预期入口
39-- 在 VPS 上落地 Nginx 配置并完成校验 / reload
40-- 在 mini / mac 上落地 runtime 目录、launchd 安装与基础探活
41-- 把真实环境 URL、端口、角色、检查结果写回文档与任务卡
42-
43-## 本任务不包含
44-
45-- 重写应用架构
46-- 修改 `baa-firefox` 仓库
47-- 长时间稳定性结论
48-
49-## 建议起始文件
50-
51-- `docs/ops/README.md`
52-- `docs/runtime/README.md`
53-- `ops/nginx/baa-conductor.conf`
54-- `apps/control-api-worker/wrangler.jsonc`
55-- `scripts/ops/cloudflare-dns-plan.sh`
56-- `scripts/runtime/install-launchd.sh`
57-
58-## 交付物
59-
60-- 真实可访问的 control-api / status-api / conductor 入口
61-- VPS、mini、mac 的上线执行记录
62-- 更新后的运维文档与上线注意事项
63-
64-## 验收
65-
66-- 能说明 control-api 的真实外网入口和至少一个内网入口
67-- 能说明 VPS 上 Nginx 已完成校验与 reload
68-- 能说明 mini / mac 节点至少完成一次 on-node 探活
69-- `git diff --check`
70-
71-## 更新要求
72-
73-完成时更新 frontmatter 的:
74-
75-- `status`
76-- `base_ref`
77-- `updated_at`
78-
79-并补充下面这些内容:
80-
81-## files_changed
82-
83-- `coordination/tasks/T-028-real-rollout.md`
84-- `scripts/runtime/check-node.sh`
85-- `docs/runtime/launchd.md`
86-- `docs/runtime/node-verification.md`
87-- `docs/runtime/real-rollout-2026-03-22.md`
88-- `docs/ops/README.md`
89-- `docs/ops/real-rollout-2026-03-22.md`
90-
91-## commands_run
92-
93-- 本地干净 worktree:`git -C /Users/george/code/baa-conductor-main-merge worktree add -b feat/T-028-real-rollout-v2 /Users/george/code/baa-conductor-T028-v2 be74d585251f20ff5bd74ae17f6f6b011ff0bd34`
94-- 本地新 worktree 首命令:`cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm install`
95-- 本地构建:`cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm -r build`
96-- `mini` 本机:`./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor-T028-v2`
97-- `mini` 本机:`set -a; source /Users/george/.config/baa-conductor/control-api-worker.secrets.env; set +a; ./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-host 100.71.210.78`
98-- `mini` 本机:`./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-host 100.71.210.78`
99-- `mini` 本机:`./scripts/runtime/reload-launchd.sh --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents`
100-- `mini` 本机:`./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-base http://100.71.210.78:4318 --status-api-host 100.71.210.78 --expected-rolez leader --check-loaded`
101-- 本地到双节点探活:`curl http://100.71.210.78:4317/healthz`、`curl http://100.71.210.78:4318/healthz`、`curl http://100.112.239.13:4317/healthz`、`curl http://100.112.239.13:4318/healthz`
102-- 同步到 `mac`:`rsync -az --delete --exclude '.git' --exclude 'node_modules' /Users/george/code/baa-conductor-T028-v2/ george@100.112.239.13:/Users/george/code/baa-conductor-T028-v2/`
103-- `mac` 远端:`cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm install`
104-- `mac` 远端:`cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm -r build`
105-- `mac` 远端:`./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor-T028-v2`
106-- `mac` 远端:`set -a; source /Users/george/.config/baa-conductor/control-api-worker.secrets.env; set +a; ./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-host 100.112.239.13`
107-- `mac` 远端:`./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-host 100.112.239.13`
108-- `mac` 远端:`./scripts/runtime/reload-launchd.sh --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents`
109-- `mac` 远端:`./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-base http://100.112.239.13:4318 --status-api-host 100.112.239.13 --expected-rolez standby --check-loaded`
110-- 生成 DNS / Nginx 计划:`scripts/ops/cloudflare-dns-plan.sh --env /Users/george/.config/baa-conductor/ops.env --fetch-current --emit-shell .tmp/ops/cloudflare-dns-preview.sh --output .tmp/ops/cloudflare-dns-plan.json`、`scripts/ops/nginx-sync-plan.sh --env /Users/george/.config/baa-conductor/ops.env --bundle-dir .tmp/ops/baa-conductor-nginx`
111-- VPS 基线:`ssh -p 2222 root@192.210.137.113 'hostname; tailscale ip -4; nginx -v; ss -ltnp | grep -E ":(80|443|2222)\\b"'`
112-- VPS upstream 探活:`ssh -p 2222 root@192.210.137.113 'curl http://100.71.210.78:4317/healthz && curl http://100.112.239.13:4317/healthz'`
113-- VPS status-api 探活:`ssh -p 2222 root@192.210.137.113 'curl http://100.71.210.78:4318/healthz && curl http://100.112.239.13:4318/healthz'`
114-- VPS 依赖:`ssh -p 2222 root@192.210.137.113 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y apache2-utils python3-certbot-dns-cloudflare'`
115-- VPS Cloudflare DNS challenge 凭据:`printf 'dns_cloudflare_api_token = %s\n' "$CLOUDFLARE_API_TOKEN" | ssh -p 2222 root@192.210.137.113 'install -d -m 700 /root/.secrets/certbot && cat > /root/.secrets/certbot/cloudflare.ini && chmod 600 /root/.secrets/certbot/cloudflare.ini'`
116-- VPS 三张证书:三次 `ssh -p 2222 root@192.210.137.113 'certbot certonly --non-interactive --agree-tos --register-unsafely-without-email --preferred-challenges dns-01 --authenticator dns-cloudflare --dns-cloudflare-credentials /root/.secrets/certbot/cloudflare.ini --dns-cloudflare-propagation-seconds 30 --cert-name <host> -d <host>'`
117-- 本地直连域名密码文件:`/Users/george/.config/baa-conductor/direct-node-basic-auth.env`
118-- VPS htpasswd:`printf '%s\n' "$BAA_DIRECT_NODE_BASIC_AUTH_PASSWORD" | ssh -p 2222 root@192.210.137.113 "htpasswd -i -c /etc/nginx/.htpasswd-baa-conductor conductor-ops && chmod 640 /etc/nginx/.htpasswd-baa-conductor && chown root:www-data /etc/nginx/.htpasswd-baa-conductor"`
119-- Nginx bundle 同步:`rsync -az -e 'ssh -p 2222' /Users/george/code/baa-conductor-T028-v2/.tmp/ops/baa-conductor-nginx/ root@192.210.137.113:/tmp/baa-conductor-nginx/`
120-- VPS 安装 / 校验 / reload:`ssh -p 2222 root@192.210.137.113 'cd /tmp/baa-conductor-nginx && ./deploy-on-vps.sh'`、`ssh -p 2222 root@192.210.137.113 'cd /tmp/baa-conductor-nginx && ./deploy-on-vps.sh --reload'`
121-- Cloudflare DNS 初次切换:`bash /Users/george/code/baa-conductor-T028-v2/.tmp/ops/cloudflare-dns-preview.sh`
122-- Cloudflare DNS 改为 DNS-only:`scripts/ops/cloudflare-dns-plan.sh --env /Users/george/.config/baa-conductor/ops.env --fetch-current --emit-shell .tmp/ops/cloudflare-dns-proxy-off.sh --output .tmp/ops/cloudflare-dns-plan-proxy-off.json`、`bash /Users/george/code/baa-conductor-T028-v2/.tmp/ops/cloudflare-dns-proxy-off.sh`
123-- 验收:`curl -H "Authorization: Bearer $CONTROL_API_OPS_ADMIN_TOKEN" https://control-api.makefile.so/v1/system/state`、`curl --resolve conductor.makefile.so:443:192.210.137.113 https://conductor.makefile.so/healthz`、`curl --resolve conductor.makefile.so:443:192.210.137.113 https://conductor.makefile.so/rolez`、`curl -u conductor-ops:... --resolve mini-conductor.makefile.so:443:192.210.137.113 https://mini-conductor.makefile.so/rolez`、`curl -u conductor-ops:... --resolve mac-conductor.makefile.so:443:192.210.137.113 https://mac-conductor.makefile.so/rolez`
124-- 收尾校验:`bash -n scripts/ops/*.sh scripts/runtime/*.sh`、`git diff --check`
125-- 提交与推送:`git commit -m "Complete T-028 real rollout"`、`git push -u origin feat/T-028-real-rollout-v2`
126-
127-## result
128-
129-- `mini` 与 `mac` 已从旧的 loopback-only 配置切到各自的 Tailscale `100.x` 监听:`mini 100.71.210.78:4317/4318`、`mac 100.112.239.13:4317/4318`,两台节点都通过了真实 `check-node.sh --check-loaded`。
130-- VPS `racknerd-ff37952` 已真实打通到两台节点的 `4317` 与 `4318`;`curl http://100.71.210.78:4317/healthz`、`curl http://100.112.239.13:4317/healthz` 以及对应 `4318` 探针都返回 `ok`。
131-- VPS 已安装 `apache2-utils` 与 `python3-certbot-dns-cloudflare`,并通过 Cloudflare DNS challenge 签发三张证书:`conductor.makefile.so`、`mini-conductor.makefile.so`、`mac-conductor.makefile.so`,到期日均为 `2026-06-19`。
132-- `/etc/nginx/.htpasswd-baa-conductor` 已创建,仓库生成的 Nginx bundle 已同步到 VPS,`./deploy-on-vps.sh` 与 `./deploy-on-vps.sh --reload` 都真实通过,`nginx -t` 成功。
133-- Cloudflare DNS 已真实切换。三条 conductor 记录最终状态为 `A -> 192.210.137.113 proxied=false`;Cloudflare API 回读计划为 `noop`,权威 NS `giancarlo.ns.cloudflare.com` 对三个 host 都返回 `192.210.137.113`。
134-- 公网验收闭环完成:`https://control-api.makefile.so/v1/system/state` 返回 `ok=true` 且 `holder_id=mini-main`;`conductor.makefile.so` 入口返回 `healthz=ok`、`rolez=leader`;`mini-conductor.makefile.so` / `mac-conductor.makefile.so` 未认证为 `401`,带 Basic Auth 后分别返回 `leader` / `standby`。
135-- 为了让 Tailscale rollout 的静态校验真实可用,本分支顺手修复了 `scripts/runtime/check-node.sh`,让它把 `--local-api-allowed-hosts` 和 `--status-api-host` 正确传给 `check-launchd.sh`。
136-- 分支已推送:`origin/feat/T-028-real-rollout-v2`
137-
138-## risks
139-
140-- 这三条 conductor DNS 记录当前是 `proxied=false`。原因不是源站问题,而是当前 Cloudflare token 只有 DNS 权限,无法把 zone SSL mode 从疑似 `Flexible` 改到 `Full` / `Full (strict)`;若重新开代理,公网会回到 `301 Location: https://$host$request_uri` 循环。
141-- `mini` 与 `mac` 运行时现在都指向 `/Users/george/code/baa-conductor-T028-v2`,不是文档默认的 `/Users/george/code/baa-conductor`;如果后续切回 canonical 路径,需要重新渲染并 reload launchd 副本。
142-- `mini/mac` 直连域名的 Basic Auth 密码只保存在仓库外的 `/Users/george/.config/baa-conductor/direct-node-basic-auth.env` 和 VPS htpasswd 文件里,没有进入 repo;交接时需要确保整合者知道这份私有文件的位置。
143-
144-## next_handoff
145-
146-- 如需把 `conductor.makefile.so`、`mini-conductor.makefile.so`、`mac-conductor.makefile.so` 重新放回 Cloudflare 代理,先准备带 zone settings 权限的 token,把 `makefile.so` 的 SSL mode 调到 `Full` 或 `Full (strict)`,再把私有 inventory `/Users/george/.config/baa-conductor/ops.env` 里的 `BAA_CF_PROXY_*` 改回 `true` 并重新执行 DNS 计划脚本。
147-- 观察三张证书的首次自动续期是否正常;若要显式演练,可在 VPS 上执行 `certbot renew --dry-run`。
148-- 后续若进入 `T-029`,就直接基于当前 `mini leader / mac standby / VPS ingress` 现网做 smoke、切换与长时间稳定性回归,不需要再重新铺节点监听或公网入口。
149-
150-开始时建议直接把 `status` 改为 `in_progress`。
151-
152-做完并推送后:
153-
154-- 如果等待整合,改为 `review`
155-- 如果确认结束,改为 `done`
1@@ -1,112 +0,0 @@
2----
3-task_id: T-028A
4-title: T-028 Tailscale 监听解阻
5-status: done
6-branch: feat/T-028-unblock-tailscale-listen
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@0b59439
9-depends_on:
10- - T-028
11-write_scope:
12- - apps/conductor-daemon/**
13- - apps/status-api/**
14- - docs/runtime/**
15- - docs/ops/**
16- - scripts/runtime/**
17-updated_at: 2026-03-22T03:02:38+0800
18----
19-
20-# T-028 Tailscale 监听解阻
21-
22-## 目标
23-
24-解除 T-028 的当前真实阻塞:允许 VPS 通过 Tailscale `100.x` 回源到 `mini` / `mac` 的 conductor 与 status-api 监听面,同时保持默认 loopback 行为安全。
25-
26-## 本任务包含
27-
28-- 为 `conductor-daemon` 增加受控的 Tailscale `100.x` 监听支持
29-- 为 `status-api` 补齐 launchd/runtime 层的显式 host 配置支持
30-- 更新 runtime / ops 文档,写清节点监听与 VPS 回源约定
31-- 运行与本次实现直接相关的本地验证
32-
33-## 本任务不包含
34-
35-- 修改 `baa-firefox`
36-- 真实 DNS 切换
37-- 直接改 VPS 线上 Nginx 配置
38-- 与监听解阻无关的重构
39-
40-## 建议起始文件
41-
42-- `apps/conductor-daemon/src/index.ts`
43-- `apps/conductor-daemon/src/index.test.js`
44-- `apps/status-api/src/host.ts`
45-- `scripts/runtime/install-launchd.sh`
46-- `scripts/runtime/check-launchd.sh`
47-- `docs/runtime/environment.md`
48-- `docs/ops/README.md`
49-
50-## 交付物
51-
52-- 可显式配置为 Tailscale `100.x` 监听的 conductor / status-api
53-- 更新后的 launchd/runtime 文档与脚本支持
54-- 记录了验证命令、结果和后续交接项的任务卡
55-
56-## 验收
57-
58-- `BAA_CONDUCTOR_LOCAL_API` 不再被硬性限制为 loopback,且只接受 loopback 或显式允许的 Tailscale `100.x`
59-- `status-api` 默认仍监听 `127.0.0.1`,但能通过配置监听节点 Tailscale `100.x`
60-- runtime / ops 文档说明 mini / mac 配置与 VPS 回源地址
61-- `typecheck`
62-- `build`
63-- conductor-daemon 相关测试
64-- status-api 最小 smoke 或启动验证
65-- `git diff --check`
66-
67-## files_changed
68-
69-- `apps/conductor-daemon/src/index.ts`
70-- `apps/conductor-daemon/src/index.test.js`
71-- `scripts/runtime/install-launchd.sh`
72-- `scripts/runtime/check-launchd.sh`
73-- `ops/launchd/so.makefile.baa-status-api.plist`
74-- `docs/runtime/environment.md`
75-- `docs/runtime/launchd.md`
76-- `docs/runtime/node-verification.md`
77-- `docs/ops/README.md`
78-- `docs/ops/failover-topology.md`
79-- `coordination/tasks/T-028-unblock-tailscale-listen.md`
80-
81-## commands_run
82-
83-- `git worktree add -b feat/T-028-unblock-tailscale-listen /Users/george/code/baa-conductor-T028-unblock-tailscale-listen 0b59439`
84-- `npx --yes pnpm install`
85-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
86-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
87-- `npx --yes pnpm --filter @baa-conductor/conductor-daemon test`
88-- `npx --yes pnpm typecheck`
89-- `npx --yes pnpm build`
90-- `node apps/status-api/dist/index.js smoke`
91-- `bash -n scripts/runtime/common.sh scripts/runtime/install-launchd.sh scripts/runtime/check-launchd.sh scripts/runtime/check-node.sh`
92-- `plutil -lint ops/launchd/so.makefile.baa-status-api.plist`
93-- `git diff --check`
94-- `git push -u origin feat/T-028-unblock-tailscale-listen`
95-
96-## result
97-
98-- `conductor-daemon` 现在允许 `BAA_CONDUCTOR_LOCAL_API` 绑定 loopback 或显式列入 `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS` 的 Tailscale `100.x`,不再硬性报错为 loopback-only。
99-- `status-api` 应用代码本身已支持 `BAA_STATUS_API_HOST`;本分支补齐了 launchd 模板、安装脚本和校验脚本,使真实节点可以稳定写入 `100.71.210.78` / `100.112.239.13`。
100-- runtime / ops 文档已明确 mini / mac 的监听覆盖值,以及 VPS 继续回源 `100.71.210.78:4317` 主、`100.112.239.13:4317` 备的约定。
101-- 本地验证通过:仓库级 `typecheck`、仓库级 `build`、`conductor-daemon` 测试、`status-api` smoke、shell 语法检查、plist lint、`git diff --check`。
102-- 分支已推送:`origin/feat/T-028-unblock-tailscale-listen`
103-
104-## risks
105-
106-- 当前实现是“单 host 显式绑定”,不是 loopback 与 Tailscale 双监听;节点如果切到 `100.x`,本机探针与 `check-node.sh` 也必须同步改用 Tailscale URL。
107-- 本分支没有执行真实 mini / mac launchd 重装,也没有做 VPS/Nginx 线上切换;T-028 后续仍需在真实节点套用文档中的新参数完成落地验证。
108-
109-## next_handoff
110-
111-- 在 `mini` 上重装或更新 launchd:`--all-services --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-host 100.71.210.78`。
112-- 在 `mac` 上重装或更新 launchd:`--all-services --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-host 100.112.239.13`。
113-- 两台节点分别执行 `check-launchd.sh` / `check-node.sh` 的 Tailscale 版本命令,确认 `4317` 与 `4318` 已在各自 `100.x` 上可达,再继续 T-028 的 VPS 回源与公网切换准备。
1@@ -1,129 +0,0 @@
2----
3-task_id: T-029
4-title: 多节点长时间稳定性回归
5-status: done
6-branch: feat/T-029-stability-regression
7-repo: /Users/george/code/baa-conductor
8-base_ref: main@926860a
9-depends_on:
10- - T-028
11-write_scope:
12- - docs/ops/**
13- - docs/runtime/**
14- - tests/e2e/**
15- - scripts/smoke/**
16- - scripts/failover/**
17- - scripts/runtime/**
18-updated_at: 2026-03-22 05:35:00 CST
19----
20-
21-# T-029 多节点长时间稳定性回归
22-
23-## 目标
24-
25-在真实 mini / mac / VPS / Cloudflare 环境上完成一轮长时间稳定性回归,确认主备、探活、控制面和 smoke 脚本在真实环境下可持续工作。
26-
27-## 本任务包含
28-
29-- 基于 `T-028` 已上线环境跑多轮 smoke / failover / 恢复
30-- 记录长时间运行期间的 `healthz`、`readyz`、`rolez`、`/v1/system/state`、`/v1/status`
31-- 至少覆盖一次 planned failover 和一次 switchback
32-- 把异常、抖动、人工干预点和最终结论写入回归报告
33-
34-## 本任务不包含
35-
36-- 重新部署 Cloudflare / VPS
37-- 重写 control-api / conductor / status-api 代码
38-- 修改 `baa-firefox` 仓库
39-
40-## 建议起始文件
41-
42-- `tests/e2e/smoke.test.mjs`
43-- `scripts/smoke/run-e2e.sh`
44-- `scripts/failover/rehearsal-check.sh`
45-- `scripts/runtime/check-node.sh`
46-- `docs/ops/README.md`
47-
48-## 交付物
49-
50-- 一份真实环境稳定性回归记录
51-- 一份 failover / switchback 实测结果
52-- 必要时更新后的 smoke / failover / on-node 文档
53-
54-## 验收
55-
56-- 能给出至少一轮真实环境 smoke 结果
57-- 能给出至少一次 failover 和一次 switchback 的结果
58-- `git diff --check`
59-
60-## 更新要求
61-
62-完成时更新 frontmatter 的:
63-
64-- `status`
65-- `base_ref`
66-- `updated_at`
67-
68-并补充下面这些内容:
69-
70-## files_changed
71-
72-- coordination/tasks/T-029-stability-regression.md
73-- docs/ops/README.md
74-- docs/ops/real-stability-regression-2026-03-22.md
75-- scripts/smoke/README.md
76-- scripts/smoke/live-regression.mjs
77-
78-## commands_run
79-
80-- `npx --yes pnpm install`
81-- `./scripts/failover/print-topology.sh --env /Users/george/.config/baa-conductor/ops.env`
82-- `./scripts/failover/rehearsal-check.sh --env /Users/george/.config/baa-conductor/ops.env --basic-auth "$DIRECT_BASIC_AUTH" --bearer-token "$CONTROL_API_OPS_ADMIN_TOKEN" --expect-leader mini`
83-- `node scripts/smoke/live-regression.mjs --env /Users/george/.config/baa-conductor/ops.env --control-secrets /Users/george/.config/baa-conductor/control-api-worker.secrets.env --basic-auth-file /Users/george/.config/baa-conductor/direct-node-basic-auth.env --expect-leader mini`
84-- `./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-base http://100.71.210.78:4318 --status-api-host 100.71.210.78 --expected-rolez leader --check-loaded`
85-- `ssh george@100.112.239.13 'cd /Users/george/code/baa-conductor-T028-v2 && ./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-base http://100.112.239.13:4318 --status-api-host 100.112.239.13 --expected-rolez standby --check-loaded'`
86-- `for i in 1 2 3 4 5 6 7; do node scripts/smoke/live-regression.mjs ... --expect-leader mini --compact; sleep 300; done`
87-- `ps -axo pid=,command= | grep '/apps/worker-runner/dist/index.js' | grep -v grep || true`
88-- `ssh george@100.112.239.13 "ps -axo pid=,command= | grep '/apps/worker-runner/dist/index.js' | grep -v grep || true"`
89-- `curl -X POST ... https://control-api.makefile.so/v1/system/drain`
90-- `curl -X POST ... https://control-api.makefile.so/v1/system/pause`
91-- `launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"`
92-- `./scripts/failover/rehearsal-check.sh --env /Users/george/.config/baa-conductor/ops.env --basic-auth "$DIRECT_BASIC_AUTH" --bearer-token "$CONTROL_API_OPS_ADMIN_TOKEN" --skip-node mini --expect-leader mac`
93-- `curl -X POST ... https://control-api.makefile.so/v1/system/resume`
94-- `ssh george@100.112.239.13 'launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"'`
95-- `cd /Users/george/code/baa-conductor-T028-v2 && ./scripts/runtime/reload-launchd.sh --service conductor --install-dir /Users/george/Library/LaunchAgents`
96-- `./scripts/failover/rehearsal-check.sh --env /Users/george/.config/baa-conductor/ops.env --basic-auth "$DIRECT_BASIC_AUTH" --bearer-token "$CONTROL_API_OPS_ADMIN_TOKEN" --skip-node mac --expect-leader mini`
97-- `ssh george@100.112.239.13 'cd /Users/george/code/baa-conductor-T028-v2 && ./scripts/runtime/reload-launchd.sh --service conductor --install-dir /Users/george/Library/LaunchAgents'`
98-- `dig @giancarlo.ns.cloudflare.com +short conductor.makefile.so`
99-- `echo | openssl s_client -servername conductor.makefile.so -connect 192.210.137.113:443 2>/dev/null | openssl x509 -noout -dates`
100-
101-## result
102-
103-- 新增 `scripts/smoke/live-regression.mjs`,把 control-api、公网入口、直连入口、status-api 和鉴权结果收成一份真实环境快照;`scripts/smoke/README.md` 与 `docs/ops/README.md` 已补用法与报告入口。
104-- 在 live `mini/mac/VPS/Cloudflare` 环境上完成了一轮基线 smoke、一轮 `30.3` 分钟持续观察、一次 planned failover 和一次 switchback,并把过程写入 `docs/ops/real-stability-regression-2026-03-22.md`。
105-- steady-state 结论:control-api、公网 conductor、mini/mac 直连 conductor、Basic Auth、mini/mac on-node `check-node.sh` 都可以工作;final steady-state 已回到 `mini-main` leader、`mac-standby` standby、`mode=running`。
106-- failover / switchback 都真实成功,但都观察到了短暂外部不一致窗口:停掉当前 leader 后,公网或恢复中的节点会先返回 `standby`,随后才稳定到新的 `leader`。
107-- 本轮 smoke 没有完全通过:两台节点的 `status-api /v1/status` 全程都停在 `source=empty`, `mode=paused`, `leaderId=null`,与 control-api 真相不一致。
108-
109-## risks
110-
111-- live `status-api` 目前不是可靠真相源;它不能用来判断自动化是否真的在 `running`,也不能确认当前 leader、queueDepth 或 activeRuns。
112-- mini/mac 两侧 launchd 安装副本仍然指向 `/Users/george/code/baa-conductor-T028-v2`,还没有整理到 canonical repo path `/Users/george/code/baa-conductor`。
113-- switchback 不是“停 mac、起 mini”就完全结束;要恢复 canonical `mini leader + mac standby`,还需要额外显式 reload `mac` conductor。
114-- authoritative DNS 仍是 DNS-only,Cloudflare proxy 没有重新开启;本轮没有尝试把 conductor hosts 切回橙云。
115-- 本机非权威 DNS / 代理层对 `conductor.makefile.so`、`mac-conductor.makefile.so` 的解析与权威结果不一致,直接做 DNS/TLS 诊断时必须区分 authoritative 结果和本地 resolver 结果。
116-- 在 planned failover 前,我有一次把 `GET /v1/system/state` 和 `POST /v1/system/pause` 并行发出,所以那次 `mode=paused` 读数不能单独拿来证明 `drain` 的独立效果;后续状态读取都改回串行。
117-
118-## next_handoff
119-
120-- 优先排查 live `status-api` 为什么持续返回 `source=empty`,并让 `/v1/status` 跟随 control-api 的真实 leader / mode / queue state。
121-- 规划一次 runtime canonicalization,把 mini/mac 的 launchd、logs、runs、worktrees 路径从 `/Users/george/code/baa-conductor-T028-v2` 收口到 `/Users/george/code/baa-conductor`。
122-- 如果要把 switchback 降为更可重复的运维流程,runbook 或脚本层需要显式补上“恢复 mac standby”的尾步骤。
123-- 如果后续要重新启用 Cloudflare proxy,先把 DNS / TLS / zone SSL 模式的真实控制面整理清楚,再做单独演练,不要在当前 DNS-only 基线之上直接切橙云。
124-
125-开始时建议直接把 `status` 改为 `in_progress`。
126-
127-做完并推送后:
128-
129-- 如果等待整合,改为 `review`
130-- 如果确认结束,改为 `done`
+1,
-1
1@@ -14,7 +14,7 @@
2
3 | Role | 代表对象 | 推荐 Token | 明确允许 | 明确禁止 |
4 | --- | --- | --- | --- | --- |
5-| `controller` | leader/standby conductor 节点 | `service_hmac`,后续可升级为 `service_signed` | `controllers.heartbeat`、`leader.acquire`、`tasks.plan`、`tasks.claim` | `system.pause`、`system.resume`、`system.drain`、任何维护操作、任何 step 执行回写 |
6+| `controller` | 当前单节点 conductor 服务 | `service_hmac`,后续可升级为 `service_signed` | `controllers.heartbeat`、`leader.acquire`、`tasks.plan`、`tasks.claim` | `system.pause`、`system.resume`、`system.drain`、任何维护操作、任何 step 执行回写 |
7 | `worker` | 运行 step 的 Codex worker | `service_hmac`,后续可升级为 `service_signed` | `steps.heartbeat`、`steps.checkpoint`、`steps.complete`、`steps.fail` | claim task、lease、task 创建、系统级操作、维护操作 |
8 | `browser_admin` | 可见 Claude `control` 会话 / 浏览器管理面板 | `browser_session` | `tasks.create`、`system.pause`、`system.resume`、`system.drain`、状态/日志查看 | lease、claim、step 执行回写、主备切换 |
9 | `ops_admin` | 运维维护入口 | `ops_session` | `maintenance.promote`、`maintenance.demote`、状态/日志查看 | task 创建、普通调度、step 执行回写、浏览器控制按钮 |
+10,
-7
1@@ -6,7 +6,7 @@
2
3 ## 决策
4
5-当前项目不再继续推进 `mini + mac` 主备切换方案,统一收口为:
6+当前项目统一收口为:
7
8 - `mini` 是唯一长期运行的 conductor 节点
9 - `mini` 负责自启动、状态面、控制面和公网入口
10@@ -14,14 +14,14 @@
11
12 ## 原因
13
14-- 当前项目后续会被其他项目替代,不值得继续为主备切换维护额外复杂度
15-- 现网主要剩余问题已经集中在 `mini` 自身的运行态收口,而不是多节点编排能力
16-- 主备切换文档、脚本和操作链条过长,继续维护的性价比低
17+- 当前项目后续会被其他项目替代,不值得继续维护主备复杂度
18+- 现网剩余问题已经集中在 `mini` 自身的运行态收口
19+- 切换文档、脚本和操作链条过长,继续维护性价比低
20
21 ## 直接影响
22
23 - 当前推荐运维模式改成 `mini` 单节点
24-- `mac` 相关的主备/failover/switchback 文档降级为历史参考
25+- 历史主备/failover/switchback 内容直接从主线移除
26 - 后续零散修复优先聚焦:
27 - live `status-api` 漂移
28 - `mini` runtime 路径 canonicalization
29@@ -29,6 +29,9 @@
30
31 ## 不做的事情
32
33-- 不在这一轮强拆所有历史 failover 代码路径
34-- 不把所有旧文档全部重写成单节点版本
35 - 不再围绕 `mac` 设计新的 rehearsal、切换或兜底流程
36+- 不为历史兼容保留多节点运维入口
37+
38+## 回溯方式
39+
40+- 旧的主备资料统一通过 tag `ha-failover-archive-2026-03-22` 回溯
+1,
-9
1@@ -1,14 +1,6 @@
2 # decisions
3
4-这个目录用于后续放 ADR 或设计决策记录。
5-
6-建议命名:
7-
8-- `0001-control-api-auth.md`
9-- `0002-failover-policy.md`
10-- `0003-checkpoint-storage.md`
11-
12-当前阶段先保留目录与约定,不写具体决策内容。
13+这个目录用于记录当前仍然有效的设计决策。
14
15 当前已经落地的决策:
16
+1,
-1
1@@ -67,7 +67,7 @@ Firefox 插件至少要读取这些字段:
2 | `automation.requested_by` | string \| null | 最近一次变更来源,建议用于审计展示 |
3 | `automation.reason` | string \| null | 最近一次变更原因 |
4 | `leader.controller_id` | string \| null | 当前 leader controller id |
5-| `leader.host` | string \| null | 当前 leader host,例如 `mini` 或 `mac` |
6+| `leader.host` | string \| null | 当前持有租约的 host;当前主线一般是 `mini` |
7 | `leader.role` | string \| null | 当前 leader 注册角色 |
8 | `leader.lease_expires_at` | integer \| null | 当前 lease 到期时间,毫秒时间戳 |
9 | `queue.active_runs` | integer | 当前运行中的 run 数 |
+33,
-187
1@@ -1,79 +1,34 @@
2 # VPS、Nginx 与 Cloudflare DNS 运维
3
4-本目录收口公网入口的运维步骤。
5+当前运维模型只保留一条路径:
6
7-## 当前推荐模式
8+- `control-api.makefile.so` 由 Cloudflare Worker / D1 提供
9+- `conductor.makefile.so` 由 VPS Nginx 回源到 `mini`
10+- `mini` 内网固定地址:`100.71.210.78:4317`
11
12-从现在开始,当前有效运维目标改成:
13+主备切换、直连 `mac` 的公网域名和历史切换 runbook 已从当前主线移除。
14
15-- 只保留 `mini` 作为唯一中控
16-- 只保留 `mini` 的自启动、探活、控制面与公网入口
17-- 不再继续推进 `mac` 备主、planned failover、emergency failover、switchback
18+## 当前 inventory
19
20-下面的主备/failover 文档保留为历史参考,不再作为当前执行目标。
21-
22-当前方案固定遵守这几个约束:
23-
24-- 公网只暴露 VPS 的 `80/tcp` 与 `443/tcp`
25-- `mini` 回源固定走 Tailscale `100.x` 地址
26-- 不依赖 `*.ts.net` 的 MagicDNS 名称
27-- 仓库脚本默认只做 DNS 计划、Nginx 渲染和部署分发,不会直接改线上 DNS
28-- `deploy-on-vps.sh` 只有显式传 `--reload` 时才会重载 Nginx
29-
30-`control-api.makefile.so` 的 Worker 自定义域仍由 Cloudflare Worker / D1 相关任务管理,不在这里的脚本覆盖范围内。
31-
32-## 历史参考
33-
34-下面这些主备切换文档只保留作历史记录:
35-
36-- [`failover-topology.md`](./failover-topology.md)
37-- [`planned-failover.md`](./planned-failover.md)
38-- [`emergency-failover.md`](./emergency-failover.md)
39-- [`switchback.md`](./switchback.md)
40-
41-配套只读/半自动脚本在:
42-
43-- [`../../scripts/failover/print-topology.sh`](../../scripts/failover/print-topology.sh)
44-- [`../../scripts/failover/rehearsal-check.sh`](../../scripts/failover/rehearsal-check.sh)
45-- [`../../scripts/failover/print-checklist.sh`](../../scripts/failover/print-checklist.sh)
46-
47-最新真实回归记录仍保留在:
48-
49-- [`real-stability-regression-2026-03-22.md`](./real-stability-regression-2026-03-22.md)
50-
51-## 单一来源 inventory
52-
53-本任务把公网域名、VPS 公网 IP、内网 Tailscale `100.x` 和 Nginx 安装路径收口到一份 inventory:
54+使用一份最小 inventory:
55
56 - [`scripts/ops/baa-conductor.env.example`](../../scripts/ops/baa-conductor.env.example)
57-- [`scripts/ops/cloudflare-dns-plan.sh`](../../scripts/ops/cloudflare-dns-plan.sh)
58-- [`scripts/ops/nginx-sync-plan.sh`](../../scripts/ops/nginx-sync-plan.sh)
59-- [`ops/nginx/templates/baa-conductor.conf.template`](../../ops/nginx/templates/baa-conductor.conf.template)
60-- [`ops/nginx/templates/includes/direct-node-auth.conf.template`](../../ops/nginx/templates/includes/direct-node-auth.conf.template)
61-
62-推荐做法:
63-
64-1. 把 `baa-conductor.env.example` 复制到仓库外的私有路径
65-2. 在那份私有 inventory 里填写真实 VPS 公网 IP、Cloudflare Zone ID 和证书路径
66-3. 用同一份 inventory 先出 DNS 计划,再渲染 Nginx bundle
67-
68-## 当前域名与内网关系
69
70-| 公网域名 | Cloudflare DNS 目标 | VPS 上的 Nginx upstream | 内网实际回源 |
71-| --- | --- | --- | --- |
72-| `conductor.makefile.so` | VPS 公网 IP | `conductor_primary` | `mini 100.71.210.78:4317` |
73-| `mini-conductor.makefile.so` | VPS 公网 IP | `mini_conductor_direct` | `100.71.210.78:4317` |
74+里面只需要维护:
75
76-统一原则:
77+- Cloudflare zone
78+- VPS 公网 IP
79+- `conductor.makefile.so`
80+- `mini` 的 Tailscale `100.x`
81+- 证书与 Nginx 安装路径
82
83-- 当前优先维护 `conductor.makefile.so` 和 `mini-conductor.makefile.so`
84-- 只有 VPS 对公网暴露 `80/443`
85-- `4317` 只允许 VPS 通过 Tailscale 或 WireGuard 访问
86-- `mini-conductor.makefile.so` 默认保留 Basic Auth
87+## 当前公网关系
88
89-## 节点监听配置
90+| 公网域名 | 目标 | 回源 |
91+| --- | --- | --- |
92+| `conductor.makefile.so` | VPS 公网 IP | `100.71.210.78:4317` |
93
94-要让 VPS 真实回源成功,节点本地 runtime 需要明确切到自己的 Tailscale `100.x`,而不是继续停在 loopback 默认值。
95+## 当前节点监听
96
97 `mini`:
98
99@@ -83,15 +38,9 @@ BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.71.210.78
100 BAA_STATUS_API_HOST=100.71.210.78
101 ```
102
103-运维边界:
104+## DNS 计划
105
106-- VPS Nginx 继续只回源 `mini` 的 conductor,也就是 `100.71.210.78:4317`
107-- `status-api` 如需 Tailscale 直连,地址对应 `http://100.71.210.78:4318`
108-- 当前实现不是双监听;切到 `100.x` 后,节点本地检查要同步改用 `check-node.sh --local-api-base ... --status-api-base ...`
109-
110-## Cloudflare DNS 计划
111-
112-### 1. 准备 inventory
113+### 1. 准备私有 inventory
114
115 ```bash
116 cp scripts/ops/baa-conductor.env.example ../baa-conductor.ops.env
117@@ -99,20 +48,13 @@ $EDITOR ../baa-conductor.ops.env
118 export CLOUDFLARE_API_TOKEN=...your token...
119 ```
120
121-注意:
122-
123-- 真实 inventory 建议放仓库外,避免把公网 IP、Zone ID 或 token 变量名混进提交
124-- token 不写进文件,脚本从 `BAA_CF_API_TOKEN_ENV` 指定的环境变量读取
125-
126 ### 2. 只看目标记录
127
128 ```bash
129 scripts/ops/cloudflare-dns-plan.sh --env ../baa-conductor.ops.env
130 ```
131
132-这一步只渲染期望记录,不访问 Cloudflare API。
133-
134-### 3. 读取现网并生成变更预览
135+### 3. 生成变更预览
136
137 ```bash
138 scripts/ops/cloudflare-dns-plan.sh \
139@@ -122,25 +64,11 @@ scripts/ops/cloudflare-dns-plan.sh \
140 --output .tmp/ops/cloudflare-dns-plan.json
141 ```
142
143-这一步会:
144-
145-- 用 Cloudflare DNS GET API 读取当前记录
146-- 输出 create / update / delete 计划
147-- 生成一个 `curl` 预览脚本,供人工审阅后再决定是否执行
148-
149-安全边界:
150-
151-- 脚本本身不会发 `POST` / `PATCH` / `DELETE`
152-- `--emit-shell` 只是把预览命令写到文件里,不会自动执行
153+脚本只生成计划,不会直接改线上 DNS。
154
155-实战注意:
156+## Nginx 渲染与部署
157
158-- 如果 `conductor*.makefile.so` 已经有有效源站证书、`--resolve` 直打 VPS 的 `https://.../healthz` 正常,但经 Cloudflare 代理访问时返回 `301 Location: https://$host$request_uri` 自重定向,通常说明 zone 仍在 `Flexible`。
159-- 当前 token 如果没有 zone settings 权限,无法直接把 SSL mode 改到 `Full` / `Full (strict)`;这种情况下先把 `BAA_CF_PROXY_*` 设成 `false`,切成 DNS-only,避免公网入口卡在 Cloudflare 边缘循环。
160-
161-## Nginx 渲染与部署 bundle
162-
163-### 1. 渲染并打包
164+### 1. 渲染 bundle
165
166 ```bash
167 scripts/ops/nginx-sync-plan.sh \
168@@ -148,16 +76,7 @@ scripts/ops/nginx-sync-plan.sh \
169 --bundle-dir .tmp/ops/baa-conductor-nginx
170 ```
171
172-bundle 内会包含:
173-
174-- 渲染后的 `etc/nginx/sites-available/baa-conductor.conf`
175-- 渲染后的 `etc/nginx/includes/baa-conductor/direct-node-auth.conf`
176-- 当前的 `common-proxy.conf`
177-- `inventory-summary.json`
178-- `DEPLOY_COMMANDS.txt`
179-- `deploy-on-vps.sh`
180-
181-### 2. 校验模板是否仍与仓库默认配置一致
182+### 2. 校验仓库默认配置
183
184 ```bash
185 scripts/ops/nginx-sync-plan.sh \
186@@ -165,8 +84,6 @@ scripts/ops/nginx-sync-plan.sh \
187 --check-repo
188 ```
189
190-这一步用于维护仓库里的默认模板和默认配置,不是线上部署必需步骤。
191-
192 ### 3. 分发到 VPS
193
194 ```bash
195@@ -175,87 +92,16 @@ ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh'
196 ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh --reload'
197 ```
198
199-行为说明:
200-
201-- 第一个 `deploy-on-vps.sh` 会安装文件并执行 `nginx -t`,但不会 reload
202-- 只有显式传 `--reload` 时才会执行 `systemctl reload nginx`
203-
204 ## 前置条件
205
206-部署前确认:
207-
208 1. VPS 已安装 `nginx`
209-2. VPS 能访问:
210- - `100.71.210.78:4317`
211- - `100.112.239.13:4317`
212-3. 三个公网域名都已准备好 DNS 记录
213-4. 证书路径已经与 inventory 一致
214-5. 若启用 Basic Auth,VPS 已安装 `htpasswd`
215-
216-常见依赖:
217-
218-```bash
219-sudo apt-get update
220-sudo apt-get install -y nginx apache2-utils certbot
221-```
222-
223-若证书通过 Cloudflare DNS challenge 或其他流程签发,按现有方式安装对应插件。
224-
225-## Basic Auth 与证书
226-
227-直连域名默认引用:
228-
229-- `auth_basic_user_file /etc/nginx/.htpasswd-baa-conductor`
230-- `mini-conductor.makefile.so`
231-- `mac-conductor.makefile.so`
232-
233-准备 Basic Auth:
234-
235-```bash
236-sudo htpasswd -c /etc/nginx/.htpasswd-baa-conductor conductor-ops
237-```
238-
239-准备证书时,仓库默认按 Let’s Encrypt 路径渲染:
240-
241-- `/etc/letsencrypt/live/conductor.makefile.so/fullchain.pem`
242-- `/etc/letsencrypt/live/conductor.makefile.so/privkey.pem`
243-- `/etc/letsencrypt/live/mini-conductor.makefile.so/fullchain.pem`
244-- `/etc/letsencrypt/live/mini-conductor.makefile.so/privkey.pem`
245-- `/etc/letsencrypt/live/mac-conductor.makefile.so/fullchain.pem`
246-- `/etc/letsencrypt/live/mac-conductor.makefile.so/privkey.pem`
247-
248-若改用 Cloudflare Origin Cert,就把 inventory 里的证书根目录改成实际落盘位置,然后重新生成 bundle。
249-
250-## 上线后验证
251-
252-入口与跳转:
253-
254-```bash
255-curl -I http://conductor.makefile.so
256-curl -I https://conductor.makefile.so/healthz
257-```
258-
259-预期:
260-
261-- `http://conductor.makefile.so` 返回 `301`
262-- `https://conductor.makefile.so/healthz` 返回 `200`
263-
264-直连 mini/mac:
265-
266-```bash
267-curl -I https://mini-conductor.makefile.so/healthz
268-curl -u conductor-ops:YOUR_PASSWORD https://mini-conductor.makefile.so/healthz
269-curl -u conductor-ops:YOUR_PASSWORD https://mac-conductor.makefile.so/healthz
270-```
271-
272-预期:
273-
274-- 未带认证访问直连域名返回 `401`
275-- 带正确认证后返回 `200`
276+2. VPS 能访问 `100.71.210.78:4317`
277+3. `conductor.makefile.so` 已有 DNS 记录
278+4. 证书路径与 inventory 一致
279
280-## 运维加固
281+## 说明
282
283-- 主机防火墙明确拒绝公网访问 `4317`
284-- `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so` 至少保留 Basic Auth
285-- 有固定办公出口时,在 [`ops/nginx/includes/direct-node-auth.conf`](../../ops/nginx/includes/direct-node-auth.conf) 基础上叠加 allowlist
286-- 域名走 Cloudflare 代理时,先恢复真实客户端 IP,再启用 `allow/deny`
287+- 当前只维护 `conductor.makefile.so`
288+- 不依赖 MagicDNS
289+- 是否启用 Cloudflare proxy 由实际证书和 SSL mode 决定
290+- 历史多节点资料只通过 tag `ha-failover-archive-2026-03-22` 回溯
+0,
-145
1@@ -1,145 +0,0 @@
2-# Emergency Failover Runbook
3-
4-适用场景:
5-
6-- `mini` 宕机、不可登录、或其 conductor 行为已经不可信
7-- 需要尽快把公网入口稳定在 `mac`
8-
9-这个 runbook 的目标是“先恢复可用性,再谈回切整洁度”。
10-
11-## 1. 判断当前属于哪种故障
12-
13-先拿一份只读快照:
14-
15-```bash
16-./scripts/failover/rehearsal-check.sh \
17- --env ../baa-conductor.ops.env \
18- --basic-auth 'conductor-ops:REPLACE_ME' \
19- --bearer-token 'REPLACE_ME' \
20- --skip-node mini \
21- --expect-leader mac
22-```
23-
24-根据结果分三类看:
25-
26-1. `mac` 直连已经是 `leader`,公网也正常
27- 这说明 VPS 已经自动从 mini 掉到了 mac,只需要继续观察和记录。
28-2. `mac` 直连不健康
29- 这说明备用节点自己还没准备好,先修 `mac`。
30-3. `mac` 已经是 `leader`,但公网入口仍不稳定,或者公网 `/rolez` 不是 `leader`
31- 这通常说明 VPS 仍在命中 mini,或者 VPS 到 mac 的回源有问题。
32-
33-## 2. 先把 mac 修到可服务
34-
35-在 `mac` 上检查 launchd:
36-
37-```bash
38-cd /Users/george/code/baa-conductor
39-./scripts/runtime/check-launchd.sh \
40- --repo-dir /Users/george/code/baa-conductor \
41- --node mac \
42- --install-dir "$HOME/Library/LaunchAgents"
43-```
44-
45-必要时重载:
46-
47-```bash
48-cd /Users/george/code/baa-conductor
49-./scripts/runtime/reload-launchd.sh
50-launchctl print "gui/$(id -u)/so.makefile.baa-conductor"
51-```
52-
53-如果安装副本不存在或与当前 repo 偏离,先补一次:
54-
55-```bash
56-cd /Users/george/code/baa-conductor
57-./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac
58-./scripts/runtime/reload-launchd.sh
59-```
60-
61-## 3. 检查 VPS 到 mac 的回源
62-
63-在 VPS 上确认:
64-
65-- `100.112.239.13:4317` 可达
66-- Nginx 没有语法错误
67-- TLS 证书和 include 仍然完整
68-
69-最小检查:
70-
71-```bash
72-curl -sS http://100.112.239.13:4317/healthz
73-curl -sS http://100.112.239.13:4317/rolez
74-sudo nginx -t
75-```
76-
77-如果这些都正常,但公网还是不落到 mac,说明 mini 侧可能仍有一个“能响应但不该接流量”的进程。
78-
79-## 4. 应急 Nginx 热修
80-
81-当前 canonical 配置是“mini 主、mac 备”。这是正常态最简配置,但应急时有一个代价:
82-
83-- 只要 mini 还能回 HTTP,公网就会先打到 mini
84-- 即使 mini `/rolez` 已经不是 leader,Nginx 也不会主动让路
85-
86-这时可以在 VPS 上对已部署配置做临时热修,把 mac 调到前面。
87-
88-先备份:
89-
90-```bash
91-sudo cp \
92- /etc/nginx/sites-available/baa-conductor.conf \
93- /etc/nginx/sites-available/baa-conductor.conf.bak.$(date +%Y%m%d%H%M%S)
94-```
95-
96-临时把 `conductor_primary` 改成:
97-
98-```nginx
99-upstream conductor_primary {
100- server 100.112.239.13:4317 max_fails=2 fail_timeout=5s;
101- server 100.71.210.78:4317 backup;
102- keepalive 32;
103-}
104-```
105-
106-然后:
107-
108-```bash
109-sudo nginx -t
110-sudo systemctl reload nginx
111-```
112-
113-这一步的性质必须记清楚:
114-
115-- 这是“已部署文件”的应急热修,不是 repo 变更
116-- switchback 时必须把 VPS 恢复到 repo 生成的 canonical bundle
117-
118-## 5. 验证 emergency failover 已落稳
119-
120-再次执行:
121-
122-```bash
123-./scripts/failover/rehearsal-check.sh \
124- --env ../baa-conductor.ops.env \
125- --basic-auth 'conductor-ops:REPLACE_ME' \
126- --bearer-token 'REPLACE_ME' \
127- --skip-node mini \
128- --expect-leader mac
129-```
130-
131-成功条件:
132-
133-- `conductor.makefile.so` 返回 `leader`
134-- `mac-conductor.makefile.so` 返回 `leader`
135-- `GET /v1/system/state` 的 `holder_id` 以 `mac-` 开头
136-
137-## 6. 事后记录
138-
139-emergency failover 结束后,至少记录:
140-
141-- mini 是“完全不可达”还是“仍可达但角色错误”
142-- mac 是否需要人工 reload launchd 才恢复
143-- VPS 是否做了临时 Nginx 热修
144-- 哪个备份文件是本次热修前的 `baa-conductor.conf`
145-
146-这些信息会直接决定后续 switchback 的复杂度。
+0,
-158
1@@ -1,158 +0,0 @@
2-# Failover Topology
3-
4-本页把当前主备设计里最容易混淆的四层关系写死:
5-
6-1. Cloudflare DNS
7-2. VPS 上的 Nginx
8-3. 两台节点的 Tailscale `100.x`
9-4. 节点本地的 `launchd`
10-
11-目标不是引入新的自动切换机制,而是把现有约定整理成可 rehearsal 的操作模型。
12-
13-## 1. 固定拓扑
14-
15-### 公网入口
16-
17-- `conductor.makefile.so`
18-- `mini-conductor.makefile.so`
19-- `mac-conductor.makefile.so`
20-
21-这三个 host 都固定解析到同一台 VPS 公网 IP。failover 不通过改 DNS 完成,Cloudflare 只是把公网流量送到 VPS。
22-
23-`control-api.makefile.so` 是单独的 Cloudflare Worker 自定义域,仍然作为控制面使用:
24-
25-- `GET /v1/system/state`
26-- `POST /v1/system/drain`
27-- `POST /v1/system/pause`
28-- `POST /v1/system/resume`
29-
30-它不经过 VPS Nginx,因此可以在 conductor 主备切换时继续承担“冻结/恢复自动化”和“读取 lease 状态”的职责。
31-
32-### VPS Nginx
33-
34-仓库里的 canonical 配置见:
35-
36-- [`ops/nginx/baa-conductor.conf`](../../ops/nginx/baa-conductor.conf)
37-
38-当前 upstream 关系是:
39-
40-| 公网 host | VPS upstream | 实际回源 |
41-| --- | --- | --- |
42-| `conductor.makefile.so` | `conductor_primary` | `mini 100.71.210.78:4317` 主,`mac 100.112.239.13:4317` 备 |
43-| `mini-conductor.makefile.so` | `mini_conductor_direct` | `mini 100.71.210.78:4317` |
44-| `mac-conductor.makefile.so` | `mac_conductor_direct` | `mac 100.112.239.13:4317` |
45-
46-关键点:
47-
48-- `conductor.makefile.so` 只会在 mini 上游连不上时,才 TCP 级别回退到 mac。
49-- Nginx 不看 lease,不知道谁是逻辑 leader。
50-- 因此只要 mini 仍然在 `100.71.210.78:4317` 上返回 HTTP,公网流量就还会优先打到 mini。
51-
52-这意味着:
53-
54-- planned failover 不能只让 mac 获得 lease,还必须让 mini 的 conductor 停掉或不再对 VPS 可达。
55-- emergency failover 时,如果 mini 还活着但已经不是 leader,可能需要临时热修 VPS 上的已部署 Nginx 配置。
56-
57-### Tailscale `100.x`
58-
59-当前仓库明确固定使用 Tailscale IPv4,不依赖 MagicDNS:
60-
61-- `mini`: `100.71.210.78`
62-- `mac`: `100.112.239.13`
63-- port: `4317`
64-
65-这些值来自 inventory:
66-
67-- [`scripts/ops/baa-conductor.env.example`](../../scripts/ops/baa-conductor.env.example)
68-
69-### launchd
70-
71-节点进程是否存在、以什么身份存在,由 `launchd` 安装副本决定。
72-
73-默认节点身份见:
74-
75-- [`docs/runtime/environment.md`](../runtime/environment.md)
76-
77-| 节点 | `BAA_CONDUCTOR_HOST` | `BAA_CONDUCTOR_ROLE` | `BAA_NODE_ID` |
78-| --- | --- | --- | --- |
79-| `mini` | `mini` | `primary` | `mini-main` |
80-| `mac` | `mac` | `standby` | `mac-standby` |
81-
82-真实回源还要把监听地址显式切到节点自己的 Tailscale `100.x`:
83-
84-- `mini`: `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317`
85-- `mini`: `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.71.210.78`
86-- `mac`: `BAA_CONDUCTOR_LOCAL_API=http://100.112.239.13:4317`
87-- `mac`: `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.112.239.13`
88-
89-安装/校验/重载脚本见:
90-
91-- [`scripts/runtime/install-launchd.sh`](../../scripts/runtime/install-launchd.sh)
92-- [`scripts/runtime/check-launchd.sh`](../../scripts/runtime/check-launchd.sh)
93-- [`scripts/runtime/reload-launchd.sh`](../../scripts/runtime/reload-launchd.sh)
94-
95-`reload-launchd.sh` 适合“把安装副本重新 bootstrap/kickstart”;planned failover 要“只停 conductor,不立刻重启”时,直接用 `launchctl bootout` 更合适。
96-
97-## 2. 当前 failover 语义
98-
99-当前设计的真实行为可以压缩成一句话:
100-
101-> 逻辑 leader 由 lease 决定,公网入口是否切走则由 VPS 是否还能连通当前 primary upstream 决定。
102-
103-所以三种场景的区别是:
104-
105-- planned failover: 先冻结自动化,再显式停掉 mini conductor,让 VPS 落到 mac backup,上游 lease 也随之迁到 mac。
106-- emergency failover: mini 已宕机或不可信,优先保证 mac 可服务;必要时在 VPS 上做临时 Nginx 热修。
107-- switchback: mini 修复后,先让 mini 恢复健康,再停掉 mac conductor,并把 VPS 配置恢复为 canonical 的 mini 主、mac 备。
108-
109-## 3. Rehearsal 辅助脚本
110-
111-新增脚本都在:
112-
113-- [`scripts/failover/common.sh`](../../scripts/failover/common.sh)
114-- [`scripts/failover/print-topology.sh`](../../scripts/failover/print-topology.sh)
115-- [`scripts/failover/rehearsal-check.sh`](../../scripts/failover/rehearsal-check.sh)
116-- [`scripts/failover/print-checklist.sh`](../../scripts/failover/print-checklist.sh)
117-
118-它们的边界是:
119-
120-- 只做只读 GET 检查,或输出 checklist
121-- 不会直接执行真实 failover
122-- 不会改 DNS
123-- 不会改 repo 里的 Nginx 模板
124-
125-推荐先看拓扑:
126-
127-```bash
128-./scripts/failover/print-topology.sh --env ../baa-conductor.ops.env
129-```
130-
131-再做一次基线探测:
132-
133-```bash
134-./scripts/failover/rehearsal-check.sh \
135- --env ../baa-conductor.ops.env \
136- --basic-auth 'conductor-ops:REPLACE_ME' \
137- --bearer-token 'REPLACE_ME' \
138- --expect-leader mini
139-```
140-
141-如果要拿到按场景整理好的命令骨架:
142-
143-```bash
144-./scripts/failover/print-checklist.sh \
145- --scenario planned \
146- --env ../baa-conductor.ops.env
147-```
148-
149-## 4. 使用这些 runbook 的前提
150-
151-开始任何 rehearsal 之前,至少准备好:
152-
153-- `mini`、`mac`、VPS 的 shell 访问权限
154-- 直连域名 Basic Auth 凭据
155-- `GET /v1/system/state` 的 readonly 或 browser_admin token
156-- `POST /v1/system/drain|pause|resume` 的 browser_admin token
157-- 一份仓库外的 inventory env 文件
158-
159-若这些前提不满足,本轮只能做“文档演练”或“单机只读检查”,不要冒险把真实主节点停掉。
+0,
-184
1@@ -1,184 +0,0 @@
2-# Planned Failover Runbook
3-
4-适用场景:
5-
6-- `mini` 当前是 leader
7-- `mac` 已安装并加载 standby conductor
8-- 计划内维护,需要把公网入口从 `mini` 平滑切到 `mac`
9-
10-非目标:
11-
12-- 不改 DNS
13-- 不改 app 代码
14-- 不让 `baa-firefox` 介入切换逻辑
15-
16-## 1. 前置条件
17-
18-准备这些材料:
19-
20-- 仓库外 inventory,例如 `../baa-conductor.ops.env`
21-- 直连域名 Basic Auth
22-- `GET /v1/system/state` 的 readonly token
23-- `POST /v1/system/drain|pause|resume` 的 browser_admin token
24-- `mini` / `mac` 的 shell 访问权限
25-
26-先确认当前拓扑和基线:
27-
28-```bash
29-./scripts/failover/print-topology.sh --env ../baa-conductor.ops.env
30-
31-./scripts/failover/rehearsal-check.sh \
32- --env ../baa-conductor.ops.env \
33- --basic-auth 'conductor-ops:REPLACE_ME' \
34- --bearer-token 'REPLACE_ME' \
35- --expect-leader mini
36-```
37-
38-预期:
39-
40-- `conductor.makefile.so` 的 `/rolez` 返回 `leader`
41-- `mini-conductor.makefile.so` 的 `/rolez` 返回 `leader`
42-- `mac-conductor.makefile.so` 的 `/rolez` 返回 `standby`
43-- `GET /v1/system/state` 的 `holder_id` 以 `mini-` 开头
44-
45-## 2. 冻结自动化
46-
47-planned failover 先 drain,再 pause。
48-
49-`drain` 的目的:
50-
51-- 不再启动新的 work
52-- 给当前运行中的 task 一个自然收尾窗口
53-
54-当前仓库没有“自动等到 active runs 归零”的单独脚本,所以这里需要人工观察 status 面板、任务板或日志,确认已经到可切换窗口。
55-
56-执行:
57-
58-```bash
59-export CONTROL_API_BASE='https://control-api.makefile.so'
60-export BROWSER_ADMIN_TOKEN='REPLACE_ME'
61-
62-curl -sS -X POST \
63- -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
64- -H 'Content-Type: application/json' \
65- -d '{"requested_by":"ops_runbook","reason":"planned_failover_rehearsal"}' \
66- "${CONTROL_API_BASE%/}/v1/system/drain"
67-
68-curl -sS -X POST \
69- -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
70- -H 'Content-Type: application/json' \
71- -d '{"requested_by":"ops_runbook","reason":"planned_failover_cutover"}' \
72- "${CONTROL_API_BASE%/}/v1/system/pause"
73-```
74-
75-## 3. 先确认 mac standby 可接手
76-
77-在 `mac` 上先做 launchd 静态/加载校验:
78-
79-```bash
80-cd /Users/george/code/baa-conductor
81-./scripts/runtime/check-launchd.sh \
82- --repo-dir /Users/george/code/baa-conductor \
83- --node mac \
84- --install-dir "$HOME/Library/LaunchAgents"
85-
86-launchctl print "gui/$(id -u)/so.makefile.baa-conductor"
87-```
88-
89-如果安装副本有漂移,先重渲染再继续:
90-
91-```bash
92-cd /Users/george/code/baa-conductor
93-./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac
94-./scripts/runtime/reload-launchd.sh
95-```
96-
97-## 4. 切走 mini
98-
99-这是本设计里最关键的一步。
100-
101-原因:
102-
103-- `mac` 获得 lease 还不够
104-- 只要 mini 继续在 `100.71.210.78:4317` 上返回 HTTP,VPS Nginx 仍会优先把公网流量送到 mini
105-
106-所以 planned failover 要显式停掉 mini 的 conductor 进程,而不是只做逻辑层 promote/demote。
107-
108-默认 `LaunchAgents` 方式:
109-
110-```bash
111-launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"
112-```
113-
114-如果该节点使用 `LaunchDaemons`:
115-
116-```bash
117-sudo launchctl bootout system /Library/LaunchDaemons/so.makefile.baa-conductor.plist
118-```
119-
120-注意:
121-
122-- 这里只停 `so.makefile.baa-conductor`
123-- `worker-runner`、`status-api` 是否一起停,由维护窗口自己决定
124-- 本 runbook 默认只切 conductor,不扩大停机面
125-
126-## 5. 验证 cutover
127-
128-mini conductor 停掉后,再做一次探测:
129-
130-```bash
131-./scripts/failover/rehearsal-check.sh \
132- --env ../baa-conductor.ops.env \
133- --basic-auth 'conductor-ops:REPLACE_ME' \
134- --bearer-token 'REPLACE_ME' \
135- --skip-node mini \
136- --expect-leader mac
137-```
138-
139-预期:
140-
141-- `conductor.makefile.so` 仍然 `healthz=ok`、`readyz=ready`、`rolez=leader`
142-- `mac-conductor.makefile.so` 的 `/rolez` 变成 `leader`
143-- `GET /v1/system/state` 的 `holder_id` 以 `mac-` 开头
144-
145-如果 `mac` 已经是 leader,但公网 `/rolez` 仍不对:
146-
147-- 优先检查 VPS 到 `100.112.239.13:4317` 的连通性
148-- 再检查 mini 是否实际上还在对 `100.71.210.78:4317` 提供服务
149-- planned failover 不应该通过改 DNS 修复
150-
151-## 6. 恢复自动化
152-
153-确认公网入口和 mac 直连都健康后,再 `resume`:
154-
155-```bash
156-curl -sS -X POST \
157- -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
158- -H 'Content-Type: application/json' \
159- -d '{"requested_by":"ops_runbook","reason":"planned_failover_complete"}' \
160- "${CONTROL_API_BASE%/}/v1/system/resume"
161-```
162-
163-## 7. Abort / 回滚
164-
165-如果在验证窗口里发现 mac 无法稳定接手:
166-
167-1. 保持 automation `paused`
168-2. 在 `mini` 上重新启动 conductor:
169-
170-```bash
171-cd /Users/george/code/baa-conductor
172-./scripts/runtime/reload-launchd.sh
173-```
174-
175-3. 再次确认基线回到 `mini`:
176-
177-```bash
178-./scripts/failover/rehearsal-check.sh \
179- --env ../baa-conductor.ops.env \
180- --basic-auth 'conductor-ops:REPLACE_ME' \
181- --bearer-token 'REPLACE_ME' \
182- --expect-leader mini
183-```
184-
185-4. 确认恢复正常后,再决定是否 `resume`
+0,
-101
1@@ -1,101 +0,0 @@
2-# 2026-03-22 Real Rollout Record
3-
4-## Snapshot
5-
6-| Surface | Target | Actual state |
7-| --- | --- | --- |
8-| `control-api.makefile.so` | Cloudflare Worker + D1 | Already deployed and verified |
9-| `conductor.makefile.so` | VPS `192.210.137.113` -> `mini/mac 100.x:4317` | Live |
10-| `mini-conductor.makefile.so` | VPS `192.210.137.113` -> `mini 100.71.210.78:4317` | Live with Basic Auth |
11-| `mac-conductor.makefile.so` | VPS `192.210.137.113` -> `mac 100.112.239.13:4317` | Live with Basic Auth |
12-
13-## Cloudflare
14-
15-- zone: `makefile.so`
16-- zone id: `f3507ab962df815d93e7ad3f1a390615`
17-- public IPv4: `192.210.137.113`
18-- Worker custom domain kept as-is: `https://control-api.makefile.so`
19-
20-DNS records created on 2026-03-22 CST:
21-
22-- `conductor.makefile.so` record id `535f46affdb393e30a24e5f9fb5b95c5`
23-- `mini-conductor.makefile.so` record id `41a6d2fab5071ea6de09eacabd5ffca6`
24-- `mac-conductor.makefile.so` record id `e7011abac5c5684e22a047f5e2ced5d7`
25-
26-Final record state:
27-
28-- `A conductor.makefile.so -> 192.210.137.113 proxied=false`
29-- `A mini-conductor.makefile.so -> 192.210.137.113 proxied=false`
30-- `A mac-conductor.makefile.so -> 192.210.137.113 proxied=false`
31-
32-Why DNS-only:
33-
34-- With `proxied=true`, `--resolve ...:443:192.210.137.113` direct-to-origin checks were healthy, but public requests through Cloudflare returned `301 Location: https://$host$request_uri`.
35-- The token available to this rollout could edit DNS but could not read or change zone SSL settings; `GET /zones/<zone>/settings/ssl` returned `9109 Unauthorized`.
36-- The observed behavior is consistent with the zone still being on `Flexible`, so the rollout switched these three conductor records to DNS-only instead of leaving public traffic on a redirect loop.
37-
38-Authoritative DNS confirmation after the final PATCH:
39-
40-- `@giancarlo.ns.cloudflare.com conductor.makefile.so -> 192.210.137.113`
41-- `@giancarlo.ns.cloudflare.com mini-conductor.makefile.so -> 192.210.137.113`
42-- `@giancarlo.ns.cloudflare.com mac-conductor.makefile.so -> 192.210.137.113`
43-
44-## VPS / Nginx
45-
46-VPS facts:
47-
48-- host: `racknerd-ff37952`
49-- SSH: `root@192.210.137.113 -p 2222`
50-- Tailscale IPv4: `100.68.201.85`
51-- Nginx: `1.24.0`
52-
53-Packages installed during this rollout:
54-
55-- `apache2-utils`
56-- `python3-certbot-dns-cloudflare`
57-
58-TLS and auth:
59-
60-- Cloudflare DNS challenge credentials written to `/root/.secrets/certbot/cloudflare.ini`
61-- `certbot certonly --authenticator dns-cloudflare ... --cert-name conductor.makefile.so -d conductor.makefile.so`
62-- `certbot certonly --authenticator dns-cloudflare ... --cert-name mini-conductor.makefile.so -d mini-conductor.makefile.so`
63-- `certbot certonly --authenticator dns-cloudflare ... --cert-name mac-conductor.makefile.so -d mac-conductor.makefile.so`
64-- all three certificates expire on `2026-06-19`
65-- Basic Auth file created at `/etc/nginx/.htpasswd-baa-conductor`
66-- local secret copy stored outside the repo at `/Users/george/.config/baa-conductor/direct-node-basic-auth.env`
67-
68-Nginx deployment:
69-
70-- bundle rendered from `/Users/george/code/baa-conductor-T028-v2/.tmp/ops/baa-conductor-nginx`
71-- synced to `/tmp/baa-conductor-nginx` on the VPS
72-- `./deploy-on-vps.sh` passed `nginx -t`
73-- `./deploy-on-vps.sh --reload` passed and reloaded Nginx
74-
75-## Public verification
76-
77-Control plane:
78-
79-- `curl -H "Authorization: Bearer $CONTROL_API_OPS_ADMIN_TOKEN" https://control-api.makefile.so/v1/system/state`
80-- result: `ok=true`, `holder_id=mini-main`, `mode=running`
81-
82-Pre-cutover ingress verification against the fresh VPS config:
83-
84-- `curl --resolve conductor.makefile.so:443:192.210.137.113 https://conductor.makefile.so/healthz -> ok`
85-- `curl --resolve conductor.makefile.so:443:192.210.137.113 https://conductor.makefile.so/rolez -> leader`
86-- `curl --resolve mini-conductor.makefile.so:443:192.210.137.113 https://mini-conductor.makefile.so/healthz -> 401` without auth
87-- `curl -u conductor-ops:... --resolve mini-conductor.makefile.so:443:192.210.137.113 https://mini-conductor.makefile.so/healthz -> ok`
88-- `curl -u conductor-ops:... --resolve mini-conductor.makefile.so:443:192.210.137.113 https://mini-conductor.makefile.so/rolez -> leader`
89-- `curl --resolve mac-conductor.makefile.so:443:192.210.137.113 https://mac-conductor.makefile.so/healthz -> 401` without auth
90-- `curl -u conductor-ops:... --resolve mac-conductor.makefile.so:443:192.210.137.113 https://mac-conductor.makefile.so/healthz -> ok`
91-- `curl -u conductor-ops:... --resolve mac-conductor.makefile.so:443:192.210.137.113 https://mac-conductor.makefile.so/rolez -> standby`
92-
93-VPS upstream probes:
94-
95-- `curl http://100.71.210.78:4317/healthz -> ok`
96-- `curl http://100.112.239.13:4317/healthz -> ok`
97-- `curl http://100.71.210.78:4318/healthz -> ok`
98-- `curl http://100.112.239.13:4318/healthz -> ok`
99-
100-## Follow-up
101-
102-- If these three conductor domains need to go back behind Cloudflare proxy, use a token that can change zone SSL settings and switch the zone from `Flexible` to `Full` or `Full (strict)` before turning `proxied=true` back on.
1@@ -1,236 +0,0 @@
2-# 2026-03-22 Real Stability Regression
3-
4-## Scope
5-
6-This pass reused the live environment from `T-028` instead of redeploying:
7-
8-- `control-api.makefile.so`
9-- `conductor.makefile.so`
10-- `mini-conductor.makefile.so`
11-- `mac-conductor.makefile.so`
12-- `mini` on Tailscale `100.71.210.78`
13-- `mac` on Tailscale `100.112.239.13`
14-- VPS `192.210.137.113`
15-
16-The goal was a real live-environment regression on smoke, observation, planned failover, switchback, and residual risk capture.
17-
18-## Environment Snapshot
19-
20-- authoritative DNS at `@giancarlo.ns.cloudflare.com` returned `192.210.137.113` for all three conductor hosts during this pass
21-- both installed launchd plists still point at `/Users/george/code/baa-conductor-T028-v2`, not `/Users/george/code/baa-conductor`
22-- `ps` showed no active `worker-runner` process on either node before planned failover
23-- origin TLS on `192.210.137.113:443` currently presents these expiry dates:
24- - `conductor.makefile.so`: `notAfter=Jun 19 18:17:37 2026 GMT`
25- - `mini-conductor.makefile.so`: `notAfter=Jun 19 18:18:23 2026 GMT`
26- - `mac-conductor.makefile.so`: `notAfter=Jun 19 18:19:28 2026 GMT`
27-
28-## Baseline Smoke
29-
30-Executed on 2026-03-22 CST before failover:
31-
32-```bash
33-./scripts/failover/print-topology.sh --env /Users/george/.config/baa-conductor/ops.env
34-
35-./scripts/failover/rehearsal-check.sh \
36- --env /Users/george/.config/baa-conductor/ops.env \
37- --basic-auth "$DIRECT_BASIC_AUTH" \
38- --bearer-token "$CONTROL_API_OPS_ADMIN_TOKEN" \
39- --expect-leader mini
40-
41-node scripts/smoke/live-regression.mjs \
42- --env /Users/george/.config/baa-conductor/ops.env \
43- --control-secrets /Users/george/.config/baa-conductor/control-api-worker.secrets.env \
44- --basic-auth-file /Users/george/.config/baa-conductor/direct-node-basic-auth.env \
45- --expect-leader mini
46-
47-./scripts/runtime/check-node.sh \
48- --repo-dir /Users/george/code/baa-conductor-T028-v2 \
49- --node mini \
50- --service conductor \
51- --service status-api \
52- --install-dir /Users/george/Library/LaunchAgents \
53- --local-api-base http://100.71.210.78:4317 \
54- --local-api-allowed-hosts 100.71.210.78 \
55- --status-api-base http://100.71.210.78:4318 \
56- --status-api-host 100.71.210.78 \
57- --expected-rolez leader \
58- --check-loaded
59-
60-ssh george@100.112.239.13 \
61- 'cd /Users/george/code/baa-conductor-T028-v2 && ./scripts/runtime/check-node.sh \
62- --repo-dir /Users/george/code/baa-conductor-T028-v2 \
63- --node mac \
64- --service conductor \
65- --service status-api \
66- --install-dir /Users/george/Library/LaunchAgents \
67- --local-api-base http://100.112.239.13:4317 \
68- --local-api-allowed-hosts 100.112.239.13 \
69- --status-api-base http://100.112.239.13:4318 \
70- --status-api-host 100.112.239.13 \
71- --expected-rolez standby \
72- --check-loaded'
73-```
74-
75-Observed baseline:
76-
77-- `control-api /v1/system/state` returned `holder_id=mini-main`, `mode=running`, `term=1`
78-- public ingress returned `healthz=ok`, `readyz=ready`, `rolez=leader`
79-- `mini` direct host returned `healthz=ok`, `readyz=ready`, `rolez=leader`
80-- `mac` direct host returned `healthz=ok`, `readyz=ready`, `rolez=standby`
81-- unauthenticated direct-host probes returned `401`
82-- both `check-node.sh` passes succeeded against the live `T028-v2` runtime
83-
84-Smoke did not fully pass:
85-
86-- both `http://100.71.210.78:4318/v1/status` and `http://100.112.239.13:4318/v1/status` returned `ok=true`
87-- but both payloads stayed at `source=empty`, `mode=paused`, `leaderId=null`
88-- this did not match the live control plane state (`mini-main`, `running`)
89-
90-## 30-Minute Observation Window
91-
92-Sampling method:
93-
94-- command: `node scripts/smoke/live-regression.mjs ... --expect-leader mini --compact`
95-- sample count: `7`
96-- interval: `300s`
97-- raw log: `/tmp/t029-observation-20260322.jsonl`
98-- window: `2026-03-22 03:43:28 CST` to `2026-03-22 04:13:44 CST` (`30.3` minutes)
99-
100-Observed across all 7 samples:
101-
102-- control plane holder stayed `mini-main`
103-- control plane mode stayed `running`
104-- public `/rolez` stayed `leader`
105-- `mini` direct `/rolez` stayed `leader`
106-- `mac` direct `/rolez` stayed `standby`
107-- no `healthz` or `readyz` failure was observed on public or direct conductor hosts
108-- direct-host Basic Auth remained enforced
109-- the only repeated smoke failure was status drift:
110- - `mini status-api`: `source=empty`, `mode=paused`
111- - `mac status-api`: `source=empty`, `mode=paused`
112-
113-## Planned Failover
114-
115-Preconditions captured before cutover:
116-
117-- no `worker-runner` process on `mini`
118-- no `worker-runner` process on `mac`
119-- note: because `status-api` was already stale, active-run confirmation had to rely on host process checks instead of `/v1/status`
120-
121-Commands used:
122-
123-```bash
124-curl -X POST \
125- -H "Authorization: Bearer $CONTROL_API_BROWSER_ADMIN_TOKEN" \
126- -H 'Content-Type: application/json' \
127- -d '{"requested_by":"T-029","reason":"planned_failover_rehearsal"}' \
128- https://control-api.makefile.so/v1/system/drain
129-
130-curl -X POST \
131- -H "Authorization: Bearer $CONTROL_API_BROWSER_ADMIN_TOKEN" \
132- -H 'Content-Type: application/json' \
133- -d '{"requested_by":"T-029","reason":"planned_failover_cutover"}' \
134- https://control-api.makefile.so/v1/system/pause
135-
136-launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"
137-```
138-
139-Timeline in CST:
140-
141-- `04:14:48`: `drain` accepted
142-- `04:15:03`: control plane confirmed `mode=paused`
143-- `04:15:09`: `mini` conductor booted out from launchd
144-- `04:15:25`: first post-stop probe showed `public /rolez=standby`, `mac /rolez=standby`, control holder still `mini-main`
145-- `04:15:40`: second probe showed `public /rolez=standby`, `mac /rolez=leader`, control holder `mac-standby`, `term=2`
146-- `04:15:54`: third probe showed `public /rolez=leader`, `mac /rolez=leader`, control holder `mac-standby`, `term=2`
147-- `04:16:29`: authenticated `mini-conductor.makefile.so/healthz` returned `502`; `curl --noproxy '*' http://100.71.210.78:4317/healthz` failed with curl `7` / HTTP `000`
148-- `04:17:00`: `resume` accepted and control plane returned `mode=running`, `holder_id=mac-standby`
149-
150-Failover conclusion:
151-
152-- planned failover worked on the real environment
153-- lease moved from `mini-main` to `mac-standby`
154-- public ingress recovered back to `leader` without DNS edits
155-- there was a real transient cutover window after stopping `mini`
156- - public `/rolez` was observed as `standby`
157- - the window was still present at `04:15:40`
158- - it had cleared by `04:15:54`
159-
160-## Switchback
161-
162-Commands used:
163-
164-```bash
165-curl -X POST \
166- -H "Authorization: Bearer $CONTROL_API_BROWSER_ADMIN_TOKEN" \
167- -H 'Content-Type: application/json' \
168- -d '{"requested_by":"T-029","reason":"switchback_prepare"}' \
169- https://control-api.makefile.so/v1/system/pause
170-
171-ssh george@100.112.239.13 \
172- 'launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"'
173-
174-cd /Users/george/code/baa-conductor-T028-v2
175-./scripts/runtime/reload-launchd.sh --service conductor --install-dir /Users/george/Library/LaunchAgents
176-```
177-
178-Timeline in CST:
179-
180-- `04:17:17`: switchback `pause` accepted
181-- `04:17:24`: `mac` conductor booted out; `mini` conductor reload completed
182-- `04:17:41`: first probe showed `public /rolez=standby`, `mini /rolez=standby`, control holder still `mac-standby`
183-- by the next probe window, control holder had returned to `mini-main`, `term=3`, and both public/mini `/rolez` were back to `leader`
184-- `04:18:08`: `resume` accepted and control plane returned `mode=running`, `holder_id=mini-main`
185-
186-Switchback was not fully closed at that point:
187-
188-- `04:18:23` snapshot still showed `mac-conductor.makefile.so` returning `502`
189-- that was expected from the runbook sequence because `mac` had been stopped, but it meant the topology was not yet back to `mini leader + mac standby`
190-
191-Canonical topology restoration required one more explicit step:
192-
193-```bash
194-ssh george@100.112.239.13 \
195- 'cd /Users/george/code/baa-conductor-T028-v2 && ./scripts/runtime/reload-launchd.sh \
196- --service conductor \
197- --install-dir /Users/george/Library/LaunchAgents'
198-```
199-
200-Observed after reloading `mac`:
201-
202-- `04:18:41`: remote reload started
203-- first remote `check-node.sh` after reload saw the `mac` conductor process but reported `conductor is not listening on TCP port 4317`
204-- a few seconds later, `rehearsal-check.sh` showed `mac-conductor.makefile.so` back at `rolez=standby`
205-- `04:19:51`: final `live-regression.mjs` snapshot showed public/direct roles back to `leader / leader / standby`
206-- final local and remote `check-node.sh` runs both passed
207-
208-Switchback conclusion:
209-
210-- control plane and public ingress successfully returned to `mini-main`, `term=3`
211-- restoring `mac` as standby required an explicit post-switchback reload
212-- there was a short runtime window where the process existed before `4317` was actually listening
213-
214-## Current Findings
215-
216-### Confirmed working
217-
218-- authoritative DNS still points all conductor hosts at the VPS public IP
219-- public ingress stayed available through planned failover and switchback
220-- leader lease moved `mini -> mac -> mini`
221-- direct-host Basic Auth stayed enforced
222-- both nodes can return to a healthy `leader / standby` split after explicit reloads
223-
224-### Confirmed regression
225-
226-- both `status-api /v1/status` endpoints are stale in live
227-- they continue to report `source=empty`, `mode=paused`, `leaderId=null`
228-- this remained true before failover, during failover, after switchback, and after both nodes re-entered healthy roles
229-
230-## Residual Risks
231-
232-- `status-api` is not a reliable truth source in the live environment right now; it cannot be used to decide whether automation is really `running` or whether leader identity has updated.
233-- installed launchd plists on both nodes still target `/Users/george/code/baa-conductor-T028-v2`; the runtime has not been normalized to the canonical repo path.
234-- switchback is not a one-command return to `mini leader + mac standby`; after moving leadership back to `mini`, `mac` still needed an explicit `reload-launchd.sh --service conductor`.
235-- there is a real short-lived public inconsistency window during both failover and switchback where `/rolez` can read `standby` before the next leader is externally visible as `leader`.
236-- this rollout is still operating in DNS-only mode from the authoritative DNS perspective; Cloudflare proxy is not back in service for the conductor hosts.
237-- local non-authoritative resolver output was inconsistent with authoritative DNS for some hostnames during this task, which is another reminder that local DNS/proxy layers can mislead direct diagnostics.
+0,
-145
1@@ -1,145 +0,0 @@
2-# Switchback Runbook
3-
4-适用场景:
5-
6-- emergency 或 planned failover 之后,当前 leader 在 `mac`
7-- `mini` 已修复,准备把系统恢复到 canonical 的“mini 主、mac 备”
8-
9-switchback 的重点不是“尽快让 mini 上线”,而是“把临时状态清干净,再回到可重复的默认形态”。
10-
11-## 1. 先修 mini,不先切流量
12-
13-在 `mini` 上先把基础面校验完:
14-
15-```bash
16-cd /Users/george/code/baa-conductor
17-./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor
18-npx --yes pnpm -r build
19-./scripts/runtime/check-launchd.sh \
20- --repo-dir /Users/george/code/baa-conductor \
21- --node mini \
22- --install-dir "$HOME/Library/LaunchAgents"
23-```
24-
25-如有需要,重渲染并重载安装副本:
26-
27-```bash
28-cd /Users/george/code/baa-conductor
29-./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mini
30-./scripts/runtime/reload-launchd.sh
31-```
32-
33-在还没停掉 mac 之前,mini 即使已经恢复,也可能仍只是 `standby` 或暂时拿不到 lease,这是正常的。
34-
35-## 2. 先 pause,再移交
36-
37-switchback 前先暂停自动化:
38-
39-```bash
40-export CONTROL_API_BASE='https://control-api.makefile.so'
41-export BROWSER_ADMIN_TOKEN='REPLACE_ME'
42-
43-curl -sS -X POST \
44- -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
45- -H 'Content-Type: application/json' \
46- -d '{"requested_by":"ops_runbook","reason":"switchback_prepare"}' \
47- "${CONTROL_API_BASE%/}/v1/system/pause"
48-```
49-
50-## 3. 停掉 mac conductor
51-
52-为了让 lease 和公网入口都回到 mini,需要先让 mac conductor 退下。
53-
54-默认 `LaunchAgents`:
55-
56-```bash
57-launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"
58-```
59-
60-若使用 `LaunchDaemons`:
61-
62-```bash
63-sudo launchctl bootout system /Library/LaunchDaemons/so.makefile.baa-conductor.plist
64-```
65-
66-然后在 mini 上确保 conductor 重新 bootstrap:
67-
68-```bash
69-cd /Users/george/code/baa-conductor
70-./scripts/runtime/reload-launchd.sh
71-```
72-
73-## 4. 恢复 canonical Nginx
74-
75-如果 emergency failover 时对 VPS 做过热修,switchback 必须用 repo 的 canonical 配置覆盖回去。
76-
77-生成 bundle:
78-
79-```bash
80-scripts/ops/nginx-sync-plan.sh \
81- --env ../baa-conductor.ops.env \
82- --bundle-dir .tmp/ops/baa-conductor-nginx
83-```
84-
85-分发并 reload:
86-
87-```bash
88-rsync -av .tmp/ops/baa-conductor-nginx/ root@YOUR_VPS:/tmp/baa-conductor-nginx/
89-ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh --reload'
90-```
91-
92-这样会把 VPS 配置恢复成:
93-
94-- mini `100.71.210.78:4317` 为 primary
95-- mac `100.112.239.13:4317` 为 backup
96-
97-## 5. 验证已经回到 mini
98-
99-执行:
100-
101-```bash
102-./scripts/failover/rehearsal-check.sh \
103- --env ../baa-conductor.ops.env \
104- --basic-auth 'conductor-ops:REPLACE_ME' \
105- --bearer-token 'REPLACE_ME' \
106- --skip-node mac \
107- --expect-leader mini
108-```
109-
110-成功条件:
111-
112-- `conductor.makefile.so` 返回 `leader`
113-- `mini-conductor.makefile.so` 返回 `leader`
114-- `GET /v1/system/state` 的 `holder_id` 以 `mini-` 开头
115-
116-如果这一步失败,不要急着 `resume`,先查:
117-
118-- mini 的 `launchctl print`
119-- mini 的 `logs/launchd/so.makefile.baa-conductor.err.log`
120-- VPS 上是否还有 emergency 热修残留
121-
122-## 6. 恢复自动化
123-
124-确认 switchback 完成后,再 resume:
125-
126-```bash
127-curl -sS -X POST \
128- -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
129- -H 'Content-Type: application/json' \
130- -d '{"requested_by":"ops_runbook","reason":"switchback_complete"}' \
131- "${CONTROL_API_BASE%/}/v1/system/resume"
132-```
133-
134-## 7. 收尾检查
135-
136-switchback 后建议立刻补做一次完整基线:
137-
138-```bash
139-./scripts/failover/rehearsal-check.sh \
140- --env ../baa-conductor.ops.env \
141- --basic-auth 'conductor-ops:REPLACE_ME' \
142- --bearer-token 'REPLACE_ME' \
143- --expect-leader mini
144-```
145-
146-如果这份结果和正常基线一致,说明系统已经回到默认拓扑。
+15,
-31
1@@ -1,41 +1,25 @@
2 # runtime
3
4-本目录定义当前推荐的 `mini` 单节点 runtime 约定:目录布局、环境变量,以及 `scripts/runtime/*.sh` 驱动的 `launchd` 安装方式。
5-
6-当前仓库已经把 app 级 `build` 从单纯 typecheck 推进到真实 emit。执行 `npx --yes pnpm -r build` 后,`apps/*/dist/index.js` 会生成,`ops/launchd/*.plist` 里的入口路径也因此固定下来。
7-
8-这仍然不代表所有长期服务都已经完成运行时接线。当前阶段解决的是“产物路径存在且一致”,而不是“所有 daemon/worker 逻辑都已可直接上线”。
9+当前 runtime 只定义 `mini` 单节点的长期运行方式。
10
11 ## 内容
12
13-- [`layout.md`](./layout.md): `runs/`、`worktrees/`、`logs/`、`tmp/` 与 `state/` 的初始化方式和生命周期
14-- [`environment.md`](./environment.md): `launchd` 下必须显式写入的环境变量,以及安装脚本如何覆盖默认值
15-- [`launchd.md`](./launchd.md): `mini` 的脚本化安装步骤,以及 `LaunchAgents` / `LaunchDaemons` 的差异
16-- [`node-verification.md`](./node-verification.md): `mini` 节点的 on-node 验证顺序、期望探针结果,以及日志/进程检查点
17+- [`layout.md`](./layout.md): runtime 目录布局
18+- [`environment.md`](./environment.md): 必要环境变量
19+- [`launchd.md`](./launchd.md): `mini` 上的 launchd 安装
20+- [`node-verification.md`](./node-verification.md): `mini` 节点 on-node 检查
21
22-## 统一约定
23+## 当前约定
24
25-- 当前只保留 `mini` 作为长期运行节点,默认跑 `so.makefile.baa-conductor`。
26-- `mac` 不再作为备主节点要求的一部分;相关说明只保留作历史参考。
27-- 推荐把运行中的仓库放在 `/Users/george/code/baa-conductor`,这样 repo 内的 plist 源模板可以直接复用设计里的绝对路径。
28-- repo 中的 plist 只作为源模板;真正加载的是 `scripts/runtime/install-launchd.sh` 复制并改写到 `~/Library/LaunchAgents/` 或 `/Library/LaunchDaemons/` 的副本。
29+- 长期运行节点只有 `mini`
30+- 推荐仓库路径:`/Users/george/code/baa-conductor`
31+- repo 内的 plist 只作为模板;真正加载的是脚本渲染出来的安装副本
32
33 ## 最短路径
34
35-1. 先按 [`layout.md`](./layout.md) 运行 `./scripts/runtime/bootstrap.sh` 初始化 runtime 根目录。
36-2. 再按 [`environment.md`](./environment.md) 准备共享变量和 `mini` 节点变量,特别是 `BAA_SHARED_TOKEN`。
37-3. 按 [`launchd.md`](./launchd.md) 运行 `install-launchd.sh` 生成安装副本,先用 `check-launchd.sh` 做静态校验。
38-4. 在节点上已有实际进程后,再按 [`node-verification.md`](./node-verification.md) 运行 `check-node.sh` 做端口、探针、status-api 宿主进程与日志路径检查。
39-5. 每次准备执行 `check-launchd.sh` 的 dist 校验、`check-node.sh` 的进程检查,或真正 `launchctl bootstrap` 前,先在 repo 根目录执行一次 `npx --yes pnpm -r build`,确认目标 app 的 `dist/index.js` 已更新。
40-
41-## 当前脚本集
42-
43-- `scripts/runtime/bootstrap.sh`: 预创建 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`tmp/`
44-- `scripts/runtime/install-launchd.sh`: 从 `ops/launchd/*.plist` 渲染实际安装副本
45-- `scripts/runtime/check-launchd.sh`: 校验源模板、runtime 目录、构建产物,以及已安装的 plist 副本
46-- `scripts/runtime/check-node.sh`: 在节点上校验 launchd 副本之外的运行态信号,例如本地端口、HTTP 探针、status-api 宿主进程与 launchd 日志文件
47-- `scripts/runtime/reload-launchd.sh`: 执行或 dry-run `launchctl bootout/bootstrap/kickstart`
48-
49-默认安装/检查/重载集合只包含 `conductor`。如果后续要把其它模板也纳入流程,显式加 `--service worker-runner`、`--service status-api`,或直接使用 `--all-services`。
50-
51-`check-node.sh` 的默认集合不同:它默认同时检查 `conductor` 和 `status-api`,因为真实节点验证至少要覆盖本地控制面和状态面两条路径。如果节点暂时不跑 `status-api`,再显式收窄到 `--service conductor`。
52+1. `./scripts/runtime/bootstrap.sh`
53+2. `npx --yes pnpm -r build`
54+3. `./scripts/runtime/install-launchd.sh --node mini`
55+4. `./scripts/runtime/check-launchd.sh --node mini`
56+5. `./scripts/runtime/reload-launchd.sh`
57+6. `./scripts/runtime/check-node.sh --node mini`
+22,
-97
1@@ -1,111 +1,36 @@
2-# environment
3+# runtime environment
4
5-## 原则
6+当前只保留 `mini` 单节点变量。
7
8-- `launchd` 不会自动读取 `.zshrc`、`.zprofile` 或 repo 根目录下的 `.env`。
9-- plist 里出现的路径都必须是绝对路径,不能依赖 `~`、`$HOME` 或 shell 展开。
10-- `BAA_SHARED_TOKEN` 必须在安装副本里替换成真实值,repo 内源模板保持 `replace-me`。
11-- `conductor` 与 `status-api` 当前都只绑定一个 listen host;默认是 loopback,真实节点如需给 VPS 回源,必须显式切到对应的 Tailscale `100.x`。
12-- 如果 `mini` 与 `mac` 都使用 `/Users/george/code/baa-conductor`,目录变量可以完全一致,只有节点身份变量需要区分。
13+## 共享变量
14
15-## 应用层变量
16+- `BAA_CONTROL_API_BASE`
17+- `BAA_SHARED_TOKEN`
18+- `BAA_RUNS_DIR`
19+- `BAA_WORKTREES_DIR`
20+- `BAA_LOGS_DIR`
21+- `BAA_TMP_DIR`
22+- `BAA_STATE_DIR`
23
24-| 变量 | `mini` | `mac` | 说明 |
25-| --- | --- | --- | --- |
26-| `BAA_CONDUCTOR_HOST` | `mini` | `mac` | 节点标识;应与节点域名和故障切换语义一致 |
27-| `BAA_CONDUCTOR_ROLE` | `primary` | `standby` | conductor 期望角色;切回主节点前不要让 `mac` 默认成 `primary` |
28-| `BAA_CONTROL_API_BASE` | `https://control-api.makefile.so` | `https://control-api.makefile.so` | 控制平面统一入口 |
29-| `BAA_CONDUCTOR_LOCAL_API` | `http://127.0.0.1:4317` | `http://127.0.0.1:4317` | 默认 loopback;真实 Tailscale 回源时改成节点自己的 `http://100.x:4317` |
30-| `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS` | 空 | 空 | 仅当 `BAA_CONDUCTOR_LOCAL_API` 改成 Tailscale `100.x` 时填写;必须显式列出允许绑定的 host |
31-| `BAA_STATUS_API_HOST` | `127.0.0.1` | `127.0.0.1` | `status-api` 默认 loopback;真实节点如需 Tailscale 访问时改成节点自己的 `100.x` |
32-| `BAA_RUNS_DIR` | `/Users/george/code/baa-conductor/runs` | `/Users/george/code/baa-conductor/runs` | run 根目录 |
33-| `BAA_WORKTREES_DIR` | `/Users/george/code/baa-conductor/worktrees` | `/Users/george/code/baa-conductor/worktrees` | task worktree 根目录 |
34-| `BAA_LOGS_DIR` | `/Users/george/code/baa-conductor/logs` | `/Users/george/code/baa-conductor/logs` | 服务日志根目录 |
35-| `BAA_TMP_DIR` | `/Users/george/code/baa-conductor/tmp` | `/Users/george/code/baa-conductor/tmp` | 临时目录根 |
36-| `BAA_STATE_DIR` | `/Users/george/code/baa-conductor/state` | `/Users/george/code/baa-conductor/state` | 本地状态镜像目录 |
37-| `BAA_NODE_ID` | `mini-main` | `mac-standby` | 节点实例 ID;应稳定且可用于 lease/日志归因 |
38-| `BAA_SHARED_TOKEN` | 安装时注入 | 安装时注入 | 节点到 control API 的共享认证口令 |
39-
40-`scripts/runtime/install-launchd.sh` 会把这些路径写进安装副本;如果 repo 根目录不在 `/Users/george/code/baa-conductor`,直接通过 `--repo-dir` 派生新的路径,不需要再手工改每个 plist。
41-
42-## Tailscale rollout 覆盖值
43-
44-默认表格保持的是安全的 loopback 基线。真实节点要让 VPS 通过 Tailscale `100.x` 回源时,覆盖成下面这样:
45-
46-`mini`:
47+## 节点变量
48
49 ```text
50+BAA_CONDUCTOR_HOST=mini
51+BAA_CONDUCTOR_ROLE=primary
52+BAA_NODE_ID=mini-main
53 BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317
54 BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.71.210.78
55 BAA_STATUS_API_HOST=100.71.210.78
56 ```
57
58-`mac`:
59-
60-```text
61-BAA_CONDUCTOR_LOCAL_API=http://100.112.239.13:4317
62-BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS=100.112.239.13
63-BAA_STATUS_API_HOST=100.112.239.13
64-```
65-
66-约束说明:
67-
68-- `BAA_CONDUCTOR_LOCAL_API` 仍只接受 `http://`,且 host 只能是 loopback 或 `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS` 里显式列出的 Tailscale `100.x`
69-- `BAA_STATUS_API_HOST` 默认不需要改;只有确实要通过 Tailscale 直连 `status-api` 时才切到 `100.x`
70-- 当前实现不是双监听;切到 `100.x` 后,`check-node.sh` 也要跟着传 `--local-api-base` / `--status-api-base`
71-
72-## launchd 辅助变量
73-
74-| 变量 | 推荐值 | 说明 |
75-| --- | --- | --- |
76-| `PATH` | `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/george/.local/bin:/Users/george/bin` | 让 `node`、`git`、`pnpm`、`codex` 等命令在 `launchd` 环境里可发现 |
77-| `HOME` | `/Users/george` | 给后续子进程一个稳定的 home 目录 |
78-| `LANG` | `en_US.UTF-8` | 保证日志和文本处理统一用 UTF-8 |
79-| `LC_ALL` | `en_US.UTF-8` | 避免不同 locale 造成命令输出差异 |
80-
81-这些变量属于 `launchd` 安装层面的补充,不代表当前应用代码已经消费了全部字段。模板里统一保留它们,是为了避免后续服务落地时再拆分不同的 plist 版本。
82-
83-`install-launchd.sh` 会同时根据 `--home-dir` 重写 `HOME` 和 `PATH`,避免模板里残留固定的 `/Users/george`。
84-
85-## 脚本输入约定
86-
87-`scripts/runtime/install-launchd.sh` / `check-launchd.sh` 目前主要消费这些输入:
88-
89-- `--node mini|mac`:决定 `BAA_CONDUCTOR_HOST`、`BAA_CONDUCTOR_ROLE`、`BAA_NODE_ID`
90-- `--repo-dir PATH`:决定 `WorkingDirectory`、`BAA_*_DIR` 与 `ProgramArguments` 里的 dist 入口
91-- `--home-dir PATH`:决定 `HOME`、`PATH` 与默认 `~/Library/LaunchAgents`
92-- `--shared-token`、`--shared-token-file` 或环境变量 `BAA_SHARED_TOKEN`:写入或校验 `BAA_SHARED_TOKEN`
93-- `--control-api-base`、`--local-api-base`:覆盖默认控制平面地址
94-- `--local-api-allowed-hosts`:覆盖 `BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS`
95-- `--status-api-host`:覆盖 `BAA_STATUS_API_HOST`
96-
97-## 节点最小差异集
98-
99-如果两台机器的 repo 路径一致,`mac` 相对 `mini` 最少只要改这三项:
100-
101-```text
102-BAA_CONDUCTOR_HOST=mac
103-BAA_CONDUCTOR_ROLE=standby
104-BAA_NODE_ID=mac-standby
105-```
106-
107-此外,conductor plist 里的 CLI 参数也要同步从:
108-
109-```text
110---host mini --role primary
111-```
112-
113-改成:
114-
115-```text
116---host mac --role standby
117-```
118-
119-脚本化安装时,这些差异不需要手工改 plist;改成:
120+## 最小例子
121
122 ```bash
123-BAA_SHARED_TOKEN='replace-with-real-token'
124-./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac
125+./scripts/runtime/install-launchd.sh \
126+ --repo-dir /Users/george/code/baa-conductor \
127+ --node mini \
128+ --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
129+ --local-api-base http://100.71.210.78:4317 \
130+ --local-api-allowed-hosts 100.71.210.78 \
131+ --status-api-host 100.71.210.78
132 ```
133-
134-即可把安装副本改成 `mac` 的节点身份。
+30,
-295
1@@ -1,338 +1,73 @@
2 # launchd
3
4-## 服务集合
5+当前只记录 `mini` 的 launchd 安装路径。
6
7-repo 里保留了三个源模板:
8-
9-- `so.makefile.baa-conductor`
10-- `so.makefile.baa-worker-runner`
11-- `so.makefile.baa-status-api`
12-
13-当前脚本化流程默认只安装和重载 `conductor`。如果要把其它模板也纳入流程,显式加:
14-
15-- `--service worker-runner`
16-- `--service status-api`
17-- 或直接用 `--all-services`
18-
19-这样可以先把已接成 CLI/runtime 入口的服务跑通,再按需扩展其他模板。
20-
21-需要注意两个脚本默认集合不同:
22-
23-- `install-launchd.sh` / `check-launchd.sh` / `reload-launchd.sh` 默认只处理 `conductor`
24-- `check-node.sh` 默认同时处理 `conductor` 和 `status-api`,因为节点验证至少要覆盖本地控制面与状态面
25-
26-repo 内的三个 plist 源模板都默认写成 `mini` 的 canonical 配置:
27-
28-- repo 根目录:`/Users/george/code/baa-conductor`
29-- 登录用户:`/Users/george`
30-- conductor 参数:`--host mini --role primary`
31-- token:`replace-me`
32-
33-安装前至少检查:
34-
35-- `BAA_SHARED_TOKEN` 已替换成真实值
36-- `logs/launchd/` 已按 [`layout.md`](./layout.md) 创建
37-- 如果 repo 不在 `/Users/george/code/baa-conductor`,同步改 `WorkingDirectory`、`ProgramArguments`、`HOME`、`StandardOutPath`、`StandardErrorPath`
38-
39-实际安装时推荐让脚本做这些改写,而不是手工编辑 plist。
40-
41-## 监听策略
42-
43-默认安装副本保持安全的 loopback 监听:
44-
45-- conductor: `http://127.0.0.1:4317`
46-- status-api: `http://127.0.0.1:4318`
47-
48-真实 rollout 如果要让 VPS 直接回源到节点,就显式切到对应节点的 Tailscale `100.x`:
49-
50-- `mini`: conductor `http://100.71.210.78:4317`,status-api `http://100.71.210.78:4318`
51-- `mac`: conductor `http://100.112.239.13:4317`,status-api `http://100.112.239.13:4318`
52-
53-当前实现不是双监听。切到 Tailscale 后,需要同时设置:
54-
55-- `--local-api-base`
56-- `--local-api-allowed-hosts`
57-- `--status-api-host`
58-
59-## `LaunchAgents` 与 `LaunchDaemons`
60-
61-| 目标路径 | 适合场景 | 优点 | 额外要求 |
62-| --- | --- | --- | --- |
63-| `~/Library/LaunchAgents/` | 开发机、自用节点、登录后再启动即可 | 不需要 `sudo`,调试最直接 | 只会在用户登录后启动 |
64-| `/Library/LaunchDaemons/` | 需要开机登录前就启动 | 适合 headless 常驻 | plist 必须 `root:wheel` 且通常要额外加 `UserName` |
65-
66-默认推荐先用 `LaunchAgents` 跑通,再按需切到 `LaunchDaemons`。
67-
68-## mini 安装
69-
70-`mini` 可以直接复用 repo 模板的大部分默认值。推荐顺序如下。
71-
72-### 1. 初始化 runtime 根目录
73-
74-```bash
75-REPO_DIR=/Users/george/code/baa-conductor
76-./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
77-```
78-
79-### 2. 构建 dist 入口
80+## 1. 构建
81
82 ```bash
83-cd /Users/george/code/baa-conductor
84 npx --yes pnpm -r build
85 ```
86
87-### 3. 渲染安装副本
88+## 2. 初始化 runtime 目录
89
90 ```bash
91-REPO_DIR=/Users/george/code/baa-conductor
92-export BAA_SHARED_TOKEN='replace-with-real-token'
93-
94-./scripts/runtime/install-launchd.sh \
95- --repo-dir "$REPO_DIR" \
96- --node mini
97+./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor
98 ```
99
100-如果要同时渲染 runner/status 模板:
101+## 3. 渲染安装副本
102
103 ```bash
104 ./scripts/runtime/install-launchd.sh \
105- --repo-dir "$REPO_DIR" \
106- --node mini \
107- --all-services
108-```
109-
110-如果这台 `mini` 要给 VPS 真实回源,改成:
111-
112-```bash
113-./scripts/runtime/install-launchd.sh \
114- --repo-dir "$REPO_DIR" \
115+ --repo-dir /Users/george/code/baa-conductor \
116 --node mini \
117- --all-services \
118+ --service conductor \
119+ --service status-api \
120+ --install-dir /Users/george/Library/LaunchAgents \
121+ --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
122+ --control-api-base https://control-api.makefile.so \
123 --local-api-base http://100.71.210.78:4317 \
124 --local-api-allowed-hosts 100.71.210.78 \
125 --status-api-host 100.71.210.78
126 ```
127
128-### 4. 静态校验安装副本
129-
130-```bash
131-REPO_DIR=/Users/george/code/baa-conductor
132-AGENTS_DIR="$HOME/Library/LaunchAgents"
133-
134-./scripts/runtime/check-launchd.sh \
135- --repo-dir "$REPO_DIR" \
136- --node mini \
137- --install-dir "$AGENTS_DIR"
138-```
139-
140-如果安装副本已经切到 Tailscale `100.x`,校验时也要带同一组覆盖值:
141+## 4. 静态校验
142
143 ```bash
144 ./scripts/runtime/check-launchd.sh \
145- --repo-dir "$REPO_DIR" \
146+ --repo-dir /Users/george/code/baa-conductor \
147 --node mini \
148- --all-services \
149- --install-dir "$AGENTS_DIR" \
150+ --service conductor \
151+ --service status-api \
152+ --install-dir /Users/george/Library/LaunchAgents \
153+ --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
154+ --control-api-base https://control-api.makefile.so \
155 --local-api-base http://100.71.210.78:4317 \
156 --local-api-allowed-hosts 100.71.210.78 \
157 --status-api-host 100.71.210.78
158 ```
159
160-### 5. on-node 验证
161-
162-`check-launchd.sh` 只覆盖静态层。节点上已经有真实进程后,再补一轮 on-node 检查:
163+## 5. 重载
164
165 ```bash
166-REPO_DIR=/Users/george/code/baa-conductor
167-AGENTS_DIR="$HOME/Library/LaunchAgents"
168-
169-./scripts/runtime/check-node.sh \
170- --repo-dir "$REPO_DIR" \
171- --node mini \
172- --all-services \
173- --install-dir "$AGENTS_DIR" \
174- --expected-rolez leader
175+./scripts/runtime/reload-launchd.sh \
176+ --install-dir /Users/george/Library/LaunchAgents \
177+ --service conductor \
178+ --service status-api
179 ```
180
181-如果 `mini` 已改成 Tailscale 监听,把 probe URL 也显式改掉:
182+## 6. 节点检查
183
184 ```bash
185 ./scripts/runtime/check-node.sh \
186- --repo-dir "$REPO_DIR" \
187+ --repo-dir /Users/george/code/baa-conductor \
188 --node mini \
189- --all-services \
190- --install-dir "$AGENTS_DIR" \
191+ --service conductor \
192+ --service status-api \
193+ --install-dir /Users/george/Library/LaunchAgents \
194 --local-api-base http://100.71.210.78:4317 \
195 --local-api-allowed-hosts 100.71.210.78 \
196 --status-api-base http://100.71.210.78:4318 \
197 --status-api-host 100.71.210.78 \
198- --expected-rolez leader
199-```
200-
201-如果 `status-api` 暂时不在该节点常驻,把 `--all-services` 改成 `--service conductor`。
202-
203-### 6. 预览或执行重载
204-
205-```bash
206-./scripts/runtime/reload-launchd.sh --dry-run
207-```
208-
209-确认输出无误后,再去掉 `--dry-run` 真正执行。
210-
211-## mac 安装
212-
213-`mac` 与 `mini` 用同一组模板,但节点身份切到 standby。
214-
215-### 1. 初始化 runtime 根目录与构建
216-
217-```bash
218-REPO_DIR=/Users/george/code/baa-conductor
219-./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
220-cd "$REPO_DIR"
221-npx --yes pnpm -r build
222-```
223-
224-### 2. 渲染 `mac` 安装副本
225-
226-```bash
227-REPO_DIR=/Users/george/code/baa-conductor
228-export BAA_SHARED_TOKEN='replace-with-real-token'
229-
230-./scripts/runtime/install-launchd.sh \
231- --repo-dir "$REPO_DIR" \
232- --node mac
233-```
234-
235-如果这台 `mac` 要作为 VPS 的 Tailscale 回源备用节点,改成:
236-
237-```bash
238-./scripts/runtime/install-launchd.sh \
239- --repo-dir "$REPO_DIR" \
240- --node mac \
241- --all-services \
242- --local-api-base http://100.112.239.13:4317 \
243- --local-api-allowed-hosts 100.112.239.13 \
244- --status-api-host 100.112.239.13
245+ --expected-rolez leader \
246+ --check-loaded
247 ```
248-
249-### 3. on-node 验证
250-
251-`mac` 的 steady-state 预期是 standby,因此把 `rolez` 预期值改成 `standby`:
252-
253-```bash
254-REPO_DIR=/Users/george/code/baa-conductor
255-AGENTS_DIR="$HOME/Library/LaunchAgents"
256-
257-./scripts/runtime/check-node.sh \
258- --repo-dir "$REPO_DIR" \
259- --node mac \
260- --all-services \
261- --install-dir "$AGENTS_DIR" \
262- --expected-rolez standby
263-```
264-
265-如果 `mac` 已改成 Tailscale 监听,把验证地址同步切到 `100.112.239.13`:
266-
267-```bash
268-./scripts/runtime/check-node.sh \
269- --repo-dir "$REPO_DIR" \
270- --node mac \
271- --all-services \
272- --install-dir "$AGENTS_DIR" \
273- --local-api-base http://100.112.239.13:4317 \
274- --local-api-allowed-hosts 100.112.239.13 \
275- --status-api-base http://100.112.239.13:4318 \
276- --status-api-host 100.112.239.13 \
277- --expected-rolez standby
278-```
279-
280-如果这是 failover rehearsal,把 `--expected-rolez standby` 改成 `--expected-rolez leader`。
281-
282-### 4. 加载服务
283-
284-静态校验和重载命令与 `mini` 相同,只是把 `--node mini` 换成 `--node mac`。
285-
286-## 切到 `LaunchDaemons`
287-
288-如果需要登录前启动,脚本流程改成:
289-
290-1. 用 `bootstrap.sh` 先准备 runtime 根目录
291-2. 用 `install-launchd.sh --scope daemon --username <user>` 渲染到 `/Library/LaunchDaemons/`
292-3. 如有需要,再手工 `sudo chown root:wheel /Library/LaunchDaemons/*.plist`
293-4. 用 `reload-launchd.sh --scope daemon` 或 `launchctl print system/...`
294-
295-示例:
296-
297-```bash
298-REPO_DIR=/Users/george/code/baa-conductor
299-export BAA_SHARED_TOKEN='replace-with-real-token'
300-
301-sudo ./scripts/runtime/install-launchd.sh \
302- --repo-dir "$REPO_DIR" \
303- --scope daemon \
304- --node mini \
305- --username george
306-
307-sudo ./scripts/runtime/check-launchd.sh \
308- --repo-dir "$REPO_DIR" \
309- --scope daemon \
310- --node mini \
311- --install-dir /Library/LaunchDaemons \
312- --username george
313-
314-sudo ./scripts/runtime/reload-launchd.sh --scope daemon --dry-run
315-```
316-
317-使用 `LaunchDaemons` 时尤其要注意:
318-
319-- plist 里的 `HOME`、repo 路径和日志路径不能依赖当前登录用户的 shell
320-- runtime 根目录必须对 `UserName` 指定的账号可写
321-- 不要把 repo 源模板直接 `sudo cp` 过去后立刻加载,先通过安装脚本生成副本
322-
323-## 校验与排障
324-
325-静态校验:
326-
327-```bash
328-plutil -lint ops/launchd/so.makefile.baa-conductor.plist
329-plutil -lint ops/launchd/so.makefile.baa-worker-runner.plist
330-plutil -lint ops/launchd/so.makefile.baa-status-api.plist
331-```
332-
333-脚本化校验:
334-
335-```bash
336-./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mini
337-./scripts/runtime/check-launchd.sh \
338- --repo-dir /Users/george/code/baa-conductor \
339- --node mini \
340- --install-dir "$HOME/Library/LaunchAgents"
341-
342-./scripts/runtime/check-node.sh \
343- --repo-dir /Users/george/code/baa-conductor \
344- --node mini \
345- --all-services \
346- --install-dir "$HOME/Library/LaunchAgents" \
347- --expected-rolez leader
348-```
349-
350-运行时排障常用命令:
351-
352-```bash
353-launchctl print "gui/$(id -u)/so.makefile.baa-conductor"
354-launchctl print "gui/$(id -u)/so.makefile.baa-worker-runner"
355-launchctl print "gui/$(id -u)/so.makefile.baa-status-api"
356-tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-conductor.err.log
357-tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-worker-runner.err.log
358-tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-status-api.err.log
359-```
360-
361-当前仓库已经能为 app 生成基础 `dist/index.js` 产物,因此 launchd 不再依赖“未来某天才会出现的入口文件”。在执行 `check-launchd.sh` 的 dist 校验或真正 `launchctl bootstrap` 之前,先在 repo 根目录跑一次:
362-
363-```bash
364-npx --yes pnpm -r build
365-```
366-
367-这样可以确保 plist 指向的 `apps/*/dist/index.js` 已刷新到最新代码。
368-
369-需要注意的是,当前脚本解决的是 runtime 目录、plist 渲染、静态检查与 `launchctl` 重载流程;它不替代各服务自己的运行时接线。这里解决的是部署路径与构建产物的一致性,而不是把所有业务进程都变成完整生产守护进程。
+14,
-70
1@@ -1,83 +1,27 @@
2 # runtime layout
3
4-## 根路径
5-
6-推荐 `mini` 与 `mac` 都把仓库放在:
7+推荐把 `mini` 上的仓库放在:
8
9 ```text
10 /Users/george/code/baa-conductor
11 ```
12
13-这样 `launchd` 模板里的绝对路径可以保持一致,只有节点身份变量需要区分。
14-
15-## 目录树
16-
17-```text
18-/Users/george/code/baa-conductor/
19- state/
20- runs/
21- worktrees/
22- logs/
23- launchd/
24- tmp/
25-```
26-
27-`state/` 是本地状态镜像目录,`runs/`、`worktrees/`、`logs/`、`tmp/` 是本任务需要先落地的 runtime 根目录。
28-
29-## 一次性初始化
30-
31-优先直接运行仓库内脚本:
32-
33-```bash
34-REPO_DIR=/Users/george/code/baa-conductor
35-./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
36-```
37-
38-如果服务通过 `LaunchDaemons` 以专门账号运行,可以在初始化时直接带 owner:
39-
40-```bash
41-REPO_DIR=/Users/george/code/baa-conductor
42-sudo ./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR" --owner george:staff
43-```
44-
45-脚本本质上仍然只是执行 `install -d -m 700`,不会加载 `launchd` 服务,也不会修改 repo 之外的配置。
46-
47-## 目录职责
48-
49-| 路径 | 初始化方式 | 后续创建者 | 说明 |
50-| --- | --- | --- | --- |
51-| `state/` | `bootstrap.sh` 预创建根目录 | conductor | 本地状态镜像,便于恢复与对账 |
52-| `runs/` | `bootstrap.sh` 预创建根目录 | conductor/worker | 每个 run 在 `runs/<task-id>/<run-id>/` 下落本地元数据与日志 |
53-| `worktrees/` | `bootstrap.sh` 预创建根目录 | conductor | 每个 task 一个确定性的 worktree,worker 不负责清理 |
54-| `logs/` | `bootstrap.sh` 预创建根目录 | `launchd` 与服务进程 | `logs/launchd/` 放服务 stdout/stderr,其它运行期日志仍按 run 归档 |
55-| `tmp/` | `bootstrap.sh` 预创建根目录 | conductor/worker | 临时文件和中间产物,整机停服后才允许清空 |
56-
57-## `runs/` 的细化约定
58-
59-每个 run 的目录建议保持:
60+运行时目录默认放在 repo 根目录内:
61
62 ```text
63-runs/<task-id>/<run-id>/
64- meta.json
65- state.json
66- stdout.log
67- stderr.log
68- worker.log
69- checkpoints/
70- artifacts/
71+state/
72+runs/
73+worktrees/
74+logs/
75+tmp/
76 ```
77
78-根目录只预创建到 `runs/`。`<task-id>`、`<run-id>`、`checkpoints/` 与 `artifacts/` 都应由 conductor/worker 在实际执行时创建。
79-
80-## `worktrees/` 的细化约定
81-
82-- `worktrees/<task-id>/` 只在有活跃 task 时创建。
83-- 同一 task 只保留一个 worktree 路径,避免恢复时找错目录。
84-- 陈旧 worktree 只能由 conductor 或后续专门的清理流程回收,worker 不应自行删除。
85+说明:
86
87-## `logs/` 与 `tmp/` 的边界
88+- `state/`: 本地状态和小型快照
89+- `runs/`: 单次 run 目录
90+- `worktrees/`: 独立 worktree
91+- `logs/launchd/`: launchd stdout/stderr
92+- `tmp/`: 脚本临时文件
93
94-- `logs/launchd/*.out.log` 与 `logs/launchd/*.err.log` 只记录长期服务级日志。
95-- step 级 stdout/stderr 仍写回各自的 `runs/<task-id>/<run-id>/`。
96-- `tmp/` 只放临时 scratch 数据,不应作为 checkpoint 或 durable 状态目录。
97-- 任何清理 `tmp/` 或 `logs/` 的脚本都应在停服后执行,避免误删活跃 run 依赖的文件。
98+`bootstrap.sh` 负责创建这些目录,但不负责清理历史数据。
+19,
-153
1@@ -1,89 +1,33 @@
2 # node verification
3
4-本页描述的是“节点上已经有真实进程时”的验证顺序。
5+当前只检查 `mini`。
6
7-它不负责加载服务,不会执行 `launchctl bootstrap`,也不会修改 Nginx / DNS。
8-
9-## 检查面
10-
11-`scripts/runtime/check-node.sh` 把节点验证拆成两层:
12-
13-| 层级 | 默认覆盖项 | 目的 |
14-| --- | --- | --- |
15-| 静态层 | runtime 目录、`dist/index.js`、安装副本 plist、共享 token、日志路径配置 | 确认 launchd 渲染结果和 repo/runtime 根目录一致 |
16-| 运行态层 | 本地端口、conductor `/healthz` `/readyz` `/rolez`、status-api `/healthz` `/v1/status` `/v1/status/ui`、status-api 宿主进程、launchd 日志文件 | 确认节点上实际跑起来的进程与预期服务面一致 |
17-
18-默认检查集合是 `conductor + status-api`。如果节点暂时只跑 conductor,可以显式改成 `--service conductor`。如果还要把 `worker-runner` 也纳入同一次节点验证,再加 `--all-services`。
19-
20-默认 probe URL 仍假设 loopback:
21-
22-- conductor: `http://127.0.0.1:4317`
23-- status-api: `http://127.0.0.1:4318`
24-
25-如果节点已经按 rollout 切到 Tailscale `100.x`,记得在下面命令里同步补上:
26-
27-- `--local-api-base http://100.x:4317`
28-- `--status-api-base http://100.x:4318`
29-
30-下面的 `mini` / `mac` 示例按当前真实 rollout 直接写成 Tailscale `100.x` 版本;如果节点仍保持 loopback-only,把这些覆盖参数去掉即可。
31-
32-## 前置条件
33-
34-在进入 on-node 检查前,先确保这些步骤已经完成:
35-
36-1. `./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor`
37-2. `cd /Users/george/code/baa-conductor && npx --yes pnpm -r build`
38-3. `./scripts/runtime/install-launchd.sh ...` 已经渲染出目标节点的安装副本
39-4. 节点上已经有真实进程
40-
41-第 4 步可以来自已有的 launchd 加载,也可以来自人工先行启动的进程;`check-node.sh` 只做验证,不负责启动。
42-
43-## mini 验证顺序
44-
45-### 1. 静态校验
46+## 1. 构建与静态检查
47
48 ```bash
49-REPO_DIR=/Users/george/code/baa-conductor
50-AGENTS_DIR="$HOME/Library/LaunchAgents"
51-
52+npx --yes pnpm -r build
53 ./scripts/runtime/check-launchd.sh \
54- --repo-dir "$REPO_DIR" \
55+ --repo-dir /Users/george/code/baa-conductor \
56 --node mini \
57- --all-services \
58- --install-dir "$AGENTS_DIR" \
59+ --service conductor \
60+ --service status-api \
61+ --install-dir /Users/george/Library/LaunchAgents \
62+ --shared-token-file /Users/george/.config/baa-conductor/shared-token.txt \
63+ --control-api-base https://control-api.makefile.so \
64 --local-api-base http://100.71.210.78:4317 \
65 --local-api-allowed-hosts 100.71.210.78 \
66 --status-api-host 100.71.210.78
67 ```
68
69-### 2. on-node 校验
70-
71-steady-state 下,`mini` 应该对外表现为 leader,因此 `rolez` 预期值写成 `leader`:
72-
73-```bash
74-REPO_DIR=/Users/george/code/baa-conductor
75-AGENTS_DIR="$HOME/Library/LaunchAgents"
76-
77-./scripts/runtime/check-node.sh \
78- --repo-dir "$REPO_DIR" \
79- --node mini \
80- --all-services \
81- --install-dir "$AGENTS_DIR" \
82- --local-api-base http://100.71.210.78:4317 \
83- --local-api-allowed-hosts 100.71.210.78 \
84- --status-api-base http://100.71.210.78:4318 \
85- --status-api-host 100.71.210.78 \
86- --expected-rolez leader
87-```
88-
89-如果该节点已经是通过 launchd 常驻起来的,再加 `--check-loaded`,把 `launchctl print gui/<uid>/<label>` 也纳入检查,但这仍然只是读取状态,不会触发加载:
90+## 2. 运行态检查
91
92 ```bash
93 ./scripts/runtime/check-node.sh \
94- --repo-dir "$REPO_DIR" \
95+ --repo-dir /Users/george/code/baa-conductor \
96 --node mini \
97- --all-services \
98- --install-dir "$AGENTS_DIR" \
99+ --service conductor \
100+ --service status-api \
101+ --install-dir /Users/george/Library/LaunchAgents \
102 --local-api-base http://100.71.210.78:4317 \
103 --local-api-allowed-hosts 100.71.210.78 \
104 --status-api-base http://100.71.210.78:4318 \
105@@ -92,87 +36,9 @@ AGENTS_DIR="$HOME/Library/LaunchAgents"
106 --check-loaded
107 ```
108
109-### 3. 失败时的人工 spot check
110-
111-```bash
112-LOCAL_API_BASE="${LOCAL_API_BASE:-http://100.71.210.78:4317}"
113-STATUS_API_BASE="${STATUS_API_BASE:-http://100.71.210.78:4318}"
114-
115-lsof -nP -iTCP:4317 -sTCP:LISTEN
116-lsof -nP -iTCP:4318 -sTCP:LISTEN
117-curl -sS "${LOCAL_API_BASE}/healthz"
118-curl -sS "${LOCAL_API_BASE}/readyz"
119-curl -sS "${LOCAL_API_BASE}/rolez"
120-curl -sS "${STATUS_API_BASE}/healthz"
121-tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-conductor.err.log
122-tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-status-api.err.log
123-```
124-
125-## mac 验证顺序
126-
127-### 1. 静态校验
128-
129-```bash
130-REPO_DIR=/Users/george/code/baa-conductor
131-AGENTS_DIR="$HOME/Library/LaunchAgents"
132-
133-./scripts/runtime/check-launchd.sh \
134- --repo-dir "$REPO_DIR" \
135- --node mac \
136- --all-services \
137- --install-dir "$AGENTS_DIR" \
138- --local-api-base http://100.112.239.13:4317 \
139- --local-api-allowed-hosts 100.112.239.13 \
140- --status-api-host 100.112.239.13
141-```
142-
143-### 2. on-node 校验
144-
145-steady-state 下,`mac` 应该保持 standby,因此默认把 `rolez` 预期值写成 `standby`:
146-
147-```bash
148-REPO_DIR=/Users/george/code/baa-conductor
149-AGENTS_DIR="$HOME/Library/LaunchAgents"
150-
151-./scripts/runtime/check-node.sh \
152- --repo-dir "$REPO_DIR" \
153- --node mac \
154- --all-services \
155- --install-dir "$AGENTS_DIR" \
156- --local-api-base http://100.112.239.13:4317 \
157- --local-api-allowed-hosts 100.112.239.13 \
158- --status-api-base http://100.112.239.13:4318 \
159- --status-api-host 100.112.239.13 \
160- --expected-rolez standby
161-```
162-
163-如果这是 failover rehearsal 期间的 `mac`,只把 `--expected-rolez standby` 改成 `--expected-rolez leader`,其余步骤保持不变。
164-
165-### 3. `LaunchDaemons` 场景
166-
167-如果 `mac` 用的是 `/Library/LaunchDaemons`,把安装路径和域名一起改成 daemon 版本:
168-
169-```bash
170-sudo ./scripts/runtime/check-node.sh \
171- --repo-dir /Users/george/code/baa-conductor \
172- --node mac \
173- --scope daemon \
174- --install-dir /Library/LaunchDaemons \
175- --username george \
176- --local-api-base http://100.112.239.13:4317 \
177- --local-api-allowed-hosts 100.112.239.13 \
178- --status-api-base http://100.112.239.13:4318 \
179- --status-api-host 100.112.239.13 \
180- --expected-rolez standby \
181- --check-loaded
182-```
183-
184-`--check-loaded` 在这里会去读 `launchctl print system/<label>`,不会重新 bootstrap 服务。
185-
186-## 常见失败信号
187+## 常见失败点
188
189-- `conductor /readyz` 返回 `503`:节点进程存活,但 runtime 还没进入 ready 状态,先看 conductor stderr 日志。
190-- `conductor /rolez` 与预期不符:节点身份没问题,但当前 lease 角色与预期不一致,先确认是否处于 failover 或 standby 场景。
191-- `status-api` 端口没监听:`status-api` 宿主进程没有起来,或监听地址/端口与当前配置的 `127.0.0.1:4318` / `100.x:4318` 不一致。
192-- `status-api` 进程匹配失败:节点上可能跑的是旧路径、旧 worktree,或 launchd 仍指向错误的 `dist/index.js`。
193-- launchd 日志文件缺失:安装副本路径虽然存在,但服务尚未真正由 launchd 打开过对应 `StandardOutPath` / `StandardErrorPath`。
194+- `conductor /rolez` 不是 `leader`
195+- `status-api /v1/status` 与 control-api 不一致
196+- `launchctl print` 失败
197+- `logs/launchd/*.log` 没有新内容
+0,
-75
1@@ -1,75 +0,0 @@
2-# 2026-03-22 Runtime Rollout Record
3-
4-## Nodes
5-
6-| Node | Host | Repo path used for rollout | Result |
7-| --- | --- | --- | --- |
8-| `mini` | local machine `Mac`, Tailscale `100.71.210.78` | `/Users/george/code/baa-conductor-T028-v2` | launchd reloaded, on-node checks passed, `rolez=leader` |
9-| `mac` | remote `MacBookPro`, Tailscale `100.112.239.13` | `/Users/george/code/baa-conductor-T028-v2` | repo synced, launchd reloaded, on-node checks passed, `rolez=standby` |
10-
11-## Shared runtime inputs
12-
13-- repo path used for this rollout: `/Users/george/code/baa-conductor-T028-v2`
14-- launchd install dir: `/Users/george/Library/LaunchAgents`
15-- shared token source: `/Users/george/.config/baa-conductor/control-api-worker.secrets.env`
16-- control API base: `https://control-api.makefile.so`
17-
18-## mini
19-
20-Executed locally on 2026-03-22 CST:
21-
22-1. `npx --yes pnpm -r build`
23-2. `./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor-T028-v2`
24-3. `./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-host 100.71.210.78`
25-4. `./scripts/runtime/check-launchd.sh ...`
26-5. `./scripts/runtime/reload-launchd.sh --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents`
27-6. `./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mini --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.71.210.78:4317 --local-api-allowed-hosts 100.71.210.78 --status-api-base http://100.71.210.78:4318 --status-api-host 100.71.210.78 --expected-rolez leader --check-loaded`
28-
29-Observed state after reload:
30-
31-- conductor PID: `79697`
32-- status-api PID: `79700`
33-- `http://100.71.210.78:4317/healthz -> ok`
34-- `http://100.71.210.78:4317/rolez -> leader`
35-- `http://100.71.210.78:4318/healthz -> ok`
36-
37-## mac
38-
39-Executed remotely over `ssh george@100.112.239.13` on 2026-03-22 CST:
40-
41-1. `rsync -az --delete --exclude '.git' --exclude 'node_modules' /Users/george/code/baa-conductor-T028-v2/ george@100.112.239.13:/Users/george/code/baa-conductor-T028-v2/`
42-2. `cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm install`
43-3. `cd /Users/george/code/baa-conductor-T028-v2 && npx --yes pnpm -r build`
44-4. `./scripts/runtime/bootstrap.sh --repo-dir /Users/george/code/baa-conductor-T028-v2`
45-5. `./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-host 100.112.239.13`
46-6. `./scripts/runtime/check-launchd.sh ...`
47-7. `./scripts/runtime/reload-launchd.sh --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents`
48-8. `./scripts/runtime/check-node.sh --repo-dir /Users/george/code/baa-conductor-T028-v2 --node mac --service conductor --service status-api --install-dir /Users/george/Library/LaunchAgents --local-api-base http://100.112.239.13:4317 --local-api-allowed-hosts 100.112.239.13 --status-api-base http://100.112.239.13:4318 --status-api-host 100.112.239.13 --expected-rolez standby --check-loaded`
49-
50-Observed state after reload:
51-
52-- conductor PID: `60338`
53-- status-api PID: `60341`
54-- `http://100.112.239.13:4317/healthz -> ok`
55-- `http://100.112.239.13:4317/rolez -> standby`
56-- `http://100.112.239.13:4318/healthz -> ok`
57-
58-## Cross-node reachability
59-
60-Validated after both nodes switched to Tailscale listeners:
61-
62-- local host:
63- - `curl http://100.71.210.78:4317/healthz -> ok`
64- - `curl http://100.71.210.78:4318/healthz -> ok`
65- - `curl http://100.112.239.13:4317/healthz -> ok`
66- - `curl http://100.112.239.13:4318/healthz -> ok`
67-- VPS `root@192.210.137.113 -p 2222`:
68- - `curl http://100.71.210.78:4317/healthz -> ok`
69- - `curl http://100.112.239.13:4317/healthz -> ok`
70- - `curl http://100.71.210.78:4318/healthz -> ok`
71- - `curl http://100.112.239.13:4318/healthz -> ok`
72-
73-## Notes
74-
75-- This rollout replaced the old `/Users/george/code/baa-conductor-T028` runtime path with `/Users/george/code/baa-conductor-T028-v2` on both nodes.
76-- `check-node.sh` now forwards `--local-api-allowed-hosts` and `--status-api-host` to `check-launchd.sh`; without that fix, Tailscale rollout checks would fail even though the installed plist values were correct.
1@@ -4,8 +4,8 @@
2 Source template kept in the repo.
3 Default values target the mini node at /Users/george/code/baa-conductor.
4 Use scripts/runtime/install-launchd.sh to render the actual install copy.
5- For mac, change BAA_CONDUCTOR_HOST, BAA_CONDUCTOR_ROLE, BAA_NODE_ID,
6- BAA_SHARED_TOKEN, and keep the CLI args aligned with those values.
7+ Adjust BAA_SHARED_TOKEN and the listen-related variables before loading if
8+ the mini node uses a non-default address.
9 If this file is installed under /Library/LaunchDaemons, add UserName and keep
10 every path absolute; launchd will not read shell rc files for you.
11 -->
1@@ -4,8 +4,8 @@
2 Source template kept in the repo.
3 Defaults target the mini node and share the same runtime tree as conductor.
4 Use scripts/runtime/install-launchd.sh to render the actual install copy.
5- For mac, change BAA_CONDUCTOR_HOST, BAA_CONDUCTOR_ROLE, BAA_NODE_ID, and
6- BAA_SHARED_TOKEN before copying the file into the launchd install path.
7+ Adjust BAA_SHARED_TOKEN and runtime paths before copying the file into the
8+ launchd install path.
9 -->
10 <plist version="1.0">
11 <dict>
+5,
-64
1@@ -1,15 +1,11 @@
2 # 部署目标:
3 # - /etc/nginx/sites-available/baa-conductor.conf
4 # - /etc/nginx/sites-enabled/baa-conductor.conf -> symlink to sites-available
5-# - /etc/nginx/includes/baa-conductor/*.conf 由仓库里的 ops/nginx/includes/* 同步过去
6 #
7-# 说明:
8-# - conductor.makefile.so 作为统一入口,走 mini 主、mac 备的 upstream
9-# - mini-conductor.makefile.so 与 mac-conductor.makefile.so 直连单节点 upstream
10-# - 所有 upstream 都直接写 Tailscale 100.x 地址
11-# - 不使用 mini.tail0125d.ts.net / mbp.tail0125d.ts.net 等 MagicDNS 名称
12-# - 这样可以避开 ClashX 与 MagicDNS 的 DNS 接管冲突
13-# - 证书路径使用 Let's Encrypt 默认目录,若走 Cloudflare Origin Cert 请替换为实际文件路径
14+# 当前主线只保留一个公网入口:
15+# - conductor.makefile.so -> VPS -> mini 100.71.210.78:4317
16+# - 不依赖 MagicDNS
17+# - 证书路径使用 Let's Encrypt 默认目录,若走其他证书方案请替换
18
19 map $http_upgrade $connection_upgrade {
20 default upgrade;
21@@ -17,27 +13,14 @@ map $http_upgrade $connection_upgrade {
22 }
23
24 upstream conductor_primary {
25- # mini 主节点,使用 Tailscale IPv4 私网地址回源
26 server 100.71.210.78:4317 max_fails=2 fail_timeout=5s;
27- # mac 备用节点,使用 Tailscale IPv4 私网地址回源
28- server 100.112.239.13:4317 backup;
29 keepalive 32;
30 }
31
32-upstream mini_conductor_direct {
33- server 100.71.210.78:4317;
34- keepalive 16;
35-}
36-
37-upstream mac_conductor_direct {
38- server 100.112.239.13:4317;
39- keepalive 16;
40-}
41-
42 server {
43 listen 80;
44 listen [::]:80;
45- server_name conductor.makefile.so mini-conductor.makefile.so mac-conductor.makefile.so;
46+ server_name conductor.makefile.so;
47
48 return 301 https://$host$request_uri;
49 }
50@@ -76,45 +59,3 @@ server {
51 include /etc/nginx/includes/baa-conductor/common-proxy.conf;
52 }
53 }
54-
55-server {
56- listen 443 ssl http2;
57- listen [::]:443 ssl http2;
58- server_name mini-conductor.makefile.so;
59-
60- ssl_certificate /etc/letsencrypt/live/mini-conductor.makefile.so/fullchain.pem;
61- ssl_certificate_key /etc/letsencrypt/live/mini-conductor.makefile.so/privkey.pem;
62- ssl_protocols TLSv1.2 TLSv1.3;
63- ssl_session_cache shared:BAAConductorTLS:10m;
64- ssl_session_timeout 1d;
65-
66- access_log /var/log/nginx/baa-conductor-mini.access.log;
67- error_log /var/log/nginx/baa-conductor-mini.error.log warn;
68-
69- location / {
70- include /etc/nginx/includes/baa-conductor/direct-node-auth.conf;
71- proxy_pass http://mini_conductor_direct;
72- include /etc/nginx/includes/baa-conductor/common-proxy.conf;
73- }
74-}
75-
76-server {
77- listen 443 ssl http2;
78- listen [::]:443 ssl http2;
79- server_name mac-conductor.makefile.so;
80-
81- ssl_certificate /etc/letsencrypt/live/mac-conductor.makefile.so/fullchain.pem;
82- ssl_certificate_key /etc/letsencrypt/live/mac-conductor.makefile.so/privkey.pem;
83- ssl_protocols TLSv1.2 TLSv1.3;
84- ssl_session_cache shared:BAAConductorTLS:10m;
85- ssl_session_timeout 1d;
86-
87- access_log /var/log/nginx/baa-conductor-mac.access.log;
88- error_log /var/log/nginx/baa-conductor-mac.error.log warn;
89-
90- location / {
91- include /etc/nginx/includes/baa-conductor/direct-node-auth.conf;
92- proxy_pass http://mac_conductor_direct;
93- include /etc/nginx/includes/baa-conductor/common-proxy.conf;
94- }
95-}
+0,
-10
1@@ -1,10 +0,0 @@
2-# 直连 mini/mac 的调试入口默认启用 Basic Auth。
3-# 如果有固定办公网或 VPN 出口,可再叠加 allowlist:
4-# satisfy any;
5-# allow 203.0.113.10;
6-# allow 2001:db8::/48;
7-# deny all;
8-# 注意:若域名经过 Cloudflare 代理,要先恢复真实客户端 IP 再使用 allow/deny。
9-
10-auth_basic "baa-conductor direct node";
11-auth_basic_user_file /etc/nginx/.htpasswd-baa-conductor;
1@@ -1,14 +1,6 @@
2-# 部署目标:
3-# - __NGINX_SITE_INSTALL_PATH__
4-# - __NGINX_SITE_ENABLED_PATH__ -> symlink to sites-available
5-# - __NGINX_INCLUDE_GLOB__ 由仓库里的 ops/nginx/includes/* 同步过去
6-#
7-# 说明:
8-# - __CONDUCTOR_HOST__ 作为统一入口,走 mini 主、mac 备的 upstream
9-# - __MINI_DIRECT_HOST__ 与 __MAC_DIRECT_HOST__ 直连单节点 upstream
10+# - __CONDUCTOR_HOST__ 作为唯一公网入口
11 # - 所有 upstream 都直接写 Tailscale 100.x 地址
12-# - 不使用 mini.tail0125d.ts.net / mbp.tail0125d.ts.net 等 MagicDNS 名称
13-# - 这样可以避开 ClashX 与 MagicDNS 的 DNS 接管冲突
14+# - 不使用 MagicDNS 名称
15 # - 证书路径使用 Let's Encrypt 默认目录,若走 Cloudflare Origin Cert 请替换为实际文件路径
16
17 map $http_upgrade $connection_upgrade {
18@@ -17,27 +9,14 @@ map $http_upgrade $connection_upgrade {
19 }
20
21 upstream conductor_primary {
22- # mini 主节点,使用 Tailscale IPv4 私网地址回源
23 server __MINI_TAILSCALE_IP__:__CONDUCTOR_PORT__ max_fails=2 fail_timeout=5s;
24- # mac 备用节点,使用 Tailscale IPv4 私网地址回源
25- server __MAC_TAILSCALE_IP__:__CONDUCTOR_PORT__ backup;
26 keepalive 32;
27 }
28
29-upstream mini_conductor_direct {
30- server __MINI_TAILSCALE_IP__:__CONDUCTOR_PORT__;
31- keepalive 16;
32-}
33-
34-upstream mac_conductor_direct {
35- server __MAC_TAILSCALE_IP__:__CONDUCTOR_PORT__;
36- keepalive 16;
37-}
38-
39 server {
40 listen 80;
41 listen [::]:80;
42- server_name __CONDUCTOR_HOST__ __MINI_DIRECT_HOST__ __MAC_DIRECT_HOST__;
43+ server_name __CONDUCTOR_HOST__;
44
45 return 301 https://$host$request_uri;
46 }
47@@ -76,45 +55,3 @@ server {
48 include __NGINX_INCLUDE_DIR__/common-proxy.conf;
49 }
50 }
51-
52-server {
53- listen 443 ssl http2;
54- listen [::]:443 ssl http2;
55- server_name __MINI_DIRECT_HOST__;
56-
57- ssl_certificate __MINI_CERT_FULLCHAIN__;
58- ssl_certificate_key __MINI_CERT_KEY__;
59- ssl_protocols TLSv1.2 TLSv1.3;
60- ssl_session_cache shared:BAAConductorTLS:10m;
61- ssl_session_timeout 1d;
62-
63- access_log /var/log/nginx/baa-conductor-mini.access.log;
64- error_log /var/log/nginx/baa-conductor-mini.error.log warn;
65-
66- location / {
67- include __NGINX_INCLUDE_DIR__/direct-node-auth.conf;
68- proxy_pass http://mini_conductor_direct;
69- include __NGINX_INCLUDE_DIR__/common-proxy.conf;
70- }
71-}
72-
73-server {
74- listen 443 ssl http2;
75- listen [::]:443 ssl http2;
76- server_name __MAC_DIRECT_HOST__;
77-
78- ssl_certificate __MAC_CERT_FULLCHAIN__;
79- ssl_certificate_key __MAC_CERT_KEY__;
80- ssl_protocols TLSv1.2 TLSv1.3;
81- ssl_session_cache shared:BAAConductorTLS:10m;
82- ssl_session_timeout 1d;
83-
84- access_log /var/log/nginx/baa-conductor-mac.access.log;
85- error_log /var/log/nginx/baa-conductor-mac.error.log warn;
86-
87- location / {
88- include __NGINX_INCLUDE_DIR__/direct-node-auth.conf;
89- proxy_pass http://mac_conductor_direct;
90- include __NGINX_INCLUDE_DIR__/common-proxy.conf;
91- }
92-}
1@@ -1,10 +0,0 @@
2-# 直连 mini/mac 的调试入口默认启用 Basic Auth。
3-# 如果有固定办公网或 VPN 出口,可再叠加 allowlist:
4-# satisfy any;
5-# allow 203.0.113.10;
6-# allow 2001:db8::/48;
7-# deny all;
8-# 注意:若域名经过 Cloudflare 代理,要先恢复真实客户端 IP 再使用 allow/deny。
9-
10-auth_basic "baa-conductor direct node";
11-auth_basic_user_file __NGINX_HTPASSWD_PATH__;
+0,
-176
1@@ -1,176 +0,0 @@
2-#!/usr/bin/env bash
3-
4-if [[ -n "${BAA_FAILOVER_COMMON_SH_LOADED:-}" ]]; then
5- return 0
6-fi
7-
8-readonly BAA_FAILOVER_COMMON_SH_LOADED=1
9-readonly BAA_FAILOVER_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
10-readonly BAA_FAILOVER_REPO_DIR_DEFAULT="$(cd -- "${BAA_FAILOVER_SCRIPT_DIR}/../.." && pwd)"
11-readonly BAA_FAILOVER_DEFAULT_ENV_PATH="${BAA_FAILOVER_REPO_DIR_DEFAULT}/scripts/ops/baa-conductor.env.example"
12-readonly BAA_FAILOVER_DEFAULT_CONTROL_API_BASE="https://control-api.makefile.so"
13-
14-failover_log() {
15- printf '[failover] %s\n' "$*"
16-}
17-
18-failover_warn() {
19- printf '[failover] warning: %s\n' "$*" >&2
20-}
21-
22-failover_error() {
23- printf '[failover] error: %s\n' "$*" >&2
24-}
25-
26-die() {
27- failover_error "$*"
28- exit 1
29-}
30-
31-require_command() {
32- if ! command -v "$1" >/dev/null 2>&1; then
33- die "Missing required command: $1"
34- fi
35-}
36-
37-contains_value() {
38- local needle="$1"
39- shift
40-
41- local value
42- for value in "$@"; do
43- if [[ "$value" == "$needle" ]]; then
44- return 0
45- fi
46- done
47-
48- return 1
49-}
50-
51-validate_node() {
52- case "$1" in
53- mini | mac) ;;
54- *)
55- die "Unsupported node: $1"
56- ;;
57- esac
58-}
59-
60-validate_scenario() {
61- case "$1" in
62- planned | emergency | switchback) ;;
63- *)
64- die "Unsupported scenario: $1"
65- ;;
66- esac
67-}
68-
69-shell_quote() {
70- printf '%q' "$1"
71-}
72-
73-require_value() {
74- local key="$1"
75- local value="${!key:-}"
76-
77- if [[ -z "$value" ]]; then
78- die "Missing required value in inventory: ${key}"
79- fi
80-
81- printf '%s\n' "$value"
82-}
83-
84-validate_tailscale_ipv4() {
85- local value="$1"
86- local key="$2"
87-
88- if [[ ! "$value" =~ ^100\.([0-9]{1,3}\.){2}[0-9]{1,3}$ ]]; then
89- die "${key} must be a Tailscale 100.x IPv4 address: ${value}"
90- fi
91-}
92-
93-load_env_file() {
94- local env_path="$1"
95-
96- if [[ ! -f "$env_path" ]]; then
97- die "Inventory file not found: ${env_path}"
98- fi
99-
100- set -a
101- # shellcheck disable=SC1090
102- source "$env_path"
103- set +a
104-}
105-
106-load_inventory() {
107- local env_path="$1"
108-
109- load_env_file "$env_path"
110-
111- FAILOVER_ENV_PATH="$env_path"
112- FAILOVER_APP_NAME="${BAA_APP_NAME:-baa-conductor}"
113- FAILOVER_PUBLIC_IPV4="${BAA_PUBLIC_IPV4:-}"
114- FAILOVER_PUBLIC_IPV6="${BAA_PUBLIC_IPV6:-}"
115- FAILOVER_CONDUCTOR_HOST="$(require_value BAA_CONDUCTOR_HOST)"
116- FAILOVER_MINI_DIRECT_HOST="$(require_value BAA_MINI_DIRECT_HOST)"
117- FAILOVER_MAC_DIRECT_HOST="$(require_value BAA_MAC_DIRECT_HOST)"
118- FAILOVER_MINI_TAILSCALE_IP="$(require_value BAA_MINI_TAILSCALE_IP)"
119- FAILOVER_MAC_TAILSCALE_IP="$(require_value BAA_MAC_TAILSCALE_IP)"
120- FAILOVER_CONDUCTOR_PORT="${BAA_CONDUCTOR_PORT:-4317}"
121- FAILOVER_CONTROL_API_BASE="${BAA_CONTROL_API_BASE:-$BAA_FAILOVER_DEFAULT_CONTROL_API_BASE}"
122-
123- validate_tailscale_ipv4 "$FAILOVER_MINI_TAILSCALE_IP" "BAA_MINI_TAILSCALE_IP"
124- validate_tailscale_ipv4 "$FAILOVER_MAC_TAILSCALE_IP" "BAA_MAC_TAILSCALE_IP"
125-}
126-
127-node_direct_host() {
128- validate_node "$1"
129-
130- case "$1" in
131- mini)
132- printf '%s\n' "$FAILOVER_MINI_DIRECT_HOST"
133- ;;
134- mac)
135- printf '%s\n' "$FAILOVER_MAC_DIRECT_HOST"
136- ;;
137- esac
138-}
139-
140-node_tailscale_ip() {
141- validate_node "$1"
142-
143- case "$1" in
144- mini)
145- printf '%s\n' "$FAILOVER_MINI_TAILSCALE_IP"
146- ;;
147- mac)
148- printf '%s\n' "$FAILOVER_MAC_TAILSCALE_IP"
149- ;;
150- esac
151-}
152-
153-node_default_role() {
154- validate_node "$1"
155-
156- case "$1" in
157- mini)
158- printf '%s\n' "primary"
159- ;;
160- mac)
161- printf '%s\n' "standby"
162- ;;
163- esac
164-}
165-
166-node_default_id() {
167- validate_node "$1"
168-
169- case "$1" in
170- mini)
171- printf '%s\n' "mini-main"
172- ;;
173- mac)
174- printf '%s\n' "mac-standby"
175- ;;
176- esac
177-}
+0,
-240
1@@ -1,240 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
6-# shellcheck source=./common.sh
7-source "${SCRIPT_DIR}/common.sh"
8-
9-usage() {
10- cat <<'EOF'
11-Usage:
12- scripts/failover/print-checklist.sh --scenario planned|emergency|switchback [options]
13-
14-Options:
15- --scenario NAME planned, emergency, or switchback.
16- --env PATH Inventory file to load.
17- --control-api-base URL Override the control API base URL.
18- --help Show this help text.
19-EOF
20-}
21-
22-print_common_exports() {
23- local env_q=""
24- local control_api_q=""
25-
26- env_q="$(shell_quote "$FAILOVER_ENV_PATH")"
27- control_api_q="$(shell_quote "$control_api_base")"
28-
29- cat <<EOF
30-Suggested operator variables
31-----------------------------
32-export FAILOVER_ENV=${env_q}
33-export DIRECT_BASIC_AUTH='conductor-ops:REPLACE_ME'
34-export READONLY_TOKEN='REPLACE_ME'
35-export BROWSER_ADMIN_TOKEN='REPLACE_ME'
36-export MINI_SSH='<mini-admin-shell>'
37-export MAC_SSH='<mac-admin-shell>'
38-export VPS_SSH='root@<vps>'
39-export CONTROL_API_BASE=${control_api_q}
40-
41-Common baseline commands
42-------------------------
43-./scripts/failover/print-topology.sh --env "\$FAILOVER_ENV"
44-./scripts/failover/rehearsal-check.sh \\
45- --env "\$FAILOVER_ENV" \\
46- --basic-auth "\$DIRECT_BASIC_AUTH" \\
47- --bearer-token "\$READONLY_TOKEN" \\
48- --control-api-base "\$CONTROL_API_BASE"
49-EOF
50-}
51-
52-print_planned_checklist() {
53- cat <<'EOF'
54-
55-Planned failover checklist
56---------------------------
57-1. Confirm the baseline is mini leader:
58-./scripts/failover/rehearsal-check.sh \
59- --env "$FAILOVER_ENV" \
60- --basic-auth "$DIRECT_BASIC_AUTH" \
61- --bearer-token "$READONLY_TOKEN" \
62- --control-api-base "$CONTROL_API_BASE" \
63- --expect-leader mini
64-
65-2. Drain and then pause automation:
66-curl -sS -X POST \
67- -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
68- -H 'Content-Type: application/json' \
69- -d '{"requested_by":"ops_runbook","reason":"planned_failover_rehearsal"}' \
70- "${CONTROL_API_BASE%/}/v1/system/drain"
71-
72-curl -sS -X POST \
73- -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
74- -H 'Content-Type: application/json' \
75- -d '{"requested_by":"ops_runbook","reason":"planned_failover_cutover"}' \
76- "${CONTROL_API_BASE%/}/v1/system/pause"
77-
78-3. On mac, confirm launchd has a healthy standby install:
79-ssh "$MAC_SSH" \
80- 'cd /Users/george/code/baa-conductor && ./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac --install-dir "$HOME/Library/LaunchAgents"'
81-
82-4. On mini, stop only the conductor service so VPS Nginx falls through to mac:
83-ssh "$MINI_SSH" \
84- 'launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"'
85-
86-5. Verify mac is now leader and public ingress still returns leader:
87-./scripts/failover/rehearsal-check.sh \
88- --env "$FAILOVER_ENV" \
89- --basic-auth "$DIRECT_BASIC_AUTH" \
90- --bearer-token "$READONLY_TOKEN" \
91- --control-api-base "$CONTROL_API_BASE" \
92- --skip-node mini \
93- --expect-leader mac
94-
95-6. Resume automation only after mac direct host and public host are both healthy:
96-curl -sS -X POST \
97- -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
98- -H 'Content-Type: application/json' \
99- -d '{"requested_by":"ops_runbook","reason":"planned_failover_complete"}' \
100- "${CONTROL_API_BASE%/}/v1/system/resume"
101-EOF
102-}
103-
104-print_emergency_checklist() {
105- cat <<'EOF'
106-
107-Emergency failover checklist
108-----------------------------
109-1. Snapshot public state first. If mini is already gone, skip its direct checks:
110-./scripts/failover/rehearsal-check.sh \
111- --env "$FAILOVER_ENV" \
112- --basic-auth "$DIRECT_BASIC_AUTH" \
113- --bearer-token "$READONLY_TOKEN" \
114- --control-api-base "$CONTROL_API_BASE" \
115- --skip-node mini \
116- --expect-leader mac
117-
118-2. On mac, repair or restart the local conductor service if needed:
119-ssh "$MAC_SSH" \
120- 'cd /Users/george/code/baa-conductor && ./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac --install-dir "$HOME/Library/LaunchAgents"'
121-
122-ssh "$MAC_SSH" \
123- 'cd /Users/george/code/baa-conductor && ./scripts/runtime/reload-launchd.sh'
124-
125-3. If public ingress still lands on mini while mini is reachable but no longer leader, hotfix the VPS config so mac becomes the first upstream:
126-ssh "$VPS_SSH" 'sudo cp /etc/nginx/sites-available/baa-conductor.conf /etc/nginx/sites-available/baa-conductor.conf.bak.$(date +%Y%m%d%H%M%S)'
127-ssh "$VPS_SSH" 'sudo editor /etc/nginx/sites-available/baa-conductor.conf'
128-ssh "$VPS_SSH" 'sudo nginx -t && sudo systemctl reload nginx'
129-
130-4. Re-run the snapshot until public /rolez=leader and mac direct /rolez=leader:
131-./scripts/failover/rehearsal-check.sh \
132- --env "$FAILOVER_ENV" \
133- --basic-auth "$DIRECT_BASIC_AUTH" \
134- --bearer-token "$READONLY_TOKEN" \
135- --control-api-base "$CONTROL_API_BASE" \
136- --skip-node mini \
137- --expect-leader mac
138-
139-5. Record whether the VPS carried an emergency Nginx hotfix. Switchback must restore the canonical repo-rendered bundle later.
140-EOF
141-}
142-
143-print_switchback_checklist() {
144- cat <<'EOF'
145-
146-Switchback checklist
147---------------------
148-1. Rebuild and validate mini before touching traffic:
149-ssh "$MINI_SSH" \
150- 'cd /Users/george/code/baa-conductor && npx --yes pnpm -r build && ./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mini --install-dir "$HOME/Library/LaunchAgents"'
151-
152-2. Pause automation so lease ownership can move cleanly back to mini:
153-curl -sS -X POST \
154- -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
155- -H 'Content-Type: application/json' \
156- -d '{"requested_by":"ops_runbook","reason":"switchback_prepare"}' \
157- "${CONTROL_API_BASE%/}/v1/system/pause"
158-
159-3. Stop mac conductor and restart mini conductor:
160-ssh "$MAC_SSH" \
161- 'launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"'
162-
163-ssh "$MINI_SSH" \
164- 'cd /Users/george/code/baa-conductor && ./scripts/runtime/reload-launchd.sh'
165-
166-4. If emergency hotfixes changed the deployed VPS config, restore the canonical mini-primary bundle from the repo:
167-scripts/ops/nginx-sync-plan.sh --env "$FAILOVER_ENV" --bundle-dir .tmp/ops/baa-conductor-nginx
168-rsync -av .tmp/ops/baa-conductor-nginx/ "$VPS_SSH":/tmp/baa-conductor-nginx/
169-ssh "$VPS_SSH" 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh --reload'
170-
171-5. Verify leadership moved back to mini:
172-./scripts/failover/rehearsal-check.sh \
173- --env "$FAILOVER_ENV" \
174- --basic-auth "$DIRECT_BASIC_AUTH" \
175- --bearer-token "$READONLY_TOKEN" \
176- --control-api-base "$CONTROL_API_BASE" \
177- --skip-node mac \
178- --expect-leader mini
179-
180-6. Resume automation after public and mini direct hosts are both healthy:
181-curl -sS -X POST \
182- -H "Authorization: Bearer ${BROWSER_ADMIN_TOKEN}" \
183- -H 'Content-Type: application/json' \
184- -d '{"requested_by":"ops_runbook","reason":"switchback_complete"}' \
185- "${CONTROL_API_BASE%/}/v1/system/resume"
186-EOF
187-}
188-
189-env_path="${BAA_FAILOVER_DEFAULT_ENV_PATH}"
190-scenario=""
191-control_api_base=""
192-
193-while [[ $# -gt 0 ]]; do
194- case "$1" in
195- --scenario)
196- validate_scenario "$2"
197- scenario="$2"
198- shift 2
199- ;;
200- --env)
201- env_path="$2"
202- shift 2
203- ;;
204- --control-api-base)
205- control_api_base="$2"
206- shift 2
207- ;;
208- --help)
209- usage
210- exit 0
211- ;;
212- *)
213- die "Unknown option: $1"
214- ;;
215- esac
216-done
217-
218-if [[ -z "$scenario" ]]; then
219- die "--scenario is required"
220-fi
221-
222-load_inventory "$env_path"
223-
224-if [[ -z "$control_api_base" ]]; then
225- control_api_base="$FAILOVER_CONTROL_API_BASE"
226-fi
227-
228-printf 'Scenario: %s\n' "$scenario"
229-print_common_exports
230-
231-case "$scenario" in
232- planned)
233- print_planned_checklist
234- ;;
235- emergency)
236- print_emergency_checklist
237- ;;
238- switchback)
239- print_switchback_checklist
240- ;;
241-esac
+0,
-74
1@@ -1,74 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
6-# shellcheck source=./common.sh
7-source "${SCRIPT_DIR}/common.sh"
8-
9-usage() {
10- cat <<'EOF'
11-Usage:
12- scripts/failover/print-topology.sh [options]
13-
14-Options:
15- --env PATH Inventory file to load. Defaults to scripts/ops/baa-conductor.env.example.
16- --help Show this help text.
17-EOF
18-}
19-
20-env_path="${BAA_FAILOVER_DEFAULT_ENV_PATH}"
21-
22-while [[ $# -gt 0 ]]; do
23- case "$1" in
24- --env)
25- env_path="$2"
26- shift 2
27- ;;
28- --help)
29- usage
30- exit 0
31- ;;
32- *)
33- die "Unknown option: $1"
34- ;;
35- esac
36-done
37-
38-load_inventory "$env_path"
39-
40-public_targets="${FAILOVER_PUBLIC_IPV4:-<unset>}"
41-if [[ -n "${FAILOVER_PUBLIC_IPV6:-}" ]]; then
42- public_targets="${public_targets}, ${FAILOVER_PUBLIC_IPV6}"
43-fi
44-
45-cat <<EOF
46-Failover Topology
47-=================
48-
49-Inventory: ${FAILOVER_ENV_PATH}
50-Control API: ${FAILOVER_CONTROL_API_BASE}
51-
52-Public ingress
53---------------
54-- Cloudflare DNS keeps conductor hosts pinned to the VPS public address: ${public_targets}
55-- https://${FAILOVER_CONDUCTOR_HOST} -> VPS Nginx upstream conductor_primary
56-- conductor_primary -> mini ${FAILOVER_MINI_TAILSCALE_IP}:${FAILOVER_CONDUCTOR_PORT} (primary), mac ${FAILOVER_MAC_TAILSCALE_IP}:${FAILOVER_CONDUCTOR_PORT} (backup)
57-
58-Direct node hosts
59------------------
60-- https://${FAILOVER_MINI_DIRECT_HOST} -> Basic Auth -> mini ${FAILOVER_MINI_TAILSCALE_IP}:${FAILOVER_CONDUCTOR_PORT}
61-- https://${FAILOVER_MAC_DIRECT_HOST} -> Basic Auth -> mac ${FAILOVER_MAC_TAILSCALE_IP}:${FAILOVER_CONDUCTOR_PORT}
62-
63-launchd defaults
64-----------------
65-- mini: BAA_CONDUCTOR_HOST=mini, BAA_CONDUCTOR_ROLE=primary, BAA_NODE_ID=mini-main
66-- mac: BAA_CONDUCTOR_HOST=mac, BAA_CONDUCTOR_ROLE=standby, BAA_NODE_ID=mac-standby
67-- Both nodes keep the same repo/runtime root: /Users/george/code/baa-conductor
68-
69-Operational notes
70------------------
71-- Cloudflare DNS is not part of failover or switchback. Public traffic stays on the VPS.
72-- Nginx failover is transport-based only. It reacts when a 100.x upstream stops accepting traffic.
73-- Nginx does not inspect leader lease state. If mini still answers on ${FAILOVER_MINI_TAILSCALE_IP}:${FAILOVER_CONDUCTOR_PORT} but /rolez says standby, public ingress can still land on mini until mini is stopped or the VPS config is hotfixed.
74-- launchd decides whether each node keeps serving 127.0.0.1:4317 and its Tailscale listener. Control API is only for drain/pause/resume and lease observation.
75-EOF
+0,
-384
1@@ -1,384 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
6-# shellcheck source=./common.sh
7-source "${SCRIPT_DIR}/common.sh"
8-
9-usage() {
10- cat <<'EOF'
11-Usage:
12- scripts/failover/rehearsal-check.sh [options]
13-
14-Options:
15- --env PATH Inventory file to load.
16- --basic-auth USER:PASS Basic Auth for mini/mac direct domains.
17- --bearer-token TOKEN Bearer token for GET /v1/system/state.
18- --bearer-token-file PATH Read the bearer token from a file.
19- --control-api-base URL Override the control API base URL.
20- --expect-leader NODE Assert that mini or mac is the active leader.
21- --skip-node NODE Skip direct checks for one node. Repeatable.
22- --skip-public Skip public conductor host checks.
23- --skip-control-api Skip GET /v1/system/state even when a token is available.
24- --timeout SEC Per-request curl timeout. Defaults to 5.
25- --help Show this help text.
26-
27-Notes:
28- - Public and direct probes are read-only GET requests against /healthz, /readyz, and /rolez.
29- - Direct-node checks are skipped automatically when Basic Auth is not provided.
30- - Control API checks are skipped automatically when a bearer token is not provided.
31-EOF
32-}
33-
34-require_command curl
35-require_command node
36-
37-env_path="${BAA_FAILOVER_DEFAULT_ENV_PATH}"
38-basic_auth="${BAA_FAILOVER_BASIC_AUTH:-}"
39-bearer_token="${BAA_CONTROL_API_TOKEN:-}"
40-bearer_token_file=""
41-control_api_base=""
42-expect_leader=""
43-timeout_sec="5"
44-skip_public="0"
45-skip_control_api="0"
46-skip_nodes=()
47-failures=0
48-
49-record_failure() {
50- failover_error "$*"
51- failures=$((failures + 1))
52-}
53-
54-probe_endpoint() {
55- local status_var="$1"
56- local body_var="$2"
57- local error_var="$3"
58- local url="$4"
59- shift 4
60-
61- local tmp_body=""
62- local tmp_err=""
63- local http_code=""
64- local error_message=""
65- local body=""
66-
67- tmp_body="$(mktemp)"
68- tmp_err="$(mktemp)"
69-
70- if ! http_code="$(curl -sS -L --max-time "$timeout_sec" -o "$tmp_body" -w '%{http_code}' "$@" "$url" 2>"$tmp_err")"; then
71- error_message="$(tr '\n' ' ' < "$tmp_err")"
72- error_message="${error_message%" "}"
73- printf -v "$status_var" '%s' "curl_error"
74- printf -v "$body_var" '%s' ""
75- printf -v "$error_var" '%s' "$error_message"
76- rm -f "$tmp_body" "$tmp_err"
77- return 1
78- fi
79-
80- body="$(tr -d '\r' < "$tmp_body")"
81- body="${body%$'\n'}"
82-
83- printf -v "$status_var" '%s' "$http_code"
84- printf -v "$body_var" '%s' "$body"
85- printf -v "$error_var" '%s' ""
86-
87- rm -f "$tmp_body" "$tmp_err"
88-}
89-
90-format_probe_result() {
91- local status="$1"
92- local body="$2"
93- local error="$3"
94-
95- if [[ "$status" == "curl_error" ]]; then
96- if [[ -n "$error" ]]; then
97- printf 'ERROR(%s)' "$error"
98- else
99- printf 'ERROR'
100- fi
101- return 0
102- fi
103-
104- if [[ -z "$body" ]]; then
105- printf '%s(<empty>)' "$status"
106- return 0
107- fi
108-
109- printf '%s(%s)' "$status" "$body"
110-}
111-
112-probe_surface() {
113- local label="$1"
114- local base_url="$2"
115- shift 2
116-
117- probe_endpoint "${label}_health_status" "${label}_health_body" "${label}_health_error" "${base_url}/healthz" "$@" || true
118- probe_endpoint "${label}_ready_status" "${label}_ready_body" "${label}_ready_error" "${base_url}/readyz" "$@" || true
119- probe_endpoint "${label}_role_status" "${label}_role_body" "${label}_role_error" "${base_url}/rolez" "$@" || true
120-}
121-
122-print_surface_summary() {
123- local label="$1"
124- local base_url="$2"
125- local health_status_var="${label}_health_status"
126- local health_body_var="${label}_health_body"
127- local health_error_var="${label}_health_error"
128- local ready_status_var="${label}_ready_status"
129- local ready_body_var="${label}_ready_body"
130- local ready_error_var="${label}_ready_error"
131- local role_status_var="${label}_role_status"
132- local role_body_var="${label}_role_body"
133- local role_error_var="${label}_role_error"
134-
135- printf '%-10s %s healthz=%s readyz=%s rolez=%s\n' \
136- "${label}" \
137- "${base_url}" \
138- "$(format_probe_result "${!health_status_var:-n/a}" "${!health_body_var:-}" "${!health_error_var:-}")" \
139- "$(format_probe_result "${!ready_status_var:-n/a}" "${!ready_body_var:-}" "${!ready_error_var:-}")" \
140- "$(format_probe_result "${!role_status_var:-n/a}" "${!role_body_var:-}" "${!role_error_var:-}")"
141-}
142-
143-assert_text_response() {
144- local label="$1"
145- local expected_status="$2"
146- local expected_body="$3"
147- local actual_status="$4"
148- local actual_body="$5"
149- local actual_error="$6"
150-
151- if [[ "$actual_status" == "curl_error" ]]; then
152- record_failure "${label} request failed: ${actual_error}"
153- return 0
154- fi
155-
156- if [[ "$actual_status" != "$expected_status" || "$actual_body" != "$expected_body" ]]; then
157- record_failure "${label} expected ${expected_status}(${expected_body}), got ${actual_status}(${actual_body})"
158- fi
159-}
160-
161-assert_surface() {
162- local label="$1"
163- local expected_role="$2"
164- local health_status_var="${label}_health_status"
165- local health_body_var="${label}_health_body"
166- local health_error_var="${label}_health_error"
167- local ready_status_var="${label}_ready_status"
168- local ready_body_var="${label}_ready_body"
169- local ready_error_var="${label}_ready_error"
170- local role_status_var="${label}_role_status"
171- local role_body_var="${label}_role_body"
172- local role_error_var="${label}_role_error"
173-
174- assert_text_response "${label} /healthz" "200" "ok" "${!health_status_var:-}" "${!health_body_var:-}" "${!health_error_var:-}"
175- assert_text_response "${label} /readyz" "200" "ready" "${!ready_status_var:-}" "${!ready_body_var:-}" "${!ready_error_var:-}"
176- assert_text_response "${label} /rolez" "200" "$expected_role" "${!role_status_var:-}" "${!role_body_var:-}" "${!role_error_var:-}"
177-}
178-
179-parse_system_state_json() {
180- node -e 'const fs = require("fs");
181-const payload = JSON.parse(fs.readFileSync(0, "utf8"));
182-const pick = (...values) => values.find((value) => value !== undefined && value !== null);
183-const mode = pick(payload.data && payload.data.mode, payload.mode, payload.automation && payload.automation.mode, "");
184-const holder = pick(payload.data && payload.data.holder_id, payload.holder_id, payload.leader && payload.leader.controller_id, "");
185-const term = pick(payload.data && payload.data.term, payload.term, payload.leader && payload.leader.term, "");
186-const lease = pick(payload.data && payload.data.lease_expires_at, payload.lease_expires_at, payload.leader && payload.leader.lease_expires_at, "");
187-process.stdout.write([mode, holder, term, lease].map((value) => value == null ? "" : String(value)).join("\t"));'
188-}
189-
190-while [[ $# -gt 0 ]]; do
191- case "$1" in
192- --env)
193- env_path="$2"
194- shift 2
195- ;;
196- --basic-auth)
197- basic_auth="$2"
198- shift 2
199- ;;
200- --bearer-token)
201- bearer_token="$2"
202- shift 2
203- ;;
204- --bearer-token-file)
205- bearer_token_file="$2"
206- shift 2
207- ;;
208- --control-api-base)
209- control_api_base="$2"
210- shift 2
211- ;;
212- --expect-leader)
213- validate_node "$2"
214- expect_leader="$2"
215- shift 2
216- ;;
217- --skip-node)
218- validate_node "$2"
219- if ! contains_value "$2" "${skip_nodes[@]-}"; then
220- skip_nodes+=("$2")
221- fi
222- shift 2
223- ;;
224- --skip-public)
225- skip_public="1"
226- shift
227- ;;
228- --skip-control-api)
229- skip_control_api="1"
230- shift
231- ;;
232- --timeout)
233- timeout_sec="$2"
234- shift 2
235- ;;
236- --help)
237- usage
238- exit 0
239- ;;
240- *)
241- die "Unknown option: $1"
242- ;;
243- esac
244-done
245-
246-load_inventory "$env_path"
247-
248-if [[ -n "$bearer_token_file" ]]; then
249- if [[ ! -f "$bearer_token_file" ]]; then
250- die "Bearer token file not found: ${bearer_token_file}"
251- fi
252- bearer_token="$(tr -d '\r\n' < "$bearer_token_file")"
253-fi
254-
255-if [[ -z "$control_api_base" ]]; then
256- control_api_base="$FAILOVER_CONTROL_API_BASE"
257-fi
258-
259-if [[ -z "$basic_auth" ]]; then
260- if ! contains_value mini "${skip_nodes[@]-}"; then
261- skip_nodes+=("mini")
262- fi
263- if ! contains_value mac "${skip_nodes[@]-}"; then
264- skip_nodes+=("mac")
265- fi
266- failover_warn "No direct-node Basic Auth configured; skipping mini/mac direct probes."
267-fi
268-
269-if [[ -n "$expect_leader" ]]; then
270- if [[ -z "$bearer_token" ]] && contains_value mini "${skip_nodes[@]-}" && contains_value mac "${skip_nodes[@]-}" ; then
271- die "Cannot verify --expect-leader without direct-node auth or a control API bearer token."
272- fi
273-fi
274-
275-basic_auth_args=()
276-if [[ -n "$basic_auth" ]]; then
277- basic_auth_args=(-u "$basic_auth")
278-fi
279-
280-printf 'Failover rehearsal snapshot\n'
281-printf 'inventory %s\n' "$FAILOVER_ENV_PATH"
282-
283-if [[ "$skip_public" != "1" ]]; then
284- public_base_url="https://${FAILOVER_CONDUCTOR_HOST}"
285- probe_surface "public" "$public_base_url"
286- print_surface_summary "public" "$public_base_url"
287- assert_surface "public" "leader"
288-else
289- printf '%-10s skipped\n' "public"
290-fi
291-
292-if ! contains_value mini "${skip_nodes[@]-}"; then
293- mini_base_url="https://${FAILOVER_MINI_DIRECT_HOST}"
294- probe_surface "mini" "$mini_base_url" "${basic_auth_args[@]}"
295- print_surface_summary "mini" "$mini_base_url"
296-else
297- printf '%-10s skipped\n' "mini"
298-fi
299-
300-if ! contains_value mac "${skip_nodes[@]-}"; then
301- mac_base_url="https://${FAILOVER_MAC_DIRECT_HOST}"
302- probe_surface "mac" "$mac_base_url" "${basic_auth_args[@]}"
303- print_surface_summary "mac" "$mac_base_url"
304-else
305- printf '%-10s skipped\n' "mac"
306-fi
307-
308-control_mode=""
309-control_holder=""
310-control_term=""
311-control_lease_expires_at=""
312-
313-if [[ "$skip_control_api" != "1" && -n "$bearer_token" ]]; then
314- control_state_url="${control_api_base%/}/v1/system/state"
315- probe_endpoint "control_status" "control_body" "control_error" "$control_state_url" \
316- -H "Authorization: Bearer ${bearer_token}" \
317- -H "Accept: application/json" || true
318-
319- if [[ "${control_status:-}" == "curl_error" ]]; then
320- printf '%-10s %s %s\n' "control" "$control_state_url" "$(format_probe_result "$control_status" "" "$control_error")"
321- record_failure "control API /v1/system/state request failed: ${control_error}"
322- elif [[ "${control_status:-}" != "200" ]]; then
323- printf '%-10s %s %s\n' "control" "$control_state_url" "$(format_probe_result "$control_status" "$control_body" "$control_error")"
324- record_failure "control API /v1/system/state expected 200, got ${control_status}(${control_body})"
325- else
326- parsed_control_state="$(printf '%s' "$control_body" | parse_system_state_json 2>/dev/null || true)"
327- if [[ -z "$parsed_control_state" ]]; then
328- printf '%-10s %s 200(raw=%s)\n' "control" "$control_state_url" "$control_body"
329- record_failure "control API /v1/system/state returned JSON that could not be normalized"
330- else
331- IFS=$'\t' read -r control_mode control_holder control_term control_lease_expires_at <<<"$parsed_control_state"
332- printf '%-10s %s mode=%s holder_id=%s term=%s lease_expires_at=%s\n' \
333- "control" \
334- "$control_state_url" \
335- "${control_mode:-<empty>}" \
336- "${control_holder:-<empty>}" \
337- "${control_term:-<empty>}" \
338- "${control_lease_expires_at:-<empty>}"
339- fi
340- fi
341-else
342- printf '%-10s skipped\n' "control"
343-fi
344-
345-if [[ -z "$expect_leader" ]]; then
346- if ! contains_value mini "${skip_nodes[@]-}" && ! contains_value mac "${skip_nodes[@]-}"; then
347- mini_role="${mini_role_body:-}"
348- mac_role="${mac_role_body:-}"
349- case "${mini_role}:${mac_role}" in
350- leader:standby | standby:leader) ;;
351- *)
352- record_failure "Expected exactly one direct node leader, got mini=${mini_role:-<empty>} mac=${mac_role:-<empty>}"
353- ;;
354- esac
355- fi
356-else
357- if ! contains_value "$expect_leader" "${skip_nodes[@]-}"; then
358- assert_surface "$expect_leader" "leader"
359- fi
360-
361- other_node="mini"
362- if [[ "$expect_leader" == "mini" ]]; then
363- other_node="mac"
364- fi
365-
366- if ! contains_value "$other_node" "${skip_nodes[@]-}"; then
367- assert_surface "$other_node" "standby"
368- fi
369-
370- if [[ -n "$control_holder" ]]; then
371- case "$control_holder" in
372- "${expect_leader}"-*) ;;
373- *)
374- record_failure "control API holder_id expected prefix ${expect_leader}-, got ${control_holder}"
375- ;;
376- esac
377- fi
378-fi
379-
380-if [[ "$failures" -gt 0 ]]; then
381- failover_error "rehearsal checks failed with ${failures} issue(s)"
382- exit 1
383-fi
384-
385-failover_log "rehearsal checks passed"
1@@ -1,6 +1,5 @@
2 # Copy this file to a private path outside the repo, then fill the real values.
3-# The committed example keeps the current fourth-wave hostnames and Tailscale 100.x
4-# upstreams, but uses placeholder public IP and Cloudflare metadata.
5+# Current mainline keeps one public ingress and one mini node.
6
7 BAA_APP_NAME=baa-conductor
8
9@@ -9,18 +8,13 @@ BAA_CF_ZONE_ID=REPLACE_WITH_CLOUDFLARE_ZONE_ID
10 BAA_CF_API_TOKEN_ENV=CLOUDFLARE_API_TOKEN
11 BAA_CF_TTL=1
12 BAA_CF_PROXY_CONDUCTOR=true
13-BAA_CF_PROXY_MINI=true
14-BAA_CF_PROXY_MAC=true
15
16 BAA_PUBLIC_IPV4=203.0.113.10
17 BAA_PUBLIC_IPV6=
18
19 BAA_CONDUCTOR_HOST=conductor.makefile.so
20-BAA_MINI_DIRECT_HOST=mini-conductor.makefile.so
21-BAA_MAC_DIRECT_HOST=mac-conductor.makefile.so
22
23 BAA_MINI_TAILSCALE_IP=100.71.210.78
24-BAA_MAC_TAILSCALE_IP=100.112.239.13
25 BAA_CONDUCTOR_PORT=4317
26
27 BAA_NGINX_SITE_NAME=baa-conductor.conf
+0,
-29
1@@ -71,8 +71,6 @@ export function loadOpsConfig(inputPath) {
2 ttl: parseNumber(env.BAA_CF_TTL ?? "1", "BAA_CF_TTL"),
3 proxied: {
4 conductor: parseBoolean(env.BAA_CF_PROXY_CONDUCTOR ?? "true", "BAA_CF_PROXY_CONDUCTOR"),
5- mini: parseBoolean(env.BAA_CF_PROXY_MINI ?? "true", "BAA_CF_PROXY_MINI"),
6- mac: parseBoolean(env.BAA_CF_PROXY_MAC ?? "true", "BAA_CF_PROXY_MAC"),
7 },
8 },
9 vps: {
10@@ -81,12 +79,9 @@ export function loadOpsConfig(inputPath) {
11 },
12 hosts: {
13 conductor: requiredValue(env, "BAA_CONDUCTOR_HOST"),
14- mini: requiredValue(env, "BAA_MINI_DIRECT_HOST"),
15- mac: requiredValue(env, "BAA_MAC_DIRECT_HOST"),
16 },
17 tailscale: {
18 mini: requiredValue(env, "BAA_MINI_TAILSCALE_IP"),
19- mac: requiredValue(env, "BAA_MAC_TAILSCALE_IP"),
20 port: parseNumber(env.BAA_CONDUCTOR_PORT ?? "4317", "BAA_CONDUCTOR_PORT"),
21 },
22 nginx: {
23@@ -110,20 +105,8 @@ export function buildHostInventory(config) {
24 key: "conductor",
25 hostname: config.hosts.conductor,
26 proxied: config.cloudflare.proxied.conductor,
27- description: `public ingress via VPS -> mini ${config.tailscale.mini}:${config.tailscale.port}, backup mac ${config.tailscale.mac}:${config.tailscale.port}`,
28- },
29- {
30- key: "mini",
31- hostname: config.hosts.mini,
32- proxied: config.cloudflare.proxied.mini,
33 description: `public ingress via VPS -> mini ${config.tailscale.mini}:${config.tailscale.port}`,
34 },
35- {
36- key: "mac",
37- hostname: config.hosts.mac,
38- proxied: config.cloudflare.proxied.mac,
39- description: `public ingress via VPS -> mac ${config.tailscale.mac}:${config.tailscale.port}`,
40- },
41 ];
42 }
43
44@@ -168,19 +151,11 @@ export function getNginxTemplateTokens(config) {
45 "__NGINX_SITE_ENABLED_PATH__": `${config.nginx.siteEnabledDir}/${config.nginx.siteName}`,
46 "__NGINX_INCLUDE_GLOB__": `${config.nginx.includeDir}/*.conf`,
47 "__CONDUCTOR_HOST__": config.hosts.conductor,
48- "__MINI_DIRECT_HOST__": config.hosts.mini,
49- "__MAC_DIRECT_HOST__": config.hosts.mac,
50 "__MINI_TAILSCALE_IP__": config.tailscale.mini,
51- "__MAC_TAILSCALE_IP__": config.tailscale.mac,
52 "__CONDUCTOR_PORT__": String(config.tailscale.port),
53 "__NGINX_INCLUDE_DIR__": config.nginx.includeDir,
54- "__NGINX_HTPASSWD_PATH__": config.nginx.htpasswdPath,
55 "__CONDUCTOR_CERT_FULLCHAIN__": certificatePath(config, config.hosts.conductor, "fullchain.pem"),
56 "__CONDUCTOR_CERT_KEY__": certificatePath(config, config.hosts.conductor, "privkey.pem"),
57- "__MINI_CERT_FULLCHAIN__": certificatePath(config, config.hosts.mini, "fullchain.pem"),
58- "__MINI_CERT_KEY__": certificatePath(config, config.hosts.mini, "privkey.pem"),
59- "__MAC_CERT_FULLCHAIN__": certificatePath(config, config.hosts.mac, "fullchain.pem"),
60- "__MAC_CERT_KEY__": certificatePath(config, config.hosts.mac, "privkey.pem"),
61 };
62 }
63
64@@ -205,7 +180,6 @@ export function buildRenderedNginxArtifacts(config, templates) {
65
66 return {
67 siteConf: renderTemplate(templates.siteConf, tokens),
68- directNodeAuth: renderTemplate(templates.directNodeAuth, tokens),
69 commonProxy: templates.commonProxy,
70 };
71 }
72@@ -266,10 +240,7 @@ function requiredValue(env, key) {
73
74 function validateConfig(config) {
75 validatePublicHostname(config.hosts.conductor, "BAA_CONDUCTOR_HOST");
76- validatePublicHostname(config.hosts.mini, "BAA_MINI_DIRECT_HOST");
77- validatePublicHostname(config.hosts.mac, "BAA_MAC_DIRECT_HOST");
78 validateTailscaleIpv4(config.tailscale.mini, "BAA_MINI_TAILSCALE_IP");
79- validateTailscaleIpv4(config.tailscale.mac, "BAA_MAC_TAILSCALE_IP");
80 }
81
82 function parseBoolean(value, key) {
+2,
-14
1@@ -21,9 +21,9 @@ const usage = `Usage:
2 node scripts/ops/nginx-sync-plan.mjs [--env PATH] [--bundle-dir PATH] [--check-repo]
3
4 Behavior:
5- - Render the committed Nginx templates from the inventory file.
6+ - Render the committed Nginx template from the inventory file.
7 - Stage a deploy bundle with deploy-on-vps.sh.
8- - --check-repo compares the rendered output against ops/nginx/*.conf in the repo.`;
9+ - --check-repo compares the rendered output against ops/nginx/baa-conductor.conf in the repo.`;
10
11 function main() {
12 const args = parseCliArgs(process.argv.slice(2));
13@@ -55,7 +55,6 @@ function main() {
14 function loadTemplates() {
15 return {
16 siteConf: readFileSync(resolve(repoRoot, "ops/nginx/templates/baa-conductor.conf.template"), "utf8"),
17- directNodeAuth: readFileSync(resolve(repoRoot, "ops/nginx/templates/includes/direct-node-auth.conf.template"), "utf8"),
18 commonProxy: readFileSync(resolve(repoRoot, "ops/nginx/includes/common-proxy.conf"), "utf8"),
19 };
20 }
21@@ -63,16 +62,11 @@ function loadTemplates() {
22 function compareWithRepo(rendered) {
23 const mismatches = [];
24 const repoSiteConf = readFileSync(resolve(repoRoot, "ops/nginx/baa-conductor.conf"), "utf8");
25- const repoDirectNodeAuth = readFileSync(resolve(repoRoot, "ops/nginx/includes/direct-node-auth.conf"), "utf8");
26
27 if (repoSiteConf !== rendered.siteConf) {
28 mismatches.push("ops/nginx/baa-conductor.conf");
29 }
30
31- if (repoDirectNodeAuth !== rendered.directNodeAuth) {
32- mismatches.push("ops/nginx/includes/direct-node-auth.conf");
33- }
34-
35 return {
36 clean: mismatches.length === 0,
37 mismatches,
38@@ -83,7 +77,6 @@ function writeBundle(bundleDir, config, rendered) {
39 const siteTargetPath = resolve(bundleDir, stripLeadingSlash(config.nginx.siteInstallDir), config.nginx.siteName);
40 const includeRoot = resolve(bundleDir, stripLeadingSlash(config.nginx.includeDir));
41 const commonProxyPath = resolve(includeRoot, "common-proxy.conf");
42- const directNodeAuthPath = resolve(includeRoot, "direct-node-auth.conf");
43 const deployScriptPath = resolve(bundleDir, "deploy-on-vps.sh");
44 const summaryPath = resolve(bundleDir, "inventory-summary.json");
45 const deployCommandsPath = resolve(bundleDir, "DEPLOY_COMMANDS.txt");
46@@ -93,7 +86,6 @@ function writeBundle(bundleDir, config, rendered) {
47
48 writeFileSync(siteTargetPath, rendered.siteConf, "utf8");
49 writeFileSync(commonProxyPath, rendered.commonProxy, "utf8");
50- writeFileSync(directNodeAuthPath, rendered.directNodeAuth, "utf8");
51 writeFileSync(summaryPath, `${JSON.stringify(buildSummary(config), null, 2)}\n`, "utf8");
52 writeFileSync(deployCommandsPath, `${buildDeployCommands(bundleDir)}\n`, "utf8");
53 writeFileSync(deployScriptPath, buildDeployScript(config), "utf8");
54@@ -115,7 +107,6 @@ function buildSummary(config) {
55 desired_dns_records: desiredDnsRecords,
56 tailscale: {
57 mini: `${config.tailscale.mini}:${config.tailscale.port}`,
58- mac: `${config.tailscale.mac}:${config.tailscale.port}`,
59 },
60 nginx: config.nginx,
61 };
62@@ -137,11 +128,9 @@ function buildDeployCommands(bundleDir) {
63 function buildDeployScript(config) {
64 const siteSource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.siteInstallDir)}/${config.nginx.siteName}"`;
65 const commonProxySource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.includeDir)}/common-proxy.conf"`;
66- const directAuthSource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.includeDir)}/direct-node-auth.conf"`;
67 const siteTarget = `${config.nginx.siteInstallDir}/${config.nginx.siteName}`;
68 const siteEnabledTarget = `${config.nginx.siteEnabledDir}/${config.nginx.siteName}`;
69 const commonProxyTarget = `${config.nginx.includeDir}/common-proxy.conf`;
70- const directAuthTarget = `${config.nginx.includeDir}/direct-node-auth.conf`;
71
72 return `#!/usr/bin/env bash
73 set -euo pipefail
74@@ -159,7 +148,6 @@ install -d -m 0755 ${config.nginx.includeDir}
75
76 install -m 0644 ${siteSource} "${siteTarget}"
77 install -m 0644 ${commonProxySource} "${commonProxyTarget}"
78-install -m 0644 ${directAuthSource} "${directAuthTarget}"
79 ln -sfn "${siteTarget}" "${siteEnabledTarget}"
80
81 nginx -t
+1,
-1
1@@ -11,7 +11,7 @@ Usage:
2 scripts/runtime/check-launchd.sh [options]
3
4 Options:
5- --node mini|mac Select node defaults. Defaults to mini.
6+ --node mini Select node defaults. Defaults to mini.
7 --scope agent|daemon Expected launchd scope for install copies. Defaults to agent.
8 --service NAME Add one service to the check set. Repeatable.
9 --all-services Check conductor, worker-runner, and status-api.
+6,
-9
1@@ -11,7 +11,7 @@ Usage:
2 scripts/runtime/check-node.sh [options]
3
4 Options:
5- --node mini|mac Select node defaults. Defaults to mini.
6+ --node mini Select node defaults. Defaults to mini.
7 --scope agent|daemon Expected launchd scope. Defaults to agent.
8 --service NAME Add one service to the runtime check set. Repeatable.
9 --all-services Check conductor, worker-runner, and status-api.
10@@ -29,7 +29,7 @@ Options:
11 --username NAME Expected UserName for LaunchDaemons.
12 --domain TARGET launchctl domain target for --check-loaded.
13 --check-loaded Also require launchctl print to succeed for each service.
14- --expected-rolez VALUE Expected conductor /rolez body: leader, standby, or any.
15+ --expected-rolez VALUE Expected conductor /rolez body: leader or any.
16 --skip-static-check Skip the underlying check-launchd.sh pass.
17 --skip-port-check Skip local TCP LISTEN checks.
18 --skip-process-check Skip host process command-line checks.
19@@ -187,7 +187,7 @@ validate_node "$node"
20 validate_scope "$scope"
21
22 case "$expected_rolez" in
23- any | leader | standby) ;;
24+ any | leader) ;;
25 *)
26 die "Unsupported --expected-rolez value: ${expected_rolez}"
27 ;;
28@@ -423,12 +423,9 @@ check_conductor_runtime() {
29
30 case "$expected_rolez" in
31 any)
32- case "$HTTP_BODY" in
33- leader | standby) ;;
34- *)
35- die "conductor /rolez must be leader or standby, got '${HTTP_BODY}'"
36- ;;
37- esac
38+ if [[ -z "$HTTP_BODY" ]]; then
39+ die "conductor /rolez returned an empty body"
40+ fi
41 ;;
42 *)
43 if [[ "$HTTP_BODY" != "$expected_rolez" ]]; then
+1,
-4
1@@ -65,7 +65,7 @@ validate_scope() {
2
3 validate_node() {
4 case "$1" in
5- mini | mac) ;;
6+ mini) ;;
7 *)
8 die "Unsupported node: $1"
9 ;;
10@@ -178,9 +178,6 @@ resolve_node_defaults() {
11 mini)
12 printf '%s %s %s\n' "mini" "primary" "mini-main"
13 ;;
14- mac)
15- printf '%s %s %s\n' "mac" "standby" "mac-standby"
16- ;;
17 esac
18 }
19
+1,
-1
1@@ -11,7 +11,7 @@ Usage:
2 scripts/runtime/install-launchd.sh [options]
3
4 Options:
5- --node mini|mac Select node defaults. Defaults to mini.
6+ --node mini Select node defaults. Defaults to mini.
7 --scope agent|daemon Install under LaunchAgents or LaunchDaemons. Defaults to agent.
8 --service NAME Add one service to the install set. Repeatable.
9 --all-services Install conductor, worker-runner, and status-api templates.
+0,
-47
1@@ -1,47 +0,0 @@
2-# Smoke Harness
3-
4-最小本地 smoke 流程:
5-
6-```bash
7-bash scripts/smoke/run-e2e.sh --json
8-```
9-
10-如果要分步执行:
11-
12-```bash
13-bash scripts/smoke/start-stack.sh --json
14-bash scripts/smoke/check-stack.sh --state-dir <tmp/smoke-...> --expected-leader smoke-mini --json
15-bash scripts/smoke/stop-stack.sh --state-dir <tmp/smoke-...> --json
16-```
17-
18-`run-e2e.sh` 会完成这些动作:
19-
20-- 构建 `control-api-worker` 和 `status-api` 的本地运行产物
21-- 启动本地 `control-api`、主 conductor、备 conductor、`status-api`
22-- 验证 `healthz` / `readyz` / `rolez` / `/v1/system/state` / `/v1/status`
23-- 创建一个 queued task,确认 `status-api` 的 `queueDepth` 能读到
24-- 执行一次最小主备切换,确认 lease 从 `smoke-mini` 漂移到 `smoke-mac`
25-
26-所有临时状态、数据库和日志都会落到 `tmp/smoke-*` 目录。
27-
28-## Live 环境快照
29-
30-真实环境回归可以直接跑:
31-
32-```bash
33-node scripts/smoke/live-regression.mjs \
34- --env /Users/george/.config/baa-conductor/ops.env \
35- --control-secrets /Users/george/.config/baa-conductor/control-api-worker.secrets.env \
36- --basic-auth-file /Users/george/.config/baa-conductor/direct-node-basic-auth.env \
37- --expect-leader mini
38-```
39-
40-它会一次性采样这些面:
41-
42-- `GET https://control-api.makefile.so/v1/system/state`
43-- `conductor.makefile.so` 的 `/healthz` `/readyz` `/rolez`
44-- `mini-conductor.makefile.so` / `mac-conductor.makefile.so` 的 Basic Auth 与 `/healthz` `/readyz` `/rolez`
45-- `http://100.71.210.78:4318/v1/status`
46-- `http://100.112.239.13:4318/v1/status`
47-
48-默认输出 JSON 快照,并把 status-api 视图是否和 control-api 当前 leader / mode 对齐也一起标出来。
+0,
-6
1@@ -1,6 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6-
7-exec node "$ROOT_DIR/scripts/smoke/stack-cli.mjs" check "$@"
+0,
-164
1@@ -1,164 +0,0 @@
2-import { createServer } from "node:http";
3-import controlApiWorker from "../../apps/control-api-worker/dist/index.js";
4-import { initializeSqliteD1Database } from "./d1-sqlite.mjs";
5-
6-function parseArgs(argv) {
7- const options = {
8- authRequired: false,
9- databasePath: null,
10- host: "127.0.0.1",
11- port: null
12- };
13-
14- for (let index = 0; index < argv.length; index += 1) {
15- const token = argv[index];
16-
17- switch (token) {
18- case "--auth-required":
19- options.authRequired = true;
20- break;
21- case "--db":
22- options.databasePath = readValue(argv, token, index);
23- index += 1;
24- break;
25- case "--host":
26- options.host = readValue(argv, token, index);
27- index += 1;
28- break;
29- case "--port":
30- options.port = parsePort(readValue(argv, token, index), token);
31- index += 1;
32- break;
33- default:
34- throw new Error(`Unknown control-api-local option "${token}".`);
35- }
36- }
37-
38- if (!options.databasePath) {
39- throw new Error("--db is required.");
40- }
41-
42- if (options.port == null) {
43- throw new Error("--port is required.");
44- }
45-
46- return options;
47-}
48-
49-function readValue(tokens, flag, index) {
50- const value = tokens[index + 1];
51-
52- if (!value || value.startsWith("--")) {
53- throw new Error(`Missing value for ${flag}.`);
54- }
55-
56- return value;
57-}
58-
59-function parsePort(value, flag) {
60- const port = Number(value);
61-
62- if (!Number.isInteger(port) || port < 0 || port > 65_535) {
63- throw new Error(`Invalid value for ${flag}: "${value}".`);
64- }
65-
66- return port;
67-}
68-
69-async function readRequestBody(request) {
70- const chunks = [];
71-
72- for await (const chunk of request) {
73- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
74- }
75-
76- return Buffer.concat(chunks);
77-}
78-
79-async function main() {
80- const options = parseArgs(process.argv.slice(2));
81- const db = initializeSqliteD1Database({
82- databasePath: options.databasePath
83- });
84- const env = {
85- CONTROL_API_AUTH_REQUIRED: options.authRequired ? "true" : "false",
86- CONTROL_API_VERSION: "smoke-local",
87- CONTROL_DB: db
88- };
89- const baseUrl = `http://${options.host}:${options.port}`;
90- const server = createServer((request, response) => {
91- void handleRequest(request, response, baseUrl, env);
92- });
93-
94- const close = async () => {
95- await new Promise((resolve, reject) => {
96- server.close((error) => {
97- if (error) {
98- reject(error);
99- return;
100- }
101-
102- resolve();
103- });
104- });
105- db.close();
106- };
107-
108- const shutdown = (signal) => {
109- void close().finally(() => {
110- process.exitCode = 0;
111- if (signal) {
112- process.stderr.write(`control-api-local stopped after ${signal}\n`);
113- }
114- });
115- };
116-
117- process.once("SIGINT", () => shutdown("SIGINT"));
118- process.once("SIGTERM", () => shutdown("SIGTERM"));
119-
120- await new Promise((resolve) => {
121- server.listen(options.port, options.host, resolve);
122- });
123-
124- process.stdout.write(`control-api-local listening on ${baseUrl}\n`);
125-}
126-
127-async function handleRequest(request, response, baseUrl, env) {
128- try {
129- const body = await readRequestBody(request);
130- const url = new URL(request.url ?? "/", baseUrl);
131- const workerRequest = new Request(url, {
132- body: request.method === "GET" || request.method === "HEAD" ? undefined : body,
133- headers: new Headers(request.headers),
134- method: request.method ?? "GET"
135- });
136- const workerResponse = await controlApiWorker.fetch(workerRequest, env, {
137- passThroughOnException() {},
138- waitUntil() {}
139- });
140-
141- response.statusCode = workerResponse.status;
142-
143- for (const [name, value] of workerResponse.headers.entries()) {
144- response.setHeader(name, value);
145- }
146-
147- response.end(Buffer.from(await workerResponse.arrayBuffer()));
148- } catch (error) {
149- response.statusCode = 500;
150- response.setHeader("content-type", "application/json; charset=utf-8");
151- response.end(
152- `${JSON.stringify(
153- {
154- ok: false,
155- error: "control_api_local_failure",
156- message: error instanceof Error ? error.message : String(error)
157- },
158- null,
159- 2
160- )}\n`
161- );
162- }
163-}
164-
165-await main();
+0,
-165
1@@ -1,165 +0,0 @@
2-import { mkdirSync, readFileSync } 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 DEFAULT_BUSY_TIMEOUT_MS = 5_000;
8-const DEFAULT_AUTOMATION_MODE = "running";
9-
10-function normalizeRow(row) {
11- if (row == null || typeof row !== "object" || Array.isArray(row)) {
12- return row ?? null;
13- }
14-
15- return { ...row };
16-}
17-
18-function toLastRowId(value) {
19- if (typeof value === "bigint") {
20- const normalized = Number(value);
21- return Number.isSafeInteger(normalized) ? normalized : undefined;
22- }
23-
24- return typeof value === "number" ? value : undefined;
25-}
26-
27-class SqliteD1PreparedStatement {
28- constructor(database, query, values = []) {
29- this.database = database;
30- this.query = query;
31- this.values = [...values];
32- }
33-
34- bind(...values) {
35- return new SqliteD1PreparedStatement(this.database, this.query, values);
36- }
37-
38- async all() {
39- const rows = this.#statement().all(...this.values).map(normalizeRow);
40-
41- return {
42- meta: {
43- rows_read: rows.length
44- },
45- results: rows,
46- success: true
47- };
48- }
49-
50- async first(columnName) {
51- const row = normalizeRow(this.#statement().get(...this.values));
52-
53- if (row == null) {
54- return null;
55- }
56-
57- if (columnName == null) {
58- return row;
59- }
60-
61- return Object.prototype.hasOwnProperty.call(row, columnName) ? row[columnName] ?? null : null;
62- }
63-
64- async raw(options = {}) {
65- const statement = this.#statement();
66- const columns = statement.columns().map((column) => column.name);
67- const rows = statement.all(...this.values).map((row) => columns.map((columnName) => row[columnName] ?? null));
68-
69- return options.columnNames ? [columns, ...rows] : rows;
70- }
71-
72- async run() {
73- const result = this.#statement().run(...this.values);
74-
75- return {
76- meta: {
77- changes: result.changes,
78- last_row_id: toLastRowId(result.lastInsertRowid)
79- },
80- success: true
81- };
82- }
83-
84- #statement() {
85- return this.database.prepare(this.query);
86- }
87-}
88-
89-export class SqliteD1Database {
90- constructor(databasePath) {
91- const resolvedPath = resolve(databasePath);
92- mkdirSync(dirname(resolvedPath), { recursive: true });
93- this.path = resolvedPath;
94- this.database = new DatabaseSync(this.path);
95- this.database.exec("PRAGMA journal_mode = WAL;");
96- this.database.exec("PRAGMA synchronous = NORMAL;");
97- this.database.exec("PRAGMA foreign_keys = ON;");
98- this.database.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
99- }
100-
101- async batch(statements) {
102- const results = [];
103-
104- this.database.exec("BEGIN IMMEDIATE;");
105-
106- try {
107- for (const statement of statements) {
108- if (!(statement instanceof SqliteD1PreparedStatement)) {
109- throw new TypeError("SqliteD1Database.batch only accepts statements created by this adapter.");
110- }
111-
112- results.push(await statement.run());
113- }
114-
115- this.database.exec("COMMIT;");
116- return results;
117- } catch (error) {
118- this.database.exec("ROLLBACK;");
119- throw error;
120- }
121- }
122-
123- close() {
124- this.database.close();
125- }
126-
127- async exec(query) {
128- this.database.exec(query);
129- return {};
130- }
131-
132- prepare(query) {
133- return new SqliteD1PreparedStatement(this.database, query);
134- }
135-}
136-
137-export function createSqliteD1Database(databasePath) {
138- return new SqliteD1Database(databasePath);
139-}
140-
141-export function getDefaultSmokeSchemaPath() {
142- return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "ops", "sql", "schema.sql");
143-}
144-
145-export function initializeSqliteD1Database({
146- databasePath,
147- schemaPath = getDefaultSmokeSchemaPath(),
148- automationMode = DEFAULT_AUTOMATION_MODE
149-}) {
150- const db = createSqliteD1Database(databasePath);
151- const schema = readFileSync(schemaPath, "utf8");
152- const nowUnixSeconds = Math.floor(Date.now() / 1_000);
153-
154- db.database.exec(schema);
155- db.database
156- .prepare(
157- `
158- INSERT INTO system_state (state_key, value_json, updated_at)
159- VALUES (?, ?, ?)
160- ON CONFLICT(state_key) DO NOTHING
161- `
162- )
163- .run("automation", JSON.stringify({ mode: automationMode }), nowUnixSeconds);
164-
165- return db;
166-}
+0,
-473
1@@ -1,473 +0,0 @@
2-#!/usr/bin/env node
3-
4-import { execFile } from "node:child_process";
5-import { readFileSync } from "node:fs";
6-import { basename } from "node:path";
7-import { promisify } from "node:util";
8-
9-const DEFAULT_CONTROL_API_BASE = "https://control-api.makefile.so";
10-const DEFAULT_CONDUCTOR_PORT = 4317;
11-const DEFAULT_STATUS_API_PORT = 4318;
12-const DEFAULT_TIMEOUT_MS = 5000;
13-const execFileAsync = promisify(execFile);
14-
15-function usage() {
16- process.stdout.write(`Usage:
17- node scripts/smoke/live-regression.mjs [options]
18-
19-Options:
20- --env PATH Inventory env file.
21- --control-secrets PATH Secrets env file with control-api tokens.
22- --basic-auth-file PATH Env file with direct-node Basic Auth credentials.
23- --bearer-token TOKEN Override the bearer token used for GET /v1/system/state.
24- --basic-auth USER:PASS Override the direct-node Basic Auth value.
25- --expect-leader VALUE mini, mac, or any. Defaults to mini.
26- --timeout-ms N Per-request timeout in milliseconds. Defaults to 5000.
27- --compact Emit compact JSON instead of pretty-printed JSON.
28- --strict Exit non-zero when assertions fail.
29- --help Show this help text.
30-`);
31-}
32-
33-function parseArgs(argv) {
34- const options = {
35- compact: false,
36- expectLeader: "mini",
37- strict: false,
38- timeoutMs: DEFAULT_TIMEOUT_MS
39- };
40-
41- for (let index = 0; index < argv.length; index += 1) {
42- const token = argv[index];
43- switch (token) {
44- case "--env":
45- options.envPath = requireValue(argv, ++index, token);
46- break;
47- case "--control-secrets":
48- options.controlSecretsPath = requireValue(argv, ++index, token);
49- break;
50- case "--basic-auth-file":
51- options.basicAuthFile = requireValue(argv, ++index, token);
52- break;
53- case "--bearer-token":
54- options.bearerToken = requireValue(argv, ++index, token);
55- break;
56- case "--basic-auth":
57- options.basicAuth = requireValue(argv, ++index, token);
58- break;
59- case "--expect-leader":
60- options.expectLeader = requireValue(argv, ++index, token);
61- break;
62- case "--timeout-ms":
63- options.timeoutMs = Number.parseInt(requireValue(argv, ++index, token), 10);
64- break;
65- case "--compact":
66- options.compact = true;
67- break;
68- case "--strict":
69- options.strict = true;
70- break;
71- case "--help":
72- usage();
73- process.exit(0);
74- default:
75- throw new Error(`Unknown option "${token}".`);
76- }
77- }
78-
79- if (!options.envPath) {
80- throw new Error("--env is required.");
81- }
82-
83- if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
84- throw new Error("--timeout-ms must be a positive integer.");
85- }
86-
87- if (!["mini", "mac", "any"].includes(options.expectLeader)) {
88- throw new Error(`Unsupported --expect-leader value "${options.expectLeader}".`);
89- }
90-
91- return options;
92-}
93-
94-function requireValue(argv, index, optionName) {
95- const value = argv[index];
96- if (!value) {
97- throw new Error(`${optionName} requires a value.`);
98- }
99- return value;
100-}
101-
102-function loadEnvFile(path) {
103- const text = readFileSync(path, "utf8");
104- const entries = {};
105-
106- for (const rawLine of text.split(/\r?\n/u)) {
107- const line = rawLine.trim();
108- if (!line || line.startsWith("#")) {
109- continue;
110- }
111-
112- const separatorIndex = line.indexOf("=");
113- if (separatorIndex <= 0) {
114- continue;
115- }
116-
117- const key = line.slice(0, separatorIndex).trim();
118- let value = line.slice(separatorIndex + 1).trim();
119-
120- if (
121- (value.startsWith('"') && value.endsWith('"')) ||
122- (value.startsWith("'") && value.endsWith("'"))
123- ) {
124- value = value.slice(1, -1);
125- }
126-
127- entries[key] = value;
128- }
129-
130- return entries;
131-}
132-
133-function pickBearerToken(options, secrets) {
134- return (
135- options.bearerToken ||
136- secrets.CONTROL_API_OPS_ADMIN_TOKEN ||
137- secrets.CONTROL_API_READONLY_TOKEN ||
138- secrets.CONTROL_API_BROWSER_ADMIN_TOKEN ||
139- ""
140- );
141-}
142-
143-function pickBasicAuth(options, authEnv) {
144- if (options.basicAuth) {
145- return options.basicAuth;
146- }
147-
148- const user = authEnv.BAA_DIRECT_NODE_BASIC_AUTH_USER || "";
149- const password = authEnv.BAA_DIRECT_NODE_BASIC_AUTH_PASSWORD || "";
150- if (!user || !password) {
151- return "";
152- }
153-
154- return `${user}:${password}`;
155-}
156-
157-function toEpochIso(value) {
158- if (value === null || value === undefined || value === "") {
159- return null;
160- }
161-
162- const numericValue = Number(value);
163- if (!Number.isFinite(numericValue)) {
164- return null;
165- }
166-
167- const millis = numericValue > 1_000_000_000_000 ? numericValue : numericValue * 1000;
168- return new Date(millis).toISOString();
169-}
170-
171-function normalizeControlState(payload) {
172- const data = payload?.data ?? payload ?? {};
173- return {
174- holderId: data.holder_id ?? payload?.holder_id ?? null,
175- leaseExpiresAt: data.lease_expires_at ?? payload?.lease_expires_at ?? null,
176- leaseExpiresAtIso: toEpochIso(data.lease_expires_at ?? payload?.lease_expires_at ?? null),
177- mode: data.mode ?? payload?.mode ?? null,
178- ok: payload?.ok === true,
179- requestId: payload?.request_id ?? null,
180- term: data.term ?? payload?.term ?? null
181- };
182-}
183-
184-function normalizeStatusSnapshot(payload) {
185- const data = payload?.data ?? payload ?? {};
186- return {
187- activeRuns: data.activeRuns ?? null,
188- leaderHost: data.leaderHost ?? null,
189- leaderId: data.leaderId ?? null,
190- leaseActive: data.leaseActive ?? null,
191- leaseExpiresAt: data.leaseExpiresAt ?? null,
192- leaseTerm: data.leaseTerm ?? null,
193- mode: data.mode ?? null,
194- observedAt: data.observedAt ?? null,
195- ok: payload?.ok === true,
196- queueDepth: data.queueDepth ?? null,
197- source: data.source ?? null,
198- updatedAt: data.updatedAt ?? null
199- };
200-}
201-
202-function expectedLeaderFromControl(controlState) {
203- const holderId = controlState?.holderId ?? "";
204- if (holderId.startsWith("mini-")) {
205- return "mini";
206- }
207- if (holderId.startsWith("mac-")) {
208- return "mac";
209- }
210- return null;
211-}
212-
213-async function fetchProbe(url, { headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
214- const args = [
215- "-sS",
216- "-L",
217- "--max-time",
218- String(Math.max(1, Math.ceil(timeoutMs / 1000)))
219- ];
220-
221- for (const [key, value] of Object.entries(headers)) {
222- args.push("-H", `${key}: ${value}`);
223- }
224-
225- args.push("-w", "\n__HTTP_STATUS__:%{http_code}", url);
226-
227- try {
228- const { stdout } = await execFileAsync("curl", args, {
229- maxBuffer: 10 * 1024 * 1024,
230- timeout: timeoutMs + 1000
231- });
232- const marker = "\n__HTTP_STATUS__:";
233- const markerIndex = stdout.lastIndexOf(marker);
234- const body = markerIndex >= 0 ? stdout.slice(0, markerIndex) : stdout;
235- const statusText = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length).trim() : "";
236- const httpStatus = Number.parseInt(statusText, 10);
237-
238- return {
239- body: body.replace(/\r/g, "").replace(/\n+$/u, ""),
240- httpStatus: Number.isFinite(httpStatus) ? httpStatus : null,
241- ok: httpStatus >= 200 && httpStatus < 300,
242- url
243- };
244- } catch (error) {
245- return {
246- body: "",
247- error:
248- error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string"
249- ? error.stderr.trim() || (error instanceof Error ? error.message : String(error))
250- : error instanceof Error
251- ? error.message
252- : String(error),
253- httpStatus: null,
254- ok: false,
255- url
256- };
257- }
258-}
259-
260-async function probeConductor(baseUrl, headers, timeoutMs) {
261- const [healthz, readyz, rolez] = await Promise.all([
262- fetchProbe(`${baseUrl}/healthz`, { headers, timeoutMs }),
263- fetchProbe(`${baseUrl}/readyz`, { headers, timeoutMs }),
264- fetchProbe(`${baseUrl}/rolez`, { headers, timeoutMs })
265- ]);
266-
267- return { baseUrl, healthz, readyz, rolez };
268-}
269-
270-async function probeStatusApi(baseUrl, timeoutMs) {
271- const [healthz, status] = await Promise.all([
272- fetchProbe(`${baseUrl}/healthz`, { timeoutMs }),
273- fetchProbe(`${baseUrl}/v1/status`, { timeoutMs })
274- ]);
275-
276- let parsedStatus = null;
277- if (!status.error && status.body) {
278- try {
279- parsedStatus = JSON.parse(status.body);
280- } catch (error) {
281- status.parseError = error instanceof Error ? error.message : String(error);
282- }
283- }
284-
285- return {
286- baseUrl,
287- healthz,
288- status,
289- summary: parsedStatus ? normalizeStatusSnapshot(parsedStatus) : null
290- };
291-}
292-
293-function addIssue(issues, condition, message) {
294- if (!condition) {
295- issues.push(message);
296- }
297-}
298-
299-function responseMatches(probe, expectedStatus, expectedBody) {
300- return probe?.httpStatus === expectedStatus && probe?.body === expectedBody;
301-}
302-
303-function statusMatchesControl(controlState, statusSummary) {
304- if (!controlState?.holderId || !controlState?.mode || !statusSummary) {
305- return false;
306- }
307-
308- return (
309- statusSummary.ok === true &&
310- statusSummary.mode === controlState.mode &&
311- statusSummary.leaderId === controlState.holderId &&
312- statusSummary.source !== "empty"
313- );
314-}
315-
316-async function main() {
317- const options = parseArgs(process.argv.slice(2));
318- const inventory = loadEnvFile(options.envPath);
319- const controlSecrets = options.controlSecretsPath ? loadEnvFile(options.controlSecretsPath) : {};
320- const basicAuthEnv = options.basicAuthFile ? loadEnvFile(options.basicAuthFile) : {};
321-
322- const bearerToken = pickBearerToken(options, controlSecrets);
323- const basicAuth = pickBasicAuth(options, basicAuthEnv);
324-
325- if (!bearerToken) {
326- throw new Error("No bearer token available. Pass --bearer-token or --control-secrets.");
327- }
328-
329- if (!basicAuth) {
330- throw new Error("No Basic Auth available. Pass --basic-auth or --basic-auth-file.");
331- }
332-
333- const controlApiBase = inventory.BAA_CONTROL_API_BASE || DEFAULT_CONTROL_API_BASE;
334- const conductorPort = Number.parseInt(inventory.BAA_CONDUCTOR_PORT || "", 10) || DEFAULT_CONDUCTOR_PORT;
335- const statusApiPort = DEFAULT_STATUS_API_PORT;
336- const publicBaseUrl = `https://${inventory.BAA_CONDUCTOR_HOST}`;
337- const miniDirectBaseUrl = `https://${inventory.BAA_MINI_DIRECT_HOST}`;
338- const macDirectBaseUrl = `https://${inventory.BAA_MAC_DIRECT_HOST}`;
339- const miniStatusBaseUrl = `http://${inventory.BAA_MINI_TAILSCALE_IP}:${statusApiPort}`;
340- const macStatusBaseUrl = `http://${inventory.BAA_MAC_TAILSCALE_IP}:${statusApiPort}`;
341-
342- const authHeaders = {
343- Authorization: `Basic ${Buffer.from(basicAuth).toString("base64")}`
344- };
345-
346- const [controlStateProbe, publicConductor, miniNoAuth, miniDirect, macNoAuth, macDirect, miniStatus, macStatus] =
347- await Promise.all([
348- fetchProbe(`${controlApiBase.replace(/\/+$/u, "")}/v1/system/state`, {
349- headers: {
350- Accept: "application/json",
351- Authorization: `Bearer ${bearerToken}`
352- },
353- timeoutMs: options.timeoutMs
354- }),
355- probeConductor(publicBaseUrl, {}, options.timeoutMs),
356- fetchProbe(`${miniDirectBaseUrl}/healthz`, { timeoutMs: options.timeoutMs }),
357- probeConductor(miniDirectBaseUrl, authHeaders, options.timeoutMs),
358- fetchProbe(`${macDirectBaseUrl}/healthz`, { timeoutMs: options.timeoutMs }),
359- probeConductor(macDirectBaseUrl, authHeaders, options.timeoutMs),
360- probeStatusApi(miniStatusBaseUrl, options.timeoutMs),
361- probeStatusApi(macStatusBaseUrl, options.timeoutMs)
362- ]);
363-
364- let controlState = null;
365- if (!controlStateProbe.error && controlStateProbe.body) {
366- try {
367- controlState = normalizeControlState(JSON.parse(controlStateProbe.body));
368- } catch (error) {
369- controlStateProbe.parseError = error instanceof Error ? error.message : String(error);
370- }
371- }
372-
373- const inferredLeader = options.expectLeader === "any" ? expectedLeaderFromControl(controlState) : options.expectLeader;
374- const expectedLeader = inferredLeader || options.expectLeader;
375- const expectedStandby = expectedLeader === "mini" ? "mac" : expectedLeader === "mac" ? "mini" : null;
376-
377- const issues = [];
378-
379- addIssue(issues, controlStateProbe.httpStatus === 200, `control-api /v1/system/state HTTP ${controlStateProbe.httpStatus ?? "error"}`);
380- addIssue(issues, controlState?.ok === true, "control-api /v1/system/state did not return ok=true");
381- addIssue(issues, responseMatches(publicConductor.healthz, 200, "ok"), "public /healthz did not return 200(ok)");
382- addIssue(issues, responseMatches(publicConductor.readyz, 200, "ready"), "public /readyz did not return 200(ready)");
383- addIssue(issues, responseMatches(publicConductor.rolez, 200, "leader"), "public /rolez did not return 200(leader)");
384- addIssue(issues, miniNoAuth.httpStatus === 401, `mini direct no-auth /healthz expected 401, got ${miniNoAuth.httpStatus ?? "error"}`);
385- addIssue(issues, macNoAuth.httpStatus === 401, `mac direct no-auth /healthz expected 401, got ${macNoAuth.httpStatus ?? "error"}`);
386- addIssue(issues, responseMatches(miniDirect.healthz, 200, "ok"), "mini direct /healthz did not return 200(ok)");
387- addIssue(issues, responseMatches(miniDirect.readyz, 200, "ready"), "mini direct /readyz did not return 200(ready)");
388- addIssue(issues, responseMatches(macDirect.healthz, 200, "ok"), "mac direct /healthz did not return 200(ok)");
389- addIssue(issues, responseMatches(macDirect.readyz, 200, "ready"), "mac direct /readyz did not return 200(ready)");
390-
391- if (expectedLeader === "mini") {
392- addIssue(issues, responseMatches(miniDirect.rolez, 200, "leader"), "mini direct /rolez did not return 200(leader)");
393- addIssue(issues, responseMatches(macDirect.rolez, 200, "standby"), "mac direct /rolez did not return 200(standby)");
394- addIssue(issues, controlState?.holderId?.startsWith("mini-") === true, `control-api holder_id did not point at mini: ${controlState?.holderId ?? "null"}`);
395- } else if (expectedLeader === "mac") {
396- addIssue(issues, responseMatches(macDirect.rolez, 200, "leader"), "mac direct /rolez did not return 200(leader)");
397- addIssue(issues, responseMatches(miniDirect.rolez, 200, "standby"), "mini direct /rolez did not return 200(standby)");
398- addIssue(issues, controlState?.holderId?.startsWith("mac-") === true, `control-api holder_id did not point at mac: ${controlState?.holderId ?? "null"}`);
399- }
400-
401- for (const [nodeName, statusProbe] of [
402- ["mini", miniStatus],
403- ["mac", macStatus]
404- ]) {
405- addIssue(issues, responseMatches(statusProbe.healthz, 200, "ok"), `${nodeName} status-api /healthz did not return 200(ok)`);
406- addIssue(issues, statusProbe.status.httpStatus === 200, `${nodeName} status-api /v1/status HTTP ${statusProbe.status.httpStatus ?? "error"}`);
407- addIssue(issues, statusProbe.summary?.ok === true, `${nodeName} status-api /v1/status did not return ok=true`);
408- addIssue(
409- issues,
410- statusMatchesControl(controlState, statusProbe.summary),
411- `${nodeName} status-api snapshot does not match control-api state`
412- );
413- }
414-
415- const result = {
416- ok: issues.length === 0,
417- observedAt: new Date().toISOString(),
418- inventory: {
419- conductorHost: inventory.BAA_CONDUCTOR_HOST,
420- envPath: options.envPath,
421- macDirectHost: inventory.BAA_MAC_DIRECT_HOST,
422- macTailscaleIp: inventory.BAA_MAC_TAILSCALE_IP,
423- miniDirectHost: inventory.BAA_MINI_DIRECT_HOST,
424- miniTailscaleIp: inventory.BAA_MINI_TAILSCALE_IP,
425- publicIpv4: inventory.BAA_PUBLIC_IPV4 || null,
426- proxies: {
427- conductor: inventory.BAA_CF_PROXY_CONDUCTOR ?? null,
428- mac: inventory.BAA_CF_PROXY_MAC ?? null,
429- mini: inventory.BAA_CF_PROXY_MINI ?? null
430- },
431- statusApiPort,
432- conductorPort
433- },
434- expectations: {
435- expectedLeader,
436- requestedExpectLeader: options.expectLeader,
437- expectedStandby
438- },
439- controlApi: {
440- baseUrl: controlApiBase,
441- probe: controlStateProbe,
442- state: controlState
443- },
444- conductors: {
445- public: publicConductor,
446- miniDirect: {
447- noAuthHealthz: miniNoAuth,
448- auth: miniDirect
449- },
450- macDirect: {
451- noAuthHealthz: macNoAuth,
452- auth: macDirect
453- }
454- },
455- statusApis: {
456- mini: miniStatus,
457- mac: macStatus
458- },
459- issues
460- };
461-
462- const json = JSON.stringify(result, null, options.compact ? 0 : 2);
463- process.stdout.write(`${json}\n`);
464-
465- if (options.strict && !result.ok) {
466- process.exit(1);
467- }
468-}
469-
470-main().catch((error) => {
471- const message = error instanceof Error ? error.message : String(error);
472- process.stderr.write(`${basename(process.argv[1])}: ${message}\n`);
473- process.exit(1);
474-});
+0,
-6
1@@ -1,6 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6-
7-exec node "$ROOT_DIR/scripts/smoke/stack-cli.mjs" run "$@"
+0,
-831
1@@ -1,831 +0,0 @@
2-import { spawn } from "node:child_process";
3-import { appendFileSync, closeSync, openSync } from "node:fs";
4-import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
5-import { createServer } from "node:net";
6-import { dirname, join, resolve } from "node:path";
7-import { setTimeout as delay } from "node:timers/promises";
8-import { fileURLToPath } from "node:url";
9-
10-const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
11-const DEFAULT_HOST = "127.0.0.1";
12-const DEFAULT_LEASE_RENEW_INTERVAL_MS = 250;
13-const DEFAULT_HEARTBEAT_INTERVAL_MS = 250;
14-const DEFAULT_LEASE_TTL_SEC = 2;
15-const HTTP_TIMEOUT_MS = 2_000;
16-const START_TIMEOUT_MS = 15_000;
17-const STOP_TIMEOUT_MS = 5_000;
18-const POLL_INTERVAL_MS = 200;
19-const PRIMARY_NODE_ID = "smoke-mini";
20-const STANDBY_NODE_ID = "smoke-mac";
21-
22-async function main() {
23- const options = parseCli(process.argv.slice(2));
24-
25- if (options.help) {
26- writeResult(options, renderHelp());
27- return;
28- }
29-
30- switch (options.command) {
31- case "start": {
32- const state = await startStack(options);
33- writeResult(options, {
34- ok: true,
35- stateDir: state.stateDir,
36- services: summarizeStateServices(state)
37- });
38- return;
39- }
40-
41- case "check": {
42- const state = await loadState(options);
43- const result = await captureLiveStack(state, {
44- expectedLeader: options.expectedLeader
45- });
46-
47- writeResult(options, {
48- ok: true,
49- stateDir: state.stateDir,
50- ...result
51- });
52- return;
53- }
54-
55- case "stop": {
56- const state = await loadState(options);
57- await stopStack(state);
58- writeResult(options, {
59- ok: true,
60- stateDir: state.stateDir,
61- stopped: true
62- });
63- return;
64- }
65-
66- case "run": {
67- const result = await runSmokeFlow(options);
68- writeResult(options, result);
69- return;
70- }
71- }
72-}
73-
74-function parseCli(argv) {
75- let command = "run";
76- let index = 0;
77-
78- if (argv[0] && !argv[0].startsWith("--")) {
79- command = argv[0];
80- index = 1;
81- }
82-
83- const options = {
84- command,
85- expectedLeader: null,
86- help: false,
87- json: false,
88- skipBuild: false,
89- stateDir: null
90- };
91-
92- for (; index < argv.length; index += 1) {
93- const token = argv[index];
94-
95- switch (token) {
96- case "--expected-leader":
97- options.expectedLeader = readOptionValue(argv, token, index);
98- index += 1;
99- break;
100- case "--help":
101- case "-h":
102- options.help = true;
103- break;
104- case "--json":
105- options.json = true;
106- break;
107- case "--skip-build":
108- options.skipBuild = true;
109- break;
110- case "--state-dir":
111- options.stateDir = resolve(readOptionValue(argv, token, index));
112- index += 1;
113- break;
114- default:
115- throw new Error(`Unknown smoke command option "${token}".`);
116- }
117- }
118-
119- if (!["start", "check", "run", "stop"].includes(options.command)) {
120- throw new Error(`Unknown smoke command "${options.command}".`);
121- }
122-
123- return options;
124-}
125-
126-function readOptionValue(argv, flag, index) {
127- const value = argv[index + 1];
128-
129- if (!value || value.startsWith("--")) {
130- throw new Error(`Missing value for ${flag}.`);
131- }
132-
133- return value;
134-}
135-
136-async function runSmokeFlow(options) {
137- const state = await startStack(options);
138-
139- try {
140- const initial = await captureLiveStack(state, {
141- expectedLeader: PRIMARY_NODE_ID
142- });
143-
144- const drained = await verifyDrainAndResume(state);
145- const task = await createSmokeTask(state.services.controlApi.baseUrl);
146- const taskRecord = await readSmokeTask(state.services.controlApi.baseUrl, task.taskId);
147- const afterQueue = await waitForQueueDepth(state.services.statusApi.baseUrl, 1);
148-
149- await stopService(state, "primary");
150-
151- const failover = await waitForFailover(state);
152-
153- return {
154- ok: true,
155- stateDir: state.stateDir,
156- initial,
157- drained,
158- task: {
159- create: task.response,
160- read: taskRecord,
161- taskId: task.taskId
162- },
163- afterQueue,
164- failover
165- };
166- } finally {
167- await stopStack(state);
168- }
169-}
170-
171-async function startStack(options) {
172- const stateDir = options.stateDir ?? (await createStateDir());
173- const state = {
174- stateDir,
175- stateFilePath: getStateFilePath(stateDir),
176- dbPath: join(stateDir, "data", "control-plane.sqlite"),
177- services: {}
178- };
179-
180- await mkdir(join(stateDir, "logs"), { recursive: true });
181- await mkdir(join(stateDir, "runtime"), { recursive: true });
182-
183- try {
184- if (!options.skipBuild) {
185- await buildDependencies();
186- }
187-
188- const ports = {
189- controlApi: await allocatePort(),
190- primary: await allocatePort(),
191- standby: await allocatePort(),
192- statusApi: await allocatePort()
193- };
194-
195- state.services.controlApi = await spawnService({
196- args: [
197- "scripts/smoke/control-api-local.mjs",
198- "--db",
199- state.dbPath,
200- "--host",
201- DEFAULT_HOST,
202- "--port",
203- String(ports.controlApi)
204- ],
205- baseUrl: `http://${DEFAULT_HOST}:${ports.controlApi}`,
206- command: "node",
207- logPath: join(stateDir, "logs", "control-api.log"),
208- name: "control-api"
209- });
210- await writeState(state);
211-
212- await waitForCondition(
213- async () => {
214- const envelope = await getJson(`${state.services.controlApi.baseUrl}/v1/system/state`);
215- return envelope.ok ? envelope : null;
216- },
217- { description: "control-api readiness" }
218- );
219-
220- state.services.primary = await spawnConductorService(state, {
221- host: "mini",
222- localApiPort: ports.primary,
223- name: "conductor-primary",
224- nodeId: PRIMARY_NODE_ID,
225- role: "primary"
226- });
227- await writeState(state);
228-
229- state.services.standby = await spawnConductorService(state, {
230- host: "mac",
231- localApiPort: ports.standby,
232- name: "conductor-standby",
233- nodeId: STANDBY_NODE_ID,
234- role: "standby"
235- });
236- await writeState(state);
237-
238- state.services.statusApi = await spawnService({
239- args: [
240- "scripts/smoke/status-api-local.mjs",
241- "--db",
242- state.dbPath,
243- "--host",
244- DEFAULT_HOST,
245- "--port",
246- String(ports.statusApi)
247- ],
248- baseUrl: `http://${DEFAULT_HOST}:${ports.statusApi}`,
249- command: "node",
250- logPath: join(stateDir, "logs", "status-api.log"),
251- name: "status-api"
252- });
253- await writeState(state);
254-
255- await waitForCondition(
256- async () => {
257- const text = await getText(`${state.services.statusApi.baseUrl}/healthz`);
258- return text === "ok" ? text : null;
259- },
260- { description: "status-api readiness" }
261- );
262-
263- await captureLiveStack(state, {
264- expectedLeader: PRIMARY_NODE_ID
265- });
266-
267- return state;
268- } catch (error) {
269- await stopStack(state);
270- throw error;
271- }
272-}
273-
274-async function spawnConductorService(state, options) {
275- const runtimeRoot = join(state.stateDir, "runtime", options.role);
276-
277- for (const directoryName of ["logs", "runs", "state", "tmp", "worktrees"]) {
278- await mkdir(join(runtimeRoot, directoryName), { recursive: true });
279- }
280-
281- const baseUrl = `http://${DEFAULT_HOST}:${options.localApiPort}`;
282- const service = await spawnService({
283- args: [
284- "--experimental-strip-types",
285- "apps/conductor-daemon/src/index.ts",
286- "start",
287- "--node-id",
288- options.nodeId,
289- "--host",
290- options.host,
291- "--role",
292- options.role,
293- "--control-api-base",
294- state.services.controlApi.baseUrl,
295- "--local-api",
296- baseUrl,
297- "--shared-token",
298- "smoke-shared-token",
299- "--heartbeat-interval-ms",
300- String(DEFAULT_HEARTBEAT_INTERVAL_MS),
301- "--lease-renew-interval-ms",
302- String(DEFAULT_LEASE_RENEW_INTERVAL_MS),
303- "--lease-ttl-sec",
304- String(DEFAULT_LEASE_TTL_SEC),
305- "--logs-dir",
306- join(runtimeRoot, "logs"),
307- "--runs-dir",
308- join(runtimeRoot, "runs"),
309- "--state-dir",
310- join(runtimeRoot, "state"),
311- "--tmp-dir",
312- join(runtimeRoot, "tmp"),
313- "--worktrees-dir",
314- join(runtimeRoot, "worktrees")
315- ],
316- baseUrl,
317- command: "node",
318- logPath: join(state.stateDir, "logs", `${options.name}.log`),
319- metadata: {
320- host: options.host,
321- nodeId: options.nodeId,
322- role: options.role
323- },
324- name: options.name
325- });
326-
327- await waitForCondition(
328- async () => {
329- const text = await getText(`${baseUrl}/healthz`);
330- return text === "ok" ? text : null;
331- },
332- { description: `${options.name} healthz` }
333- );
334-
335- return service;
336-}
337-
338-async function spawnService({ args, baseUrl, command, logPath, metadata = {}, name }) {
339- appendFileSync(logPath, `\n$ ${command} ${args.join(" ")}\n`);
340- const logFd = openSync(logPath, "a");
341- const child = spawn(command, args, {
342- cwd: REPO_ROOT,
343- env: process.env,
344- stdio: ["ignore", logFd, logFd]
345- });
346-
347- closeSync(logFd);
348-
349- if (child.pid == null) {
350- throw new Error(`Failed to start ${name}.`);
351- }
352-
353- return {
354- ...metadata,
355- baseUrl,
356- logPath,
357- name,
358- pid: child.pid
359- };
360-}
361-
362-async function captureLiveStack(state, { expectedLeader }) {
363- const [controlState, primary, standby, statusSnapshot] = await Promise.all([
364- readControlState(state.services.controlApi.baseUrl),
365- readConductorSurface(state.services.primary.baseUrl),
366- readConductorSurface(state.services.standby.baseUrl),
367- readStatusSnapshot(state.services.statusApi.baseUrl)
368- ]);
369-
370- if (expectedLeader) {
371- if (controlState.data.holder_id !== expectedLeader) {
372- throw new Error(
373- `Expected control-api leader to be ${expectedLeader}, received ${controlState.data.holder_id ?? "null"}.`
374- );
375- }
376-
377- if (statusSnapshot.data.leaderId !== expectedLeader) {
378- throw new Error(
379- `Expected status-api leader to be ${expectedLeader}, received ${statusSnapshot.data.leaderId ?? "null"}.`
380- );
381- }
382- }
383-
384- if (primary.role !== "leader") {
385- throw new Error(`Expected primary conductor rolez to be leader, received ${primary.role}.`);
386- }
387-
388- if (standby.role !== "standby") {
389- throw new Error(`Expected standby conductor rolez to be standby, received ${standby.role}.`);
390- }
391-
392- return {
393- controlState,
394- conductors: {
395- primary,
396- standby
397- },
398- statusSnapshot
399- };
400-}
401-
402-async function verifyDrainAndResume(state) {
403- const drainedResponse = await postJson(`${state.services.controlApi.baseUrl}/v1/system/drain`, {
404- reason: "verify shared state propagation",
405- requested_by: "smoke-harness"
406- });
407-
408- if (!drainedResponse.ok) {
409- throw new Error(`Expected drain request to succeed: ${JSON.stringify(drainedResponse)}`);
410- }
411-
412- const drainedStatus = await waitForCondition(
413- async () => {
414- const snapshot = await readStatusSnapshot(state.services.statusApi.baseUrl);
415- return snapshot.data.mode === "draining" ? snapshot : null;
416- },
417- { description: "status-api draining mode" }
418- );
419- const drainedControlState = await readControlState(state.services.controlApi.baseUrl);
420-
421- const resumedResponse = await postJson(`${state.services.controlApi.baseUrl}/v1/system/resume`, {
422- reason: "continue smoke flow",
423- requested_by: "smoke-harness"
424- });
425-
426- if (!resumedResponse.ok) {
427- throw new Error(`Expected resume request to succeed: ${JSON.stringify(resumedResponse)}`);
428- }
429-
430- const resumedStatus = await waitForCondition(
431- async () => {
432- const snapshot = await readStatusSnapshot(state.services.statusApi.baseUrl);
433- return snapshot.data.mode === "running" ? snapshot : null;
434- },
435- { description: "status-api running mode" }
436- );
437-
438- return {
439- drained: {
440- controlState: drainedControlState,
441- statusSnapshot: drainedStatus
442- },
443- resumed: {
444- controlState: await readControlState(state.services.controlApi.baseUrl),
445- statusSnapshot: resumedStatus
446- }
447- };
448-}
449-
450-async function createSmokeTask(controlApiBaseUrl) {
451- const response = await postJson(`${controlApiBaseUrl}/v1/tasks`, {
452- acceptance: ["status-api queue depth reflects the queued smoke task"],
453- goal: "Verify the local smoke harness can seed a queued task visible to status-api.",
454- repo: REPO_ROOT,
455- task_type: "smoke_harness",
456- title: "Smoke harness queue probe"
457- });
458-
459- if (!response.ok) {
460- throw new Error(`Expected task creation to succeed: ${JSON.stringify(response)}`);
461- }
462-
463- return {
464- response,
465- taskId: response.data.task_id
466- };
467-}
468-
469-async function readSmokeTask(controlApiBaseUrl, taskId) {
470- const response = await getJson(`${controlApiBaseUrl}/v1/tasks/${taskId}`);
471-
472- if (!response.ok) {
473- throw new Error(`Expected task read to succeed: ${JSON.stringify(response)}`);
474- }
475-
476- if (response.data.task_id !== taskId) {
477- throw new Error(`Expected task read to return ${taskId}, received ${response.data.task_id}.`);
478- }
479-
480- if (response.data.status !== "queued") {
481- throw new Error(`Expected task ${taskId} to be queued, received ${response.data.status}.`);
482- }
483-
484- return response;
485-}
486-
487-async function waitForQueueDepth(statusApiBaseUrl, minimumQueueDepth) {
488- return waitForCondition(
489- async () => {
490- const snapshot = await readStatusSnapshot(statusApiBaseUrl);
491- return snapshot.data.queueDepth >= minimumQueueDepth ? snapshot : null;
492- },
493- { description: `status-api queue depth >= ${minimumQueueDepth}` }
494- );
495-}
496-
497-async function waitForFailover(state) {
498- const standbyBaseUrl = state.services.standby.baseUrl;
499- const controlApiBaseUrl = state.services.controlApi.baseUrl;
500- const statusApiBaseUrl = state.services.statusApi.baseUrl;
501-
502- return waitForCondition(
503- async () => {
504- const [controlState, standby, statusSnapshot] = await Promise.all([
505- readControlState(controlApiBaseUrl),
506- readConductorSurface(standbyBaseUrl),
507- readStatusSnapshot(statusApiBaseUrl)
508- ]);
509-
510- if (
511- controlState.data.holder_id !== STANDBY_NODE_ID ||
512- statusSnapshot.data.leaderId !== STANDBY_NODE_ID ||
513- standby.role !== "leader" ||
514- standby.readyz !== "ready"
515- ) {
516- return null;
517- }
518-
519- return {
520- controlState,
521- conductors: {
522- standby
523- },
524- statusSnapshot
525- };
526- },
527- {
528- description: "standby failover",
529- timeoutMs: START_TIMEOUT_MS
530- }
531- );
532-}
533-
534-async function stopStack(state) {
535- await stopService(state, "primary");
536- await stopService(state, "standby");
537- await stopService(state, "statusApi");
538- await stopService(state, "controlApi");
539- await writeState(state);
540-}
541-
542-async function stopService(state, key) {
543- const service = state.services[key];
544-
545- if (!service || service.stoppedAt) {
546- return;
547- }
548-
549- if (!isProcessRunning(service.pid)) {
550- service.stoppedAt = new Date().toISOString();
551- return;
552- }
553-
554- process.kill(service.pid, "SIGTERM");
555-
556- try {
557- await waitForCondition(
558- async () => (!isProcessRunning(service.pid) ? true : null),
559- {
560- description: `${service.name} shutdown`,
561- intervalMs: 100,
562- timeoutMs: STOP_TIMEOUT_MS
563- }
564- );
565- } catch {
566- if (isProcessRunning(service.pid)) {
567- process.kill(service.pid, "SIGKILL");
568- await waitForCondition(
569- async () => (!isProcessRunning(service.pid) ? true : null),
570- {
571- description: `${service.name} forced shutdown`,
572- intervalMs: 100,
573- timeoutMs: STOP_TIMEOUT_MS
574- }
575- );
576- }
577- }
578-
579- service.stoppedAt = new Date().toISOString();
580-}
581-
582-function isProcessRunning(pid) {
583- if (pid == null) {
584- return false;
585- }
586-
587- try {
588- process.kill(pid, 0);
589- return true;
590- } catch (error) {
591- return !(error instanceof Error) || !("code" in error) || error.code !== "ESRCH";
592- }
593-}
594-
595-async function readControlState(controlApiBaseUrl) {
596- const response = await getJson(`${controlApiBaseUrl}/v1/system/state`);
597-
598- if (!response.ok) {
599- throw new Error(`Expected control-api system state to succeed: ${JSON.stringify(response)}`);
600- }
601-
602- return response;
603-}
604-
605-async function readStatusSnapshot(statusApiBaseUrl) {
606- const response = await getJson(`${statusApiBaseUrl}/v1/status`);
607-
608- if (!response.ok) {
609- throw new Error(`Expected status-api snapshot to succeed: ${JSON.stringify(response)}`);
610- }
611-
612- return response;
613-}
614-
615-async function readConductorSurface(conductorBaseUrl) {
616- const [healthz, readyz, role, runtime] = await Promise.all([
617- getText(`${conductorBaseUrl}/healthz`),
618- getText(`${conductorBaseUrl}/readyz`),
619- getText(`${conductorBaseUrl}/rolez`),
620- getJson(`${conductorBaseUrl}/v1/runtime`)
621- ]);
622-
623- if (!runtime.ok) {
624- throw new Error(`Expected conductor runtime probe to succeed: ${JSON.stringify(runtime)}`);
625- }
626-
627- return {
628- healthz,
629- readyz,
630- role,
631- runtime
632- };
633-}
634-
635-async function buildDependencies() {
636- await runCommand("npx", ["--yes", "pnpm", "--filter", "@baa-conductor/control-api-worker", "build"]);
637- await runCommand("npx", ["--yes", "pnpm", "--filter", "@baa-conductor/status-api", "build"]);
638-}
639-
640-async function runCommand(command, args) {
641- await new Promise((resolvePromise, reject) => {
642- const child = spawn(command, args, {
643- cwd: REPO_ROOT,
644- env: process.env,
645- stdio: ["ignore", "pipe", "pipe"]
646- });
647- let output = "";
648-
649- child.stdout?.on("data", (chunk) => {
650- output += chunk.toString();
651- });
652- child.stderr?.on("data", (chunk) => {
653- output += chunk.toString();
654- });
655- child.on("error", reject);
656- child.on("close", (code) => {
657- if (code === 0) {
658- resolvePromise();
659- return;
660- }
661-
662- reject(new Error(`Command failed: ${command} ${args.join(" ")}\n${output}`));
663- });
664- });
665-}
666-
667-async function waitForCondition(
668- check,
669- {
670- description,
671- intervalMs = POLL_INTERVAL_MS,
672- timeoutMs = START_TIMEOUT_MS
673- }
674-) {
675- const deadline = Date.now() + timeoutMs;
676- let lastError = null;
677-
678- while (Date.now() <= deadline) {
679- try {
680- const result = await check();
681-
682- if (result) {
683- return result;
684- }
685- } catch (error) {
686- lastError = error;
687- }
688-
689- await delay(intervalMs);
690- }
691-
692- const suffix =
693- lastError == null
694- ? ""
695- : `: ${lastError instanceof Error ? lastError.message : String(lastError)}`;
696- throw new Error(`Timed out waiting for ${description}${suffix}`);
697-}
698-
699-async function getJson(url, init) {
700- const response = await fetch(url, {
701- ...init,
702- headers: {
703- "content-type": "application/json",
704- ...(init?.headers ?? {})
705- },
706- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
707- });
708- const text = await response.text();
709-
710- try {
711- return JSON.parse(text);
712- } catch {
713- throw new Error(`Expected JSON from ${url}, received status ${response.status}: ${text}`);
714- }
715-}
716-
717-async function getText(url) {
718- const response = await fetch(url, {
719- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
720- });
721- const text = (await response.text()).trim();
722-
723- if (!response.ok) {
724- throw new Error(`Expected ${url} to return 2xx, received ${response.status}: ${text}`);
725- }
726-
727- return text;
728-}
729-
730-async function postJson(url, body) {
731- return getJson(url, {
732- body: JSON.stringify(body),
733- method: "POST"
734- });
735-}
736-
737-async function allocatePort() {
738- return new Promise((resolvePromise, reject) => {
739- const server = createServer();
740-
741- server.once("error", reject);
742- server.listen(0, DEFAULT_HOST, () => {
743- const address = server.address();
744-
745- if (!address || typeof address === "string") {
746- reject(new Error("Failed to allocate a TCP port."));
747- return;
748- }
749-
750- server.close((error) => {
751- if (error) {
752- reject(error);
753- return;
754- }
755-
756- resolvePromise(address.port);
757- });
758- });
759- });
760-}
761-
762-async function createStateDir() {
763- const tmpRoot = join(REPO_ROOT, "tmp");
764- await mkdir(tmpRoot, { recursive: true });
765- return mkdtemp(join(tmpRoot, "smoke-"));
766-}
767-
768-function getStateFilePath(stateDir) {
769- return join(stateDir, "stack-state.json");
770-}
771-
772-async function writeState(state) {
773- await writeFile(state.stateFilePath, JSON.stringify(state, null, 2));
774-}
775-
776-async function loadState(options) {
777- const stateDir = options.stateDir;
778-
779- if (!stateDir) {
780- throw new Error("--state-dir is required for check and stop commands.");
781- }
782-
783- const content = await readFile(getStateFilePath(stateDir), "utf8");
784- return JSON.parse(content);
785-}
786-
787-function summarizeStateServices(state) {
788- return Object.fromEntries(
789- Object.entries(state.services).map(([key, value]) => [
790- key,
791- {
792- baseUrl: value.baseUrl,
793- logPath: value.logPath,
794- pid: value.pid
795- }
796- ])
797- );
798-}
799-
800-function writeResult(options, value) {
801- if (typeof value === "string") {
802- process.stdout.write(value.endsWith("\n") ? value : `${value}\n`);
803- return;
804- }
805-
806- process.stdout.write(`${JSON.stringify(value, null, options.json ? 2 : 2)}\n`);
807-}
808-
809-function renderHelp() {
810- return [
811- "Usage:",
812- " node scripts/smoke/stack-cli.mjs [run|start|check|stop] [options]",
813- "",
814- "Commands:",
815- " run Build, start, probe, create a queued task, and fail over to standby.",
816- " start Build and start the local smoke stack, then write stack-state.json.",
817- " check Probe an existing stack-state.json and print the current surface snapshot.",
818- " stop Stop the processes recorded in stack-state.json.",
819- "",
820- "Options:",
821- " --state-dir <path> Reuse or write a specific smoke state directory.",
822- " --expected-leader <id> Assert the current leader for the check command.",
823- " --skip-build Skip the control-api/status-api build step.",
824- " --json Print JSON output.",
825- " --help, -h Show this help text."
826- ].join("\n");
827-}
828-
829-await main().catch((error) => {
830- process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
831- process.exitCode = 1;
832-});
+0,
-6
1@@ -1,6 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6-
7-exec node "$ROOT_DIR/scripts/smoke/stack-cli.mjs" start "$@"
+0,
-92
1@@ -1,92 +0,0 @@
2-import { D1StatusSnapshotLoader, startStatusApiServer } from "../../apps/status-api/dist/index.js";
3-import { createSqliteD1Database } from "./d1-sqlite.mjs";
4-
5-function parseArgs(argv) {
6- const options = {
7- databasePath: null,
8- host: "127.0.0.1",
9- port: null
10- };
11-
12- for (let index = 0; index < argv.length; index += 1) {
13- const token = argv[index];
14-
15- switch (token) {
16- case "--db":
17- options.databasePath = readValue(argv, token, index);
18- index += 1;
19- break;
20- case "--host":
21- options.host = readValue(argv, token, index);
22- index += 1;
23- break;
24- case "--port":
25- options.port = parsePort(readValue(argv, token, index), token);
26- index += 1;
27- break;
28- default:
29- throw new Error(`Unknown status-api-local option "${token}".`);
30- }
31- }
32-
33- if (!options.databasePath) {
34- throw new Error("--db is required.");
35- }
36-
37- if (options.port == null) {
38- throw new Error("--port is required.");
39- }
40-
41- return options;
42-}
43-
44-function readValue(tokens, flag, index) {
45- const value = tokens[index + 1];
46-
47- if (!value || value.startsWith("--")) {
48- throw new Error(`Missing value for ${flag}.`);
49- }
50-
51- return value;
52-}
53-
54-function parsePort(value, flag) {
55- const port = Number(value);
56-
57- if (!Number.isInteger(port) || port < 0 || port > 65_535) {
58- throw new Error(`Invalid value for ${flag}: "${value}".`);
59- }
60-
61- return port;
62-}
63-
64-async function main() {
65- const options = parseArgs(process.argv.slice(2));
66- const db = createSqliteD1Database(options.databasePath);
67- const server = await startStatusApiServer({
68- host: options.host,
69- port: options.port,
70- snapshotLoader: new D1StatusSnapshotLoader(db)
71- });
72-
73- const close = async () => {
74- await server.close();
75- db.close();
76- };
77-
78- const shutdown = (signal) => {
79- void close().finally(() => {
80- process.exitCode = 0;
81- if (signal) {
82- process.stderr.write(`status-api-local stopped after ${signal}\n`);
83- }
84- });
85- };
86-
87- process.once("SIGINT", () => shutdown("SIGINT"));
88- process.once("SIGTERM", () => shutdown("SIGTERM"));
89-
90- process.stdout.write(`status-api-local listening on ${server.getBaseUrl()}\n`);
91-}
92-
93-await main();
+0,
-6
1@@ -1,6 +0,0 @@
2-#!/usr/bin/env bash
3-set -euo pipefail
4-
5-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6-
7-exec node "$ROOT_DIR/scripts/smoke/stack-cli.mjs" stop "$@"
+0,
-46
1@@ -1,46 +0,0 @@
2-import assert from "node:assert/strict";
3-import { execFile } from "node:child_process";
4-import { rm } from "node:fs/promises";
5-import { promisify } from "node:util";
6-import { dirname, join, resolve } from "node:path";
7-import test from "node:test";
8-import { fileURLToPath } from "node:url";
9-
10-const execFileAsync = promisify(execFile);
11-const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
12-
13-test("smoke harness starts the local stack, reflects queue state, and fails over to standby", async () => {
14- const stateDir = join(REPO_ROOT, "tmp", `e2e-smoke-test-${Date.now()}`);
15- let shouldCleanup = true;
16-
17- try {
18- const { stdout } = await execFileAsync(
19- "bash",
20- ["scripts/smoke/run-e2e.sh", "--json", "--state-dir", stateDir],
21- {
22- cwd: REPO_ROOT,
23- maxBuffer: 10 * 1024 * 1024,
24- timeout: 120_000
25- }
26- );
27- const result = JSON.parse(stdout);
28-
29- assert.equal(result.ok, true);
30- assert.equal(result.initial.controlState.data.holder_id, "smoke-mini");
31- assert.equal(result.initial.statusSnapshot.data.leaderId, "smoke-mini");
32- assert.equal(result.drained.drained.statusSnapshot.data.mode, "draining");
33- assert.equal(result.drained.resumed.statusSnapshot.data.mode, "running");
34- assert.equal(result.task.read.data.status, "queued");
35- assert.equal(result.afterQueue.data.queueDepth, 1);
36- assert.equal(result.failover.controlState.data.holder_id, "smoke-mac");
37- assert.equal(result.failover.statusSnapshot.data.leaderId, "smoke-mac");
38- assert.equal(result.failover.conductors.standby.role, "leader");
39- } catch (error) {
40- shouldCleanup = false;
41- throw error;
42- } finally {
43- if (shouldCleanup) {
44- await rm(stateDir, { force: true, recursive: true });
45- }
46- }
47-});