baa-conductor

git clone 

commit
7fd959d
parent
088343b
author
im_wower
date
2026-03-22 01:25:33 +0800 CST
Integrate T-021 runtime bootstrap
13 files changed,  +1139, -97
M coordination/tasks/T-021-runtime-bootstrap.md
+32, -8
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-021
 4 title: launchd 安装脚本与 Runtime Bootstrap
 5-status: todo
 6+status: review
 7 branch: feat/T-021-runtime-bootstrap
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@458d7cf
11 depends_on:
12   - T-011
13   - T-013
14@@ -14,7 +14,7 @@ write_scope:
15   - ops/launchd/**
16   - docs/runtime/**
17   - scripts/runtime/**
18-updated_at: 2026-03-22
19+updated_at: 2026-03-22T01:08:59+0800
20 ---
21 
22 # T-021 launchd 安装脚本与 Runtime Bootstrap
23@@ -69,23 +69,47 @@ updated_at: 2026-03-22
24 
25 ## files_changed
26 
27-- 待填写
28+- `coordination/tasks/T-021-runtime-bootstrap.md`
29+- `docs/runtime/README.md`
30+- `docs/runtime/environment.md`
31+- `docs/runtime/launchd.md`
32+- `docs/runtime/layout.md`
33+- `ops/launchd/so.makefile.baa-conductor.plist`
34+- `ops/launchd/so.makefile.baa-worker-runner.plist`
35+- `ops/launchd/so.makefile.baa-status-api.plist`
36+- `scripts/runtime/bootstrap.sh`
37+- `scripts/runtime/check-launchd.sh`
38+- `scripts/runtime/common.sh`
39+- `scripts/runtime/install-launchd.sh`
40+- `scripts/runtime/reload-launchd.sh`
41 
42 ## commands_run
43 
44-- 待填写
45+- `npx --yes pnpm install`
46+- `chmod +x scripts/runtime/bootstrap.sh scripts/runtime/install-launchd.sh scripts/runtime/reload-launchd.sh scripts/runtime/check-launchd.sh`
47+- `bash -n scripts/runtime/*.sh`
48+- `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`
49+- `git diff --check`
50+- `./scripts/runtime/bootstrap.sh --repo-dir "$TMP_REPO"`
51+- `./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`
52+- `./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`
53+- `./scripts/runtime/reload-launchd.sh --install-dir "$TMP_INSTALL" --all-services --dry-run`
54 
55 ## result
56 
57-- 待填写
58+- 已新增 `scripts/runtime/bootstrap.sh`、`install-launchd.sh`、`check-launchd.sh`、`reload-launchd.sh` 与共享 helper,使 runtime 目录初始化、plist 安装副本渲染、静态校验、`launchctl` 重载都有可执行脚本。
59+- 已把 `docs/runtime/**` 改为以脚本驱动流程为准,补充 `--node`、`--repo-dir`、`--shared-token`、`--scope` 等输入约定,并明确默认只安装 `conductor`、其它模板需显式 opt-in。
60+- 已小幅修正 `ops/launchd/*.plist` 说明并补入 `BAA_STATE_DIR`,同时在临时 repo 上完成 bootstrap -> install -> check -> reload(dry-run) 的整链验证。
61 
62 ## risks
63 
64-- 待填写
65+- 本任务只验证了 plist 渲染、静态检查和 dry-run `launchctl` 命令,没有在本机真实 `bootstrap` 任何 launchd 服务;实际目标机上仍需再做一次权限和 domain 验证。
66+- `worker-runner` 与 `status-api` 模板现在可以被脚本渲染和校验,但是否作为常驻服务启用仍取决于各自宿主进程接线进度;默认流程因此只安装 `conductor`。
67 
68 ## next_handoff
69 
70-- 待填写
71+- 在真实 `mini` / `mac` 节点上按文档顺序执行 `bootstrap.sh`、`npx --yes pnpm -r build`、`install-launchd.sh`、`check-launchd.sh`,确认安装副本和 runtime 目录都落在预期路径。
72+- 真正准备加载服务时,先运行 `reload-launchd.sh --dry-run` 复核命令;确认无误后再去掉 `--dry-run`。若后续 runner/status 宿主进程落地,可再显式加 `--service worker-runner` 或 `--service status-api`。
73 
74 开始时建议直接把 `status` 改为 `in_progress`。
75 
M docs/runtime/README.md
+16, -7
 1@@ -1,6 +1,6 @@
 2 # runtime
 3 
 4-本目录定义 `mini` 与 `mac` 上的本地 runtime 约定:目录布局、环境变量和 `launchd` 安装方式。
 5+本目录定义 `mini` 与 `mac` 上的本地 runtime 约定:目录布局、环境变量,以及 `scripts/runtime/*.sh` 驱动的 `launchd` 安装方式。
 6 
 7 当前仓库已经把 app 级 `build` 从单纯 typecheck 推进到真实 emit。执行 `npx --yes pnpm -r build` 后,`apps/*/dist/index.js` 会生成,`ops/launchd/*.plist` 里的入口路径也因此固定下来。
 8 
 9@@ -9,19 +9,28 @@
10 ## 内容
11 
12 - [`layout.md`](./layout.md): `runs/`、`worktrees/`、`logs/`、`tmp/` 与 `state/` 的初始化方式和生命周期
13-- [`environment.md`](./environment.md): `launchd` 下必须显式写入的环境变量
14-- [`launchd.md`](./launchd.md): `mini` 与 `mac` 的安装步骤,以及 `LaunchAgents` / `LaunchDaemons` 的差异
15+- [`environment.md`](./environment.md): `launchd` 下必须显式写入的环境变量,以及安装脚本如何覆盖默认值
16+- [`launchd.md`](./launchd.md): `mini` 与 `mac` 的脚本化安装步骤,以及 `LaunchAgents` / `LaunchDaemons` 的差异
17 
18 ## 统一约定
19 
20 - `mini` 是首选 leader,默认跑 `so.makefile.baa-conductor` 的 `primary` 配置。
21 - `mac` 使用同一组服务,但 conductor 默认角色是 `standby`。
22 - 两台机器都推荐把仓库放在 `/Users/george/code/baa-conductor`,这样 repo 内的 plist 源模板可以直接复用设计里的绝对路径。
23-- repo 中的 plist 只作为源模板;真正加载的是复制到 `~/Library/LaunchAgents/` 或 `/Library/LaunchDaemons/` 的副本。
24+- repo 中的 plist 只作为源模板;真正加载的是 `scripts/runtime/install-launchd.sh` 复制并改写到 `~/Library/LaunchAgents/` 或 `/Library/LaunchDaemons/` 的副本。
25 
26 ## 最短路径
27 
28-1. 先按 [`layout.md`](./layout.md) 初始化 runtime 根目录。
29+1. 先按 [`layout.md`](./layout.md) 运行 `./scripts/runtime/bootstrap.sh` 初始化 runtime 根目录。
30 2. 再按 [`environment.md`](./environment.md) 准备共享变量和节点变量,特别是 `BAA_SHARED_TOKEN`。
31-3. 最后按 [`launchd.md`](./launchd.md) 复制、调整并加载 plist。
32-4. 每次准备加载或重载本地服务前,先在 repo 根目录执行一次 `npx --yes pnpm -r build`,确认目标 app 的 `dist/index.js` 已更新。
33+3. 按 [`launchd.md`](./launchd.md) 运行 `install-launchd.sh` 生成安装副本,再用 `check-launchd.sh` / `reload-launchd.sh` 校验与重载。
34+4. 每次准备执行 `check-launchd.sh` 的 dist 校验或真正 `launchctl bootstrap` 前,先在 repo 根目录执行一次 `npx --yes pnpm -r build`,确认目标 app 的 `dist/index.js` 已更新。
35+
36+## 当前脚本集
37+
38+- `scripts/runtime/bootstrap.sh`: 预创建 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`tmp/`
39+- `scripts/runtime/install-launchd.sh`: 从 `ops/launchd/*.plist` 渲染实际安装副本
40+- `scripts/runtime/check-launchd.sh`: 校验源模板、runtime 目录、构建产物,以及已安装的 plist 副本
41+- `scripts/runtime/reload-launchd.sh`: 执行或 dry-run `launchctl bootout/bootstrap/kickstart`
42+
43+默认安装/检查/重载集合只包含 `conductor`。如果后续要把其它模板也纳入流程,显式加 `--service worker-runner`、`--service status-api`,或直接使用 `--all-services`。
M docs/runtime/environment.md
+23, -1
 1@@ -19,10 +19,11 @@
 2 | `BAA_WORKTREES_DIR` | `/Users/george/code/baa-conductor/worktrees` | `/Users/george/code/baa-conductor/worktrees` | task worktree 根目录 |
 3 | `BAA_LOGS_DIR` | `/Users/george/code/baa-conductor/logs` | `/Users/george/code/baa-conductor/logs` | 服务日志根目录 |
 4 | `BAA_TMP_DIR` | `/Users/george/code/baa-conductor/tmp` | `/Users/george/code/baa-conductor/tmp` | 临时目录根 |
 5+| `BAA_STATE_DIR` | `/Users/george/code/baa-conductor/state` | `/Users/george/code/baa-conductor/state` | 本地状态镜像目录 |
 6 | `BAA_NODE_ID` | `mini-main` | `mac-standby` | 节点实例 ID;应稳定且可用于 lease/日志归因 |
 7 | `BAA_SHARED_TOKEN` | 安装时注入 | 安装时注入 | 节点到 control API 的共享认证口令 |
 8 
 9-当前设计已经把 `state/` 纳入本地目录布局,但还没有单独定义 `BAA_STATE_DIR`。在真实 daemon 代码需要它之前,保持 `state/` 固定在 repo 根目录下即可。
10+`scripts/runtime/install-launchd.sh` 会把这些路径写进安装副本;如果 repo 根目录不在 `/Users/george/code/baa-conductor`,直接通过 `--repo-dir` 派生新的路径,不需要再手工改每个 plist。
11 
12 ## launchd 辅助变量
13 
14@@ -35,6 +36,18 @@
15 
16 这些变量属于 `launchd` 安装层面的补充,不代表当前应用代码已经消费了全部字段。模板里统一保留它们,是为了避免后续服务落地时再拆分不同的 plist 版本。
17 
18+`install-launchd.sh` 会同时根据 `--home-dir` 重写 `HOME` 和 `PATH`,避免模板里残留固定的 `/Users/george`。
19+
20+## 脚本输入约定
21+
22+`scripts/runtime/install-launchd.sh` / `check-launchd.sh` 目前主要消费这些输入:
23+
24+- `--node mini|mac`:决定 `BAA_CONDUCTOR_HOST`、`BAA_CONDUCTOR_ROLE`、`BAA_NODE_ID`
25+- `--repo-dir PATH`:决定 `WorkingDirectory`、`BAA_*_DIR` 与 `ProgramArguments` 里的 dist 入口
26+- `--home-dir PATH`:决定 `HOME`、`PATH` 与默认 `~/Library/LaunchAgents`
27+- `--shared-token`、`--shared-token-file` 或环境变量 `BAA_SHARED_TOKEN`:写入或校验 `BAA_SHARED_TOKEN`
28+- `--control-api-base`、`--local-api-base`:覆盖默认控制平面地址
29+
30 ## 节点最小差异集
31 
32 如果两台机器的 repo 路径一致,`mac` 相对 `mini` 最少只要改这三项:
33@@ -56,3 +69,12 @@ BAA_NODE_ID=mac-standby
34 ```text
35 --host mac --role standby
36 ```
37+
38+脚本化安装时,这些差异不需要手工改 plist;改成:
39+
40+```bash
41+BAA_SHARED_TOKEN='replace-with-real-token'
42+./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac
43+```
44+
45+即可把安装副本改成 `mac` 的节点身份。
M docs/runtime/launchd.md
+102, -60
  1@@ -2,11 +2,19 @@
  2 
  3 ## 服务集合
  4 
  5-推荐长期守护的本地服务:
  6+repo 里保留了三个源模板:
  7 
  8 - `so.makefile.baa-conductor`
  9 - `so.makefile.baa-worker-runner`
 10-- 可选 `so.makefile.baa-status-api`
 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 repo 内的三个 plist 源模板都默认写成 `mini` 的 canonical 配置:
 22 
 23@@ -21,6 +29,8 @@ repo 内的三个 plist 源模板都默认写成 `mini` 的 canonical 配置:
 24 - `logs/launchd/` 已按 [`layout.md`](./layout.md) 创建
 25 - 如果 repo 不在 `/Users/george/code/baa-conductor`,同步改 `WorkingDirectory`、`ProgramArguments`、`HOME`、`StandardOutPath`、`StandardErrorPath`
 26 
 27+实际安装时推荐让脚本做这些改写,而不是手工编辑 plist。
 28+
 29 ## `LaunchAgents` 与 `LaunchDaemons`
 30 
 31 | 目标路径 | 适合场景 | 优点 | 额外要求 |
 32@@ -32,104 +42,126 @@ repo 内的三个 plist 源模板都默认写成 `mini` 的 canonical 配置:
 33 
 34 ## mini 安装
 35 
 36-`mini` 可以直接复用 repo 模板的大部分默认值,只需要先在复制后的副本里替换真实 token,并确认 control API 与本地端口设置正确。
 37+`mini` 可以直接复用 repo 模板的大部分默认值。推荐顺序如下。
 38 
 39-### 1. 复制模板
 40+### 1. 初始化 runtime 根目录
 41 
 42 ```bash
 43 REPO_DIR=/Users/george/code/baa-conductor
 44-AGENTS_DIR="$HOME/Library/LaunchAgents"
 45-
 46-install -d "$AGENTS_DIR"
 47-cp "$REPO_DIR/ops/launchd/so.makefile.baa-conductor.plist" "$AGENTS_DIR/"
 48-cp "$REPO_DIR/ops/launchd/so.makefile.baa-worker-runner.plist" "$AGENTS_DIR/"
 49-cp "$REPO_DIR/ops/launchd/so.makefile.baa-status-api.plist" "$AGENTS_DIR/"
 50+./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
 51 ```
 52 
 53-### 2. 修改安装副本
 54+### 2. 构建 dist 入口
 55 
 56-最少检查这些字段:
 57+```bash
 58+cd /Users/george/code/baa-conductor
 59+npx --yes pnpm -r build
 60+```
 61 
 62-- 所有 plist 里的 `BAA_SHARED_TOKEN`
 63-- 所有 plist 里的 `BAA_CONTROL_API_BASE`
 64-- 所有 plist 里的 `BAA_CONDUCTOR_LOCAL_API`
 65+### 3. 渲染安装副本
 66 
 67-如果 `status-api` 暂时不部署,可以删掉或不加载 `so.makefile.baa-status-api.plist`。
 68+```bash
 69+REPO_DIR=/Users/george/code/baa-conductor
 70+export BAA_SHARED_TOKEN='replace-with-real-token'
 71 
 72-### 3. 加载服务
 73+./scripts/runtime/install-launchd.sh \
 74+  --repo-dir "$REPO_DIR" \
 75+  --node mini
 76+```
 77+
 78+如果要同时渲染 runner/status 模板:
 79 
 80 ```bash
 81-launchctl bootout "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-conductor.plist" 2>/dev/null || true
 82-launchctl bootout "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-worker-runner.plist" 2>/dev/null || true
 83-launchctl bootout "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-status-api.plist" 2>/dev/null || true
 84+./scripts/runtime/install-launchd.sh \
 85+  --repo-dir "$REPO_DIR" \
 86+  --node mini \
 87+  --all-services
 88+```
 89 
 90-launchctl bootstrap "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-conductor.plist"
 91-launchctl bootstrap "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-worker-runner.plist"
 92-launchctl bootstrap "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-status-api.plist"
 93+### 4. 静态校验安装副本
 94 
 95-launchctl kickstart -k "gui/$(id -u)/so.makefile.baa-conductor"
 96-launchctl kickstart -k "gui/$(id -u)/so.makefile.baa-worker-runner"
 97-launchctl kickstart -k "gui/$(id -u)/so.makefile.baa-status-api"
 98-```
 99+```bash
100+REPO_DIR=/Users/george/code/baa-conductor
101+AGENTS_DIR="$HOME/Library/LaunchAgents"
102 
103-如果 `status-api` 没启用,把对应三行删掉即可。
104+./scripts/runtime/check-launchd.sh \
105+  --repo-dir "$REPO_DIR" \
106+  --node mini \
107+  --install-dir "$AGENTS_DIR"
108+```
109 
110-## mac 安装
111+### 5. 预览或执行重载
112 
113-`mac` 与 `mini` 用同一组模板,但需要先把节点身份改成 standby。
114+```bash
115+./scripts/runtime/reload-launchd.sh --dry-run
116+```
117 
118-### 1. 复制模板
119+确认输出无误后,再去掉 `--dry-run` 真正执行。
120 
121-复制命令与 `mini` 相同,只是目标机器改成 `mac` 本机的 `~/Library/LaunchAgents/`。
122+## mac 安装
123 
124-### 2. 修改节点差异项
125+`mac` 与 `mini` 用同一组模板,但节点身份切到 standby。
126 
127-在三个 plist 的 `EnvironmentVariables` 里统一改:
128+### 1. 初始化 runtime 根目录与构建
129 
130-- `BAA_CONDUCTOR_HOST=mac`
131-- `BAA_CONDUCTOR_ROLE=standby`
132-- `BAA_NODE_ID=mac-standby`
133-- `BAA_SHARED_TOKEN=<真实共享口令>`
134+```bash
135+REPO_DIR=/Users/george/code/baa-conductor
136+./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
137+cd "$REPO_DIR"
138+npx --yes pnpm -r build
139+```
140 
141-只在 conductor plist 里额外改:
142+### 2. 渲染 `mac` 安装副本
143 
144-- `--host mini` -> `--host mac`
145-- `--role primary` -> `--role standby`
146+```bash
147+REPO_DIR=/Users/george/code/baa-conductor
148+export BAA_SHARED_TOKEN='replace-with-real-token'
149 
150-如果 `mac` 的 repo 路径、home 目录或本地端口与 `mini` 不同,也在复制后的副本里同步改绝对路径与 `BAA_CONDUCTOR_LOCAL_API`。
151+./scripts/runtime/install-launchd.sh \
152+  --repo-dir "$REPO_DIR" \
153+  --node mac
154+```
155 
156 ### 3. 加载服务
157 
158-`launchctl bootstrap` 与 `kickstart` 命令和 `mini` 完全相同,只是执行机器换成 `mac`。
159+静态校验和重载命令与 `mini` 相同,只是把 `--node mini` 换成 `--node mac`。
160 
161 ## 切到 `LaunchDaemons`
162 
163-如果需要登录前启动,流程改成:
164+如果需要登录前启动,脚本流程改成:
165 
166-1. 把编辑好的 plist 复制到 `/Library/LaunchDaemons/`
167-2. 在每个 plist 中增加 `UserName`,例如 `george`
168-3. 设置权限为 `root:wheel` 和 `0644`
169-4. 用 `sudo launchctl bootstrap system ...`
170+1. 用 `bootstrap.sh` 先准备 runtime 根目录
171+2. 用 `install-launchd.sh --scope daemon --username <user>` 渲染到 `/Library/LaunchDaemons/`
172+3. 如有需要,再手工 `sudo chown root:wheel /Library/LaunchDaemons/*.plist`
173+4. 用 `reload-launchd.sh --scope daemon` 或 `launchctl print system/...`
174 
175 示例:
176 
177 ```bash
178-PLIST_SRC="$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"
179-
180-sudo cp "$PLIST_SRC" /Library/LaunchDaemons/
181-sudo chown root:wheel /Library/LaunchDaemons/so.makefile.baa-conductor.plist
182-sudo chmod 644 /Library/LaunchDaemons/so.makefile.baa-conductor.plist
183-
184-sudo launchctl bootout system /Library/LaunchDaemons/so.makefile.baa-conductor.plist 2>/dev/null || true
185-sudo launchctl bootstrap system /Library/LaunchDaemons/so.makefile.baa-conductor.plist
186-sudo launchctl kickstart -k system/so.makefile.baa-conductor
187+REPO_DIR=/Users/george/code/baa-conductor
188+export BAA_SHARED_TOKEN='replace-with-real-token'
189+
190+sudo ./scripts/runtime/install-launchd.sh \
191+  --repo-dir "$REPO_DIR" \
192+  --scope daemon \
193+  --node mini \
194+  --username george
195+
196+sudo ./scripts/runtime/check-launchd.sh \
197+  --repo-dir "$REPO_DIR" \
198+  --scope daemon \
199+  --node mini \
200+  --install-dir /Library/LaunchDaemons \
201+  --username george
202+
203+sudo ./scripts/runtime/reload-launchd.sh --scope daemon --dry-run
204 ```
205 
206 使用 `LaunchDaemons` 时尤其要注意:
207 
208 - plist 里的 `HOME`、repo 路径和日志路径不能依赖当前登录用户的 shell
209 - runtime 根目录必须对 `UserName` 指定的账号可写
210-- 不要把 repo 源模板直接 `sudo cp` 过去后立刻加载,先改安装副本
211+- 不要把 repo 源模板直接 `sudo cp` 过去后立刻加载,先通过安装脚本生成副本
212 
213 ## 校验与排障
214 
215@@ -141,6 +173,16 @@ plutil -lint ops/launchd/so.makefile.baa-worker-runner.plist
216 plutil -lint ops/launchd/so.makefile.baa-status-api.plist
217 ```
218 
219+脚本化校验:
220+
221+```bash
222+./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mini
223+./scripts/runtime/check-launchd.sh \
224+  --repo-dir /Users/george/code/baa-conductor \
225+  --node mini \
226+  --install-dir "$HOME/Library/LaunchAgents"
227+```
228+
229 运行时排障常用命令:
230 
231 ```bash
232@@ -150,7 +192,7 @@ tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-conduct
233 tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-worker-runner.err.log
234 ```
235 
236-当前仓库已经能为 app 生成基础 `dist/index.js` 产物,因此 launchd 不再依赖“未来某天才会出现的入口文件”。在执行 `launchctl bootstrap` 之前,先在 repo 根目录跑一次:
237+当前仓库已经能为 app 生成基础 `dist/index.js` 产物,因此 launchd 不再依赖“未来某天才会出现的入口文件”。在执行 `check-launchd.sh` 的 dist 校验或真正 `launchctl bootstrap` 之前,先在 repo 根目录跑一次:
238 
239 ```bash
240 npx --yes pnpm -r build
241@@ -158,4 +200,4 @@ npx --yes pnpm -r build
242 
243 这样可以确保 plist 指向的 `apps/*/dist/index.js` 已刷新到最新代码。
244 
245-需要注意的是,第三波后续任务还会继续补各服务的真正运行时接线,所以“入口文件存在”不等于“业务逻辑已经全部落地”。这里解决的是部署路径与构建产物的一致性。
246+需要注意的是,当前脚本解决的是 runtime 目录、plist 渲染、静态检查与 `launchctl` 重载流程;它不替代各服务自己的运行时接线。这里解决的是部署路径与构建产物的一致性,而不是把所有业务进程都变成完整生产守护进程。
M docs/runtime/layout.md
+12, -21
 1@@ -26,40 +26,31 @@
 2 
 3 ## 一次性初始化
 4 
 5-在两台机器上都先创建 runtime 根目录:
 6+优先直接运行仓库内脚本:
 7 
 8 ```bash
 9 REPO_DIR=/Users/george/code/baa-conductor
10-
11-install -d -m 700 \
12-  "$REPO_DIR/state" \
13-  "$REPO_DIR/runs" \
14-  "$REPO_DIR/worktrees" \
15-  "$REPO_DIR/logs" \
16-  "$REPO_DIR/logs/launchd" \
17-  "$REPO_DIR/tmp"
18+./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
19 ```
20 
21-如果服务通过 `LaunchDaemons` 以专门账号运行,再把这些目录的 owner 改成该账号,而不是当前登录用户:
22+如果服务通过 `LaunchDaemons` 以专门账号运行,可以在初始化时直接带 owner:
23 
24 ```bash
25-sudo chown -R george:staff \
26-  "$REPO_DIR/state" \
27-  "$REPO_DIR/runs" \
28-  "$REPO_DIR/worktrees" \
29-  "$REPO_DIR/logs" \
30-  "$REPO_DIR/tmp"
31+REPO_DIR=/Users/george/code/baa-conductor
32+sudo ./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR" --owner george:staff
33 ```
34 
35+脚本本质上仍然只是执行 `install -d -m 700`,不会加载 `launchd` 服务,也不会修改 repo 之外的配置。
36+
37 ## 目录职责
38 
39 | 路径 | 初始化方式 | 后续创建者 | 说明 |
40 | --- | --- | --- | --- |
41-| `state/` | 手工预创建根目录 | conductor | 本地状态镜像,便于恢复与对账 |
42-| `runs/` | 手工预创建根目录 | conductor/worker | 每个 run 在 `runs/<task-id>/<run-id>/` 下落本地元数据与日志 |
43-| `worktrees/` | 手工预创建根目录 | conductor | 每个 task 一个确定性的 worktree,worker 不负责清理 |
44-| `logs/` | 手工预创建根目录 | `launchd` 与服务进程 | `logs/launchd/` 放服务 stdout/stderr,其它运行期日志仍按 run 归档 |
45-| `tmp/` | 手工预创建根目录 | conductor/worker | 临时文件和中间产物,整机停服后才允许清空 |
46+| `state/` | `bootstrap.sh` 预创建根目录 | conductor | 本地状态镜像,便于恢复与对账 |
47+| `runs/` | `bootstrap.sh` 预创建根目录 | conductor/worker | 每个 run 在 `runs/<task-id>/<run-id>/` 下落本地元数据与日志 |
48+| `worktrees/` | `bootstrap.sh` 预创建根目录 | conductor | 每个 task 一个确定性的 worktree,worker 不负责清理 |
49+| `logs/` | `bootstrap.sh` 预创建根目录 | `launchd` 与服务进程 | `logs/launchd/` 放服务 stdout/stderr,其它运行期日志仍按 run 归档 |
50+| `tmp/` | `bootstrap.sh` 预创建根目录 | conductor/worker | 临时文件和中间产物,整机停服后才允许清空 |
51 
52 ## `runs/` 的细化约定
53 
M ops/launchd/so.makefile.baa-conductor.plist
+3, -0
 1@@ -3,6 +3,7 @@
 2 <!--
 3   Source template kept in the repo.
 4   Default values target the mini node at /Users/george/code/baa-conductor.
 5+  Use scripts/runtime/install-launchd.sh to render the actual install copy.
 6   For mac, change BAA_CONDUCTOR_HOST, BAA_CONDUCTOR_ROLE, BAA_NODE_ID,
 7   BAA_SHARED_TOKEN, and keep the CLI args aligned with those values.
 8   If this file is installed under /Library/LaunchDaemons, add UserName and keep
 9@@ -42,6 +43,8 @@
10       <string>/Users/george/code/baa-conductor/logs</string>
11       <key>BAA_TMP_DIR</key>
12       <string>/Users/george/code/baa-conductor/tmp</string>
13+      <key>BAA_STATE_DIR</key>
14+      <string>/Users/george/code/baa-conductor/state</string>
15       <key>BAA_NODE_ID</key>
16       <string>mini-main</string>
17       <key>BAA_SHARED_TOKEN</key>
M ops/launchd/so.makefile.baa-status-api.plist
+3, -0
 1@@ -4,6 +4,7 @@
 2   Optional local status API.
 3   Keep the same runtime paths as conductor and worker-runner so that service
 4   logs and temporary files stay under one repo-owned runtime root.
 5+  Use scripts/runtime/install-launchd.sh to render the actual install copy.
 6 -->
 7 <plist version="1.0">
 8   <dict>
 9@@ -39,6 +40,8 @@
10       <string>/Users/george/code/baa-conductor/logs</string>
11       <key>BAA_TMP_DIR</key>
12       <string>/Users/george/code/baa-conductor/tmp</string>
13+      <key>BAA_STATE_DIR</key>
14+      <string>/Users/george/code/baa-conductor/state</string>
15       <key>BAA_NODE_ID</key>
16       <string>mini-main</string>
17       <key>BAA_SHARED_TOKEN</key>
M ops/launchd/so.makefile.baa-worker-runner.plist
+3, -0
 1@@ -3,6 +3,7 @@
 2 <!--
 3   Source template kept in the repo.
 4   Defaults target the mini node and share the same runtime tree as conductor.
 5+  Use scripts/runtime/install-launchd.sh to render the actual install copy.
 6   For mac, change BAA_CONDUCTOR_HOST, BAA_CONDUCTOR_ROLE, BAA_NODE_ID, and
 7   BAA_SHARED_TOKEN before copying the file into the launchd install path.
 8 -->
 9@@ -40,6 +41,8 @@
10       <string>/Users/george/code/baa-conductor/logs</string>
11       <key>BAA_TMP_DIR</key>
12       <string>/Users/george/code/baa-conductor/tmp</string>
13+      <key>BAA_STATE_DIR</key>
14+      <string>/Users/george/code/baa-conductor/state</string>
15       <key>BAA_NODE_ID</key>
16       <string>mini-main</string>
17       <key>BAA_SHARED_TOKEN</key>
A scripts/runtime/bootstrap.sh
+59, -0
 1@@ -0,0 +1,59 @@
 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/runtime/bootstrap.sh [options]
13+
14+Options:
15+  --repo-dir PATH      Runtime root to initialize. Defaults to the current checkout root.
16+  --mode OCTAL         Directory mode passed to install -d. Defaults to 700.
17+  --owner USER:GROUP   Optional owner to apply to each runtime directory after creation.
18+  --help               Show this help text.
19+EOF
20+}
21+
22+repo_dir="${BAA_RUNTIME_REPO_DIR_DEFAULT}"
23+mode="700"
24+owner=""
25+
26+while [[ $# -gt 0 ]]; do
27+  case "$1" in
28+    --repo-dir)
29+      repo_dir="$2"
30+      shift 2
31+      ;;
32+    --mode)
33+      mode="$2"
34+      shift 2
35+      ;;
36+    --owner)
37+      owner="$2"
38+      shift 2
39+      ;;
40+    --help)
41+      usage
42+      exit 0
43+      ;;
44+    *)
45+      die "Unknown option: $1"
46+      ;;
47+  esac
48+done
49+
50+while IFS= read -r directory; do
51+  ensure_directory "$directory" "$mode"
52+
53+  if [[ -n "$owner" ]]; then
54+    chown "$owner" "$directory"
55+  fi
56+
57+  runtime_log "ready: $directory"
58+done < <(resolve_runtime_paths "$repo_dir")
59+
60+runtime_log "runtime bootstrap completed for ${repo_dir}"
A scripts/runtime/check-launchd.sh
+251, -0
  1@@ -0,0 +1,251 @@
  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/runtime/check-launchd.sh [options]
 13+
 14+Options:
 15+  --node mini|mac           Select node defaults. Defaults to mini.
 16+  --scope agent|daemon      Expected launchd scope for install copies. Defaults to agent.
 17+  --service NAME            Add one service to the check set. Repeatable.
 18+  --all-services            Check conductor, worker-runner, and status-api.
 19+  --repo-dir PATH           Repo root used to derive runtime paths.
 20+  --home-dir PATH           HOME value expected in installed plist files.
 21+  --install-dir PATH        Validate installed copies under this directory.
 22+  --shared-token TOKEN      Expect this exact token in installed copies.
 23+  --shared-token-file PATH  Read the expected token from a file.
 24+  --control-api-base URL    Expected BAA_CONTROL_API_BASE.
 25+  --local-api-base URL      Expected BAA_CONDUCTOR_LOCAL_API.
 26+  --username NAME           Expected UserName for LaunchDaemons.
 27+  --skip-dist-check         Skip dist/index.js existence checks.
 28+  --check-loaded            Also require launchctl print to succeed for each selected service.
 29+  --domain TARGET           Override launchctl domain target for --check-loaded.
 30+  --help                    Show this help text.
 31+
 32+Notes:
 33+  If no service is specified, only conductor is checked. Use --all-services or
 34+  repeat --service to opt into worker-runner/status-api templates.
 35+EOF
 36+}
 37+
 38+require_command plutil
 39+assert_file /usr/libexec/PlistBuddy
 40+
 41+node="mini"
 42+scope="agent"
 43+repo_dir="${BAA_RUNTIME_REPO_DIR_DEFAULT}"
 44+home_dir="$(default_home_dir)"
 45+install_dir=""
 46+shared_token=""
 47+shared_token_file=""
 48+control_api_base="${BAA_RUNTIME_DEFAULT_CONTROL_API_BASE}"
 49+local_api_base="${BAA_RUNTIME_DEFAULT_LOCAL_API}"
 50+username="$(default_username)"
 51+skip_dist_check="0"
 52+check_loaded="0"
 53+domain_target=""
 54+services=()
 55+
 56+while [[ $# -gt 0 ]]; do
 57+  case "$1" in
 58+    --node)
 59+      node="$2"
 60+      shift 2
 61+      ;;
 62+    --scope)
 63+      scope="$2"
 64+      shift 2
 65+      ;;
 66+    --service)
 67+      validate_service "$2"
 68+      if ! contains_value "$2" "${services[@]-}"; then
 69+        services+=("$2")
 70+      fi
 71+      shift 2
 72+      ;;
 73+    --all-services)
 74+      while IFS= read -r service; do
 75+        if ! contains_value "$service" "${services[@]-}"; then
 76+          services+=("$service")
 77+        fi
 78+      done < <(all_services)
 79+      shift
 80+      ;;
 81+    --repo-dir)
 82+      repo_dir="$2"
 83+      shift 2
 84+      ;;
 85+    --home-dir)
 86+      home_dir="$2"
 87+      shift 2
 88+      ;;
 89+    --install-dir)
 90+      install_dir="$2"
 91+      shift 2
 92+      ;;
 93+    --shared-token)
 94+      shared_token="$2"
 95+      shift 2
 96+      ;;
 97+    --shared-token-file)
 98+      shared_token_file="$2"
 99+      shift 2
100+      ;;
101+    --control-api-base)
102+      control_api_base="$2"
103+      shift 2
104+      ;;
105+    --local-api-base)
106+      local_api_base="$2"
107+      shift 2
108+      ;;
109+    --username)
110+      username="$2"
111+      shift 2
112+      ;;
113+    --skip-dist-check)
114+      skip_dist_check="1"
115+      shift
116+      ;;
117+    --check-loaded)
118+      check_loaded="1"
119+      shift
120+      ;;
121+    --domain)
122+      domain_target="$2"
123+      shift 2
124+      ;;
125+    --help)
126+      usage
127+      exit 0
128+      ;;
129+    *)
130+      die "Unknown option: $1"
131+      ;;
132+  esac
133+done
134+
135+validate_node "$node"
136+validate_scope "$scope"
137+
138+if [[ "${#services[@]}" -eq 0 ]]; then
139+  while IFS= read -r service; do
140+    services+=("$service")
141+  done < <(default_services)
142+fi
143+
144+if [[ -n "$shared_token" || -n "$shared_token_file" ]]; then
145+  shared_token="$(load_shared_token "$shared_token" "$shared_token_file")"
146+fi
147+
148+set -- $(resolve_node_defaults "$node")
149+conductor_host="$1"
150+conductor_role="$2"
151+node_id="$3"
152+launchd_path="$(default_launchd_path "$home_dir")"
153+state_dir="${repo_dir}/state"
154+runs_dir="${repo_dir}/runs"
155+worktrees_dir="${repo_dir}/worktrees"
156+logs_dir="${repo_dir}/logs"
157+logs_launchd_dir="${logs_dir}/launchd"
158+tmp_dir="${repo_dir}/tmp"
159+
160+assert_directory "$state_dir"
161+assert_directory "$runs_dir"
162+assert_directory "$worktrees_dir"
163+assert_directory "$logs_dir"
164+assert_directory "$logs_launchd_dir"
165+assert_directory "$tmp_dir"
166+
167+check_string_equals() {
168+  local name="$1"
169+  local actual="$2"
170+  local expected="$3"
171+
172+  if [[ "$actual" != "$expected" ]]; then
173+    die "${name} mismatch: expected '${expected}', got '${actual}'"
174+  fi
175+}
176+
177+check_installed_plist() {
178+  local service="$1"
179+  local plist_path="$2"
180+  local stdout_path="$3"
181+  local stderr_path="$4"
182+  local dist_entry="$5"
183+  local actual_shared_token=""
184+
185+  assert_file "$plist_path"
186+  plutil -lint "$plist_path" >/dev/null
187+
188+  check_string_equals "${service}:WorkingDirectory" "$(plist_print_value "$plist_path" ":WorkingDirectory")" "$repo_dir"
189+  check_string_equals "${service}:PATH" "$(plist_print_value "$plist_path" ":EnvironmentVariables:PATH")" "$launchd_path"
190+  check_string_equals "${service}:HOME" "$(plist_print_value "$plist_path" ":EnvironmentVariables:HOME")" "$home_dir"
191+  check_string_equals "${service}:BAA_CONDUCTOR_HOST" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_CONDUCTOR_HOST")" "$conductor_host"
192+  check_string_equals "${service}:BAA_CONDUCTOR_ROLE" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_CONDUCTOR_ROLE")" "$conductor_role"
193+  check_string_equals "${service}:BAA_CONTROL_API_BASE" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_CONTROL_API_BASE")" "$control_api_base"
194+  check_string_equals "${service}:BAA_CONDUCTOR_LOCAL_API" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_CONDUCTOR_LOCAL_API")" "$local_api_base"
195+  check_string_equals "${service}:BAA_RUNS_DIR" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_RUNS_DIR")" "$runs_dir"
196+  check_string_equals "${service}:BAA_WORKTREES_DIR" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_WORKTREES_DIR")" "$worktrees_dir"
197+  check_string_equals "${service}:BAA_LOGS_DIR" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_LOGS_DIR")" "$logs_dir"
198+  check_string_equals "${service}:BAA_TMP_DIR" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_TMP_DIR")" "$tmp_dir"
199+  check_string_equals "${service}:BAA_STATE_DIR" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_STATE_DIR")" "$state_dir"
200+  check_string_equals "${service}:BAA_NODE_ID" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_NODE_ID")" "$node_id"
201+  check_string_equals "${service}:stdout" "$(plist_print_value "$plist_path" ":StandardOutPath")" "$stdout_path"
202+  check_string_equals "${service}:stderr" "$(plist_print_value "$plist_path" ":StandardErrorPath")" "$stderr_path"
203+  check_string_equals "${service}:entry" "$(plist_print_value "$plist_path" ":ProgramArguments:2")" "$dist_entry"
204+
205+  actual_shared_token="$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_SHARED_TOKEN")"
206+  if [[ -n "$shared_token" ]]; then
207+    check_string_equals "${service}:BAA_SHARED_TOKEN" "$actual_shared_token" "$shared_token"
208+  elif [[ -z "$actual_shared_token" || "$actual_shared_token" == "replace-me" ]]; then
209+    die "${service}: BAA_SHARED_TOKEN is empty or still replace-me"
210+  fi
211+
212+  if [[ "$service" == "conductor" ]]; then
213+    check_string_equals "${service}:host-arg" "$(plist_print_value "$plist_path" ":ProgramArguments:4")" "$conductor_host"
214+    check_string_equals "${service}:role-arg" "$(plist_print_value "$plist_path" ":ProgramArguments:6")" "$conductor_role"
215+  fi
216+
217+  if [[ "$scope" == "daemon" ]]; then
218+    check_string_equals "${service}:UserName" "$(plist_print_value "$plist_path" ":UserName")" "$username"
219+  fi
220+}
221+
222+for service in "${services[@]}"; do
223+  template_path="$(service_template_path "$repo_dir" "$service")"
224+  dist_entry="${repo_dir}/$(service_dist_entry_relative "$service")"
225+  stdout_path="$(service_stdout_path "$logs_launchd_dir" "$service")"
226+  stderr_path="$(service_stderr_path "$logs_launchd_dir" "$service")"
227+
228+  assert_file "$template_path"
229+  plutil -lint "$template_path" >/dev/null
230+
231+  if [[ "$skip_dist_check" != "1" ]]; then
232+    assert_file "$dist_entry"
233+  fi
234+
235+  if [[ -n "$install_dir" ]]; then
236+    check_installed_plist "$service" "$(service_install_path "$install_dir" "$service")" "$stdout_path" "$stderr_path" "$dist_entry"
237+  fi
238+done
239+
240+if [[ "$check_loaded" == "1" ]]; then
241+  require_command launchctl
242+
243+  if [[ -z "$domain_target" ]]; then
244+    domain_target="$(default_domain_target "$scope")"
245+  fi
246+
247+  for service in "${services[@]}"; do
248+    launchctl print "${domain_target}/$(service_label "$service")" >/dev/null
249+  done
250+fi
251+
252+runtime_log "launchd checks passed"
A scripts/runtime/common.sh
+294, -0
  1@@ -0,0 +1,294 @@
  2+#!/usr/bin/env bash
  3+
  4+if [[ -n "${BAA_RUNTIME_COMMON_SH_LOADED:-}" ]]; then
  5+  return 0
  6+fi
  7+
  8+readonly BAA_RUNTIME_COMMON_SH_LOADED=1
  9+readonly BAA_RUNTIME_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
 10+readonly BAA_RUNTIME_REPO_DIR_DEFAULT="$(cd -- "${BAA_RUNTIME_SCRIPT_DIR}/../.." && pwd)"
 11+readonly BAA_RUNTIME_DEFAULT_CONTROL_API_BASE="https://control-api.makefile.so"
 12+readonly BAA_RUNTIME_DEFAULT_LOCAL_API="http://127.0.0.1:4317"
 13+readonly BAA_RUNTIME_DEFAULT_LOCALE="en_US.UTF-8"
 14+
 15+runtime_log() {
 16+  printf '[runtime] %s\n' "$*"
 17+}
 18+
 19+runtime_error() {
 20+  printf '[runtime] error: %s\n' "$*" >&2
 21+}
 22+
 23+die() {
 24+  runtime_error "$*"
 25+  exit 1
 26+}
 27+
 28+require_command() {
 29+  if ! command -v "$1" >/dev/null 2>&1; then
 30+    die "Missing required command: $1"
 31+  fi
 32+}
 33+
 34+contains_value() {
 35+  local needle="$1"
 36+  shift
 37+
 38+  local value
 39+  for value in "$@"; do
 40+    if [[ "$value" == "$needle" ]]; then
 41+      return 0
 42+    fi
 43+  done
 44+
 45+  return 1
 46+}
 47+
 48+validate_service() {
 49+  case "$1" in
 50+    conductor | worker-runner | status-api) ;;
 51+    *)
 52+      die "Unsupported service: $1"
 53+      ;;
 54+  esac
 55+}
 56+
 57+validate_scope() {
 58+  case "$1" in
 59+    agent | daemon) ;;
 60+    *)
 61+      die "Unsupported launchd scope: $1"
 62+      ;;
 63+  esac
 64+}
 65+
 66+validate_node() {
 67+  case "$1" in
 68+    mini | mac) ;;
 69+    *)
 70+      die "Unsupported node: $1"
 71+      ;;
 72+  esac
 73+}
 74+
 75+default_services() {
 76+  printf '%s\n' conductor
 77+}
 78+
 79+all_services() {
 80+  printf '%s\n' conductor worker-runner status-api
 81+}
 82+
 83+service_label() {
 84+  case "$1" in
 85+    conductor)
 86+      printf '%s\n' "so.makefile.baa-conductor"
 87+      ;;
 88+    worker-runner)
 89+      printf '%s\n' "so.makefile.baa-worker-runner"
 90+      ;;
 91+    status-api)
 92+      printf '%s\n' "so.makefile.baa-status-api"
 93+      ;;
 94+  esac
 95+}
 96+
 97+service_dist_entry_relative() {
 98+  case "$1" in
 99+    conductor)
100+      printf '%s\n' "apps/conductor-daemon/dist/index.js"
101+      ;;
102+    worker-runner)
103+      printf '%s\n' "apps/worker-runner/dist/index.js"
104+      ;;
105+    status-api)
106+      printf '%s\n' "apps/status-api/dist/index.js"
107+      ;;
108+  esac
109+}
110+
111+service_template_path() {
112+  local repo_dir="$1"
113+  local service="$2"
114+
115+  printf '%s/ops/launchd/%s.plist\n' "$repo_dir" "$(service_label "$service")"
116+}
117+
118+service_install_path() {
119+  local install_dir="$1"
120+  local service="$2"
121+
122+  printf '%s/%s.plist\n' "$install_dir" "$(service_label "$service")"
123+}
124+
125+service_stdout_path() {
126+  local logs_launchd_dir="$1"
127+  local service="$2"
128+
129+  printf '%s/%s.out.log\n' "$logs_launchd_dir" "$(service_label "$service")"
130+}
131+
132+service_stderr_path() {
133+  local logs_launchd_dir="$1"
134+  local service="$2"
135+
136+  printf '%s/%s.err.log\n' "$logs_launchd_dir" "$(service_label "$service")"
137+}
138+
139+resolve_node_defaults() {
140+  case "$1" in
141+    mini)
142+      printf '%s %s %s\n' "mini" "primary" "mini-main"
143+      ;;
144+    mac)
145+      printf '%s %s %s\n' "mac" "standby" "mac-standby"
146+      ;;
147+  esac
148+}
149+
150+default_home_dir() {
151+  if [[ -n "${HOME:-}" ]]; then
152+    printf '%s\n' "$HOME"
153+    return 0
154+  fi
155+
156+  printf '/Users/%s\n' "$(id -un)"
157+}
158+
159+default_username() {
160+  printf '%s\n' "$(id -un)"
161+}
162+
163+default_launchd_path() {
164+  local home_dir="$1"
165+
166+  printf '%s\n' "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${home_dir}/.local/bin:${home_dir}/bin"
167+}
168+
169+default_install_dir() {
170+  local scope="$1"
171+  local home_dir="$2"
172+
173+  case "$scope" in
174+    agent)
175+      printf '%s/Library/LaunchAgents\n' "$home_dir"
176+      ;;
177+    daemon)
178+      printf '%s\n' "/Library/LaunchDaemons"
179+      ;;
180+  esac
181+}
182+
183+default_domain_target() {
184+  local scope="$1"
185+
186+  case "$scope" in
187+    agent)
188+      printf 'gui/%s\n' "$(id -u)"
189+      ;;
190+    daemon)
191+      printf '%s\n' "system"
192+      ;;
193+  esac
194+}
195+
196+ensure_directory() {
197+  local path="$1"
198+  local mode="$2"
199+
200+  install -d -m "$mode" "$path"
201+}
202+
203+assert_directory() {
204+  if [[ ! -d "$1" ]]; then
205+    die "Missing directory: $1"
206+  fi
207+}
208+
209+assert_file() {
210+  if [[ ! -f "$1" ]]; then
211+    die "Missing file: $1"
212+  fi
213+}
214+
215+escape_plist_value() {
216+  local value="$1"
217+
218+  value=${value//\\/\\\\}
219+  value=${value//\"/\\\"}
220+
221+  printf '%s' "$value"
222+}
223+
224+plist_set_string() {
225+  local plist_path="$1"
226+  local key="$2"
227+  local value
228+
229+  value="$(escape_plist_value "$3")"
230+
231+  if ! /usr/libexec/PlistBuddy -c "Set ${key} \"${value}\"" "$plist_path" >/dev/null 2>&1; then
232+    /usr/libexec/PlistBuddy -c "Add ${key} string \"${value}\"" "$plist_path" >/dev/null
233+  fi
234+}
235+
236+plist_delete_key() {
237+  local plist_path="$1"
238+  local key="$2"
239+
240+  /usr/libexec/PlistBuddy -c "Delete ${key}" "$plist_path" >/dev/null 2>&1 || true
241+}
242+
243+plist_print_value() {
244+  local plist_path="$1"
245+  local key="$2"
246+
247+  /usr/libexec/PlistBuddy -c "Print ${key}" "$plist_path"
248+}
249+
250+print_shell_command() {
251+  printf '+'
252+
253+  local arg
254+  for arg in "$@"; do
255+    printf ' %q' "$arg"
256+  done
257+
258+  printf '\n'
259+}
260+
261+run_or_print() {
262+  local dry_run="$1"
263+  shift
264+
265+  if [[ "$dry_run" == "1" ]]; then
266+    print_shell_command "$@"
267+    return 0
268+  fi
269+
270+  "$@"
271+}
272+
273+resolve_runtime_paths() {
274+  local repo_dir="$1"
275+
276+  printf '%s\n' \
277+    "${repo_dir}/state" \
278+    "${repo_dir}/runs" \
279+    "${repo_dir}/worktrees" \
280+    "${repo_dir}/logs" \
281+    "${repo_dir}/logs/launchd" \
282+    "${repo_dir}/tmp"
283+}
284+
285+load_shared_token() {
286+  local shared_token="$1"
287+  local shared_token_file="$2"
288+
289+  if [[ -n "$shared_token_file" ]]; then
290+    assert_file "$shared_token_file"
291+    shared_token="$(tr -d '\r\n' <"$shared_token_file")"
292+  fi
293+
294+  printf '%s' "$shared_token"
295+}
A scripts/runtime/install-launchd.sh
+204, -0
  1@@ -0,0 +1,204 @@
  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/runtime/install-launchd.sh [options]
 13+
 14+Options:
 15+  --node mini|mac           Select node defaults. Defaults to mini.
 16+  --scope agent|daemon      Install under LaunchAgents or LaunchDaemons. Defaults to agent.
 17+  --service NAME            Add one service to the install set. Repeatable.
 18+  --all-services            Install conductor, worker-runner, and status-api templates.
 19+  --repo-dir PATH           Repo root used for WorkingDirectory and runtime paths.
 20+  --home-dir PATH           HOME value written into plist files.
 21+  --install-dir PATH        Override launchd install directory.
 22+  --shared-token TOKEN      Shared token written into the install copy.
 23+  --shared-token-file PATH  Read the shared token from a file.
 24+  --control-api-base URL    Override BAA_CONTROL_API_BASE.
 25+  --local-api-base URL      Override BAA_CONDUCTOR_LOCAL_API.
 26+  --username NAME           UserName for LaunchDaemons. Defaults to the current user.
 27+  --help                    Show this help text.
 28+
 29+Notes:
 30+  If no service is specified, only conductor is installed. Use --all-services or
 31+  repeat --service to opt into worker-runner/status-api templates.
 32+EOF
 33+}
 34+
 35+require_command cp
 36+require_command plutil
 37+assert_file /usr/libexec/PlistBuddy
 38+
 39+node="mini"
 40+scope="agent"
 41+repo_dir="${BAA_RUNTIME_REPO_DIR_DEFAULT}"
 42+home_dir="$(default_home_dir)"
 43+install_dir=""
 44+shared_token="${BAA_SHARED_TOKEN:-}"
 45+shared_token_file=""
 46+control_api_base="${BAA_RUNTIME_DEFAULT_CONTROL_API_BASE}"
 47+local_api_base="${BAA_RUNTIME_DEFAULT_LOCAL_API}"
 48+username="$(default_username)"
 49+services=()
 50+
 51+while [[ $# -gt 0 ]]; do
 52+  case "$1" in
 53+    --node)
 54+      node="$2"
 55+      shift 2
 56+      ;;
 57+    --scope)
 58+      scope="$2"
 59+      shift 2
 60+      ;;
 61+    --service)
 62+      validate_service "$2"
 63+      if ! contains_value "$2" "${services[@]-}"; then
 64+        services+=("$2")
 65+      fi
 66+      shift 2
 67+      ;;
 68+    --all-services)
 69+      while IFS= read -r service; do
 70+        if ! contains_value "$service" "${services[@]-}"; then
 71+          services+=("$service")
 72+        fi
 73+      done < <(all_services)
 74+      shift
 75+      ;;
 76+    --repo-dir)
 77+      repo_dir="$2"
 78+      shift 2
 79+      ;;
 80+    --home-dir)
 81+      home_dir="$2"
 82+      shift 2
 83+      ;;
 84+    --install-dir)
 85+      install_dir="$2"
 86+      shift 2
 87+      ;;
 88+    --shared-token)
 89+      shared_token="$2"
 90+      shift 2
 91+      ;;
 92+    --shared-token-file)
 93+      shared_token_file="$2"
 94+      shift 2
 95+      ;;
 96+    --control-api-base)
 97+      control_api_base="$2"
 98+      shift 2
 99+      ;;
100+    --local-api-base)
101+      local_api_base="$2"
102+      shift 2
103+      ;;
104+    --username)
105+      username="$2"
106+      shift 2
107+      ;;
108+    --help)
109+      usage
110+      exit 0
111+      ;;
112+    *)
113+      die "Unknown option: $1"
114+      ;;
115+  esac
116+done
117+
118+validate_node "$node"
119+validate_scope "$scope"
120+
121+if [[ "${#services[@]}" -eq 0 ]]; then
122+  while IFS= read -r service; do
123+    services+=("$service")
124+  done < <(default_services)
125+fi
126+
127+shared_token="$(load_shared_token "$shared_token" "$shared_token_file")"
128+if [[ -z "$shared_token" ]]; then
129+  die "A shared token is required. Use --shared-token, --shared-token-file, or BAA_SHARED_TOKEN."
130+fi
131+
132+if [[ -z "$install_dir" ]]; then
133+  install_dir="$(default_install_dir "$scope" "$home_dir")"
134+fi
135+
136+set -- $(resolve_node_defaults "$node")
137+conductor_host="$1"
138+conductor_role="$2"
139+node_id="$3"
140+
141+launchd_path="$(default_launchd_path "$home_dir")"
142+state_dir="${repo_dir}/state"
143+runs_dir="${repo_dir}/runs"
144+worktrees_dir="${repo_dir}/worktrees"
145+logs_dir="${repo_dir}/logs"
146+logs_launchd_dir="${logs_dir}/launchd"
147+tmp_dir="${repo_dir}/tmp"
148+
149+assert_directory "$state_dir"
150+assert_directory "$runs_dir"
151+assert_directory "$worktrees_dir"
152+assert_directory "$logs_dir"
153+assert_directory "$logs_launchd_dir"
154+assert_directory "$tmp_dir"
155+
156+ensure_directory "$install_dir" "755"
157+
158+for service in "${services[@]}"; do
159+  template_path="$(service_template_path "$repo_dir" "$service")"
160+  install_path="$(service_install_path "$install_dir" "$service")"
161+  stdout_path="$(service_stdout_path "$logs_launchd_dir" "$service")"
162+  stderr_path="$(service_stderr_path "$logs_launchd_dir" "$service")"
163+  dist_entry="${repo_dir}/$(service_dist_entry_relative "$service")"
164+
165+  assert_file "$template_path"
166+  cp "$template_path" "$install_path"
167+
168+  plist_set_string "$install_path" ":WorkingDirectory" "$repo_dir"
169+  plist_set_string "$install_path" ":EnvironmentVariables:PATH" "$launchd_path"
170+  plist_set_string "$install_path" ":EnvironmentVariables:HOME" "$home_dir"
171+  plist_set_string "$install_path" ":EnvironmentVariables:LANG" "$BAA_RUNTIME_DEFAULT_LOCALE"
172+  plist_set_string "$install_path" ":EnvironmentVariables:LC_ALL" "$BAA_RUNTIME_DEFAULT_LOCALE"
173+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_CONDUCTOR_HOST" "$conductor_host"
174+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_CONDUCTOR_ROLE" "$conductor_role"
175+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_CONTROL_API_BASE" "$control_api_base"
176+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_CONDUCTOR_LOCAL_API" "$local_api_base"
177+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_RUNS_DIR" "$runs_dir"
178+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_WORKTREES_DIR" "$worktrees_dir"
179+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_LOGS_DIR" "$logs_dir"
180+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_TMP_DIR" "$tmp_dir"
181+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_STATE_DIR" "$state_dir"
182+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_NODE_ID" "$node_id"
183+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_SHARED_TOKEN" "$shared_token"
184+  plist_set_string "$install_path" ":StandardOutPath" "$stdout_path"
185+  plist_set_string "$install_path" ":StandardErrorPath" "$stderr_path"
186+  plist_set_string "$install_path" ":ProgramArguments:2" "$dist_entry"
187+
188+  if [[ "$service" == "conductor" ]]; then
189+    plist_set_string "$install_path" ":ProgramArguments:4" "$conductor_host"
190+    plist_set_string "$install_path" ":ProgramArguments:6" "$conductor_role"
191+  fi
192+
193+  if [[ "$scope" == "daemon" ]]; then
194+    plist_set_string "$install_path" ":UserName" "$username"
195+  else
196+    plist_delete_key "$install_path" ":UserName"
197+  fi
198+
199+  chmod 644 "$install_path"
200+  plutil -lint "$install_path" >/dev/null
201+
202+  runtime_log "installed ${service} template -> ${install_path}"
203+done
204+
205+runtime_log "launchd install copies rendered for ${node} (${scope})"
A scripts/runtime/reload-launchd.sh
+137, -0
  1@@ -0,0 +1,137 @@
  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/runtime/reload-launchd.sh [options]
 13+
 14+Options:
 15+  --scope agent|daemon   launchd domain type. Defaults to agent.
 16+  --service NAME         Add one service to the reload set. Repeatable.
 17+  --all-services         Reload conductor, worker-runner, and status-api.
 18+  --home-dir PATH        Used only to derive the default LaunchAgents path.
 19+  --install-dir PATH     Override launchd install directory.
 20+  --domain TARGET        Override launchctl domain target. Defaults to gui/<uid> or system.
 21+  --skip-kickstart       Skip launchctl kickstart after bootstrap.
 22+  --dry-run              Print launchctl commands instead of executing them.
 23+  --help                 Show this help text.
 24+EOF
 25+}
 26+
 27+require_command launchctl
 28+require_command plutil
 29+
 30+scope="agent"
 31+home_dir="$(default_home_dir)"
 32+install_dir=""
 33+domain_target=""
 34+dry_run="0"
 35+skip_kickstart="0"
 36+services=()
 37+
 38+while [[ $# -gt 0 ]]; do
 39+  case "$1" in
 40+    --scope)
 41+      scope="$2"
 42+      shift 2
 43+      ;;
 44+    --service)
 45+      validate_service "$2"
 46+      if ! contains_value "$2" "${services[@]-}"; then
 47+        services+=("$2")
 48+      fi
 49+      shift 2
 50+      ;;
 51+    --all-services)
 52+      while IFS= read -r service; do
 53+        if ! contains_value "$service" "${services[@]-}"; then
 54+          services+=("$service")
 55+        fi
 56+      done < <(all_services)
 57+      shift
 58+      ;;
 59+    --home-dir)
 60+      home_dir="$2"
 61+      shift 2
 62+      ;;
 63+    --install-dir)
 64+      install_dir="$2"
 65+      shift 2
 66+      ;;
 67+    --domain)
 68+      domain_target="$2"
 69+      shift 2
 70+      ;;
 71+    --skip-kickstart)
 72+      skip_kickstart="1"
 73+      shift
 74+      ;;
 75+    --dry-run)
 76+      dry_run="1"
 77+      shift
 78+      ;;
 79+    --help)
 80+      usage
 81+      exit 0
 82+      ;;
 83+    *)
 84+      die "Unknown option: $1"
 85+      ;;
 86+  esac
 87+done
 88+
 89+validate_scope "$scope"
 90+
 91+if [[ "${#services[@]}" -eq 0 ]]; then
 92+  while IFS= read -r service; do
 93+    services+=("$service")
 94+  done < <(default_services)
 95+fi
 96+
 97+if [[ -z "$install_dir" ]]; then
 98+  install_dir="$(default_install_dir "$scope" "$home_dir")"
 99+fi
100+
101+if [[ -z "$domain_target" ]]; then
102+  domain_target="$(default_domain_target "$scope")"
103+fi
104+
105+bootout_service() {
106+  local plist_path="$1"
107+
108+  if [[ "$dry_run" == "1" ]]; then
109+    printf '+ launchctl bootout %q %q 2>/dev/null || true\n' "$domain_target" "$plist_path"
110+    return 0
111+  fi
112+
113+  launchctl bootout "$domain_target" "$plist_path" 2>/dev/null || true
114+}
115+
116+for service in "${services[@]}"; do
117+  plist_path="$(service_install_path "$install_dir" "$service")"
118+  assert_file "$plist_path"
119+  plutil -lint "$plist_path" >/dev/null
120+done
121+
122+for service in "${services[@]}"; do
123+  plist_path="$(service_install_path "$install_dir" "$service")"
124+  bootout_service "$plist_path"
125+done
126+
127+for service in "${services[@]}"; do
128+  plist_path="$(service_install_path "$install_dir" "$service")"
129+  run_or_print "$dry_run" launchctl bootstrap "$domain_target" "$plist_path"
130+done
131+
132+if [[ "$skip_kickstart" != "1" ]]; then
133+  for service in "${services[@]}"; do
134+    run_or_print "$dry_run" launchctl kickstart -k "${domain_target}/$(service_label "$service")"
135+  done
136+fi
137+
138+runtime_log "launchd reload completed for ${domain_target}"