@huynle/hub 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,392 @@
1
+ // The typed service-manager client for hub's own user services (the ServiceControl the transaction executors take,
2
+ // transaction/builtin.ts), and the activate-service executor.
3
+ //
4
+ // It names a service and a manager, never a command. Underneath, it makes only fixed calls:
5
+ // systemd --user systemctl --user daemon-reload | enable | disable | start | restart | stop <hub unit>
6
+ // systemctl --user is-active --quiet | is-enabled --quiet <hub unit>
7
+ // systemctl --user show --property=ActiveState,SubState,NRestarts <hub unit>
8
+ // launchd launchctl bootstrap gui/<uid> <the hub agent's plist> | bootout gui/<uid>/<hub label>
9
+ // launchctl print gui/<uid>/<hub label>
10
+ // Every call is checked against that set (checkManagerCall) before it reaches the injected runner, so a bug cannot
11
+ // turn it into anything else; the real runner (spawnManagerRunner) checks again, spawns the absolute program
12
+ // without a shell and with an empty environment (only the user bus address is passed), and reports an exit code;
13
+ // only the two state queries (show, print) also return their (bounded) output, which is parsed for a few state
14
+ // fields here and never passed on. Failures name the unit, its state and the exit, never output. Nothing elevated:
15
+ // only the user's own manager.
16
+ //
17
+ // Loading is not enough: a unit with Restart=on-failure (or KeepAlive) that crashes at once is "loaded" while it
18
+ // crash-loops. So activation then watches the service's state (HEALTH_WINDOW): it must be running, without a
19
+ // restart, for stableMs within timeoutMs. Failed, crash-looping (auto-restart, a respawn after an exit), stopped or
20
+ // never settling is service_failed, with a hint at where its log is (journalctl --user -u <unit>, or the agent's
21
+ // StandardErrorPath).
22
+ //
23
+ // Activation rolls back to the state the service had before: a service that was not loaded is unloaded (and
24
+ // disabled) again; one that was already loaded stays loaded (the engine then restores its previous unit file).
25
+ // After a crash the prior state is unknown, so recovery unloads it: no persistence remains without consent.
26
+ import { spawn as nodeSpawn } from "node:child_process"
27
+ import path from "node:path"
28
+ import { EMBEDDED_ASSETS, isAbsolutePath, isRecord, resolvePathRef, SERVICE_IDS, type ExecutionContext, type RootMapping, type ServiceID, type ServiceManagerID } from "../../contracts/index.ts"
29
+ import type { ServiceControl } from "../../transaction/builtin.ts"
30
+ import type { ActionExecutor } from "../../transaction/executor.ts"
31
+ import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
32
+ import { assetIDFor, serviceName, UNIT_ROOT } from "./render.ts"
33
+
34
+ export type ManagerProgram = "systemctl" | "launchctl"
35
+
36
+ /** One call to the user's service manager: the program and its words (never a shell string). */
37
+ export interface ManagerCall {
38
+ readonly program: ManagerProgram
39
+ readonly words: readonly string[]
40
+ }
41
+
42
+ /** Runs one checked call; its exit code comes back, and for the state queries (show, print) its output. */
43
+ export type ManagerRunner = (call: ManagerCall) => Promise<{ readonly exitCode: number; readonly output?: string }>
44
+
45
+ /** How long activation watches a loaded service: running without a restart for stableMs, within timeoutMs. */
46
+ export const HEALTH_WINDOW = Object.freeze({ timeoutMs: 10_000, stableMs: 3_000, pollMs: 500 })
47
+
48
+ /** What a state query says about a service. `detail` is the manager's own state words (never other output). */
49
+ export interface ServiceHealth {
50
+ readonly state: "running" | "starting" | "crash-looping" | "failed" | "stopped"
51
+ /** Restarts the manager counted (systemd NRestarts; launchd runs - 1). */
52
+ readonly restarts: number
53
+ readonly detail: string
54
+ }
55
+
56
+ export interface Clock {
57
+ now(): number
58
+ sleep(ms: number): Promise<void>
59
+ }
60
+
61
+ const realClock: Clock = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }
62
+
63
+ export interface ServiceRunState {
64
+ /** systemd: active; launchd: loaded in the user's GUI domain. */
65
+ readonly loaded: boolean
66
+ /** systemd only: enabled (started at login). */
67
+ readonly enabled?: boolean
68
+ }
69
+
70
+ export interface NodeServiceControl extends ServiceControl {
71
+ state(service: ServiceID, manager: ServiceManagerID): Promise<TxResult<ServiceRunState>>
72
+ /** Puts a service back into `prior` (the undo of an activation). */
73
+ restore(service: ServiceID, manager: ServiceManagerID, prior: ServiceRunState): Promise<TxResult<void>>
74
+ /** One state query: running, starting, crash-looping, failed or stopped. */
75
+ health(service: ServiceID, manager: ServiceManagerID): Promise<TxResult<ServiceHealth>>
76
+ }
77
+
78
+ const UNITS = new Set(SERVICE_IDS.map((s) => serviceName(s, "systemd-user")))
79
+ const LABELS = new Set(SERVICE_IDS.map((s) => serviceName(s, "launchd")))
80
+ // eslint-disable-next-line no-control-regex
81
+ const CONTROL = /[\u0000-\u001f\u007f]/
82
+ const DOMAIN = /^gui\/\d{1,10}$/
83
+ const SYSTEMD_UNIT_VERBS = new Set(["enable", "disable", "start", "restart", "stop"])
84
+ const SYSTEMD_QUERIES = new Set(["is-active", "is-enabled"])
85
+ const SHOW_PROPERTIES = "--property=ActiveState,SubState,NRestarts"
86
+
87
+ const labelOfTarget = (word: string | undefined) => {
88
+ const at = word?.lastIndexOf("/") ?? -1
89
+ return at > 0 && DOMAIN.test(word!.slice(0, at)) ? word!.slice(at + 1) : undefined
90
+ }
91
+
92
+ /** Whether `call` is one of the fixed calls this client makes. */
93
+ export function checkManagerCall(call: unknown): call is ManagerCall {
94
+ if (!isRecord(call) || !Array.isArray(call.words) || call.words.length > 4) return false
95
+ const w = call.words as unknown[]
96
+ if (!w.every((x) => typeof x === "string" && x.length > 0 && x.length <= 4096 && !CONTROL.test(x))) return false
97
+ const words = w as string[]
98
+ if (call.program === "systemctl") {
99
+ if (words[0] !== "--user") return false
100
+ const verb = words[1]!
101
+ if (verb === "daemon-reload") return words.length === 2
102
+ if (SYSTEMD_UNIT_VERBS.has(verb)) return words.length === 3 && UNITS.has(words[2]!)
103
+ if (SYSTEMD_QUERIES.has(verb)) return words.length === 4 && words[2] === "--quiet" && UNITS.has(words[3]!)
104
+ if (verb === "show") return words.length === 4 && words[2] === SHOW_PROPERTIES && UNITS.has(words[3]!)
105
+ return false
106
+ }
107
+ if (call.program === "launchctl") {
108
+ const [verb, target, file] = words
109
+ if (verb === "print" || verb === "bootout") return words.length === 2 && LABELS.has(labelOfTarget(target) ?? "")
110
+ if (verb === "bootstrap")
111
+ return words.length === 3 && DOMAIN.test(target!) && isAbsolutePath(file) && LABELS.has(path.basename(file!).replace(/\.plist$/, "")) && file!.endsWith(".plist")
112
+ return false
113
+ }
114
+ return false
115
+ }
116
+
117
+ /** Each hub unit's absolute path in this context (the manager's root + the asset's file name), when mapped. */
118
+ export function unitPathResolver(roots: readonly RootMapping[], context: ExecutionContext): (service: ServiceID, manager: ServiceManagerID) => string | undefined {
119
+ return (service, manager) => {
120
+ const asset = EMBEDDED_ASSETS[assetIDFor(service, manager)]
121
+ if (!asset) return undefined
122
+ const where = resolvePathRef({ root: UNIT_ROOT[manager], rel: asset.installAs }, roots, context)
123
+ return where.ok ? where.value : undefined
124
+ }
125
+ }
126
+
127
+ export interface ServiceControlOptions {
128
+ readonly run: ManagerRunner
129
+ /** The user whose launchd GUI domain the agents load in (process.getuid()). */
130
+ readonly uid: number
131
+ readonly unitPath: (service: ServiceID, manager: ServiceManagerID) => string | undefined
132
+ /** Time for the health window (injected in tests). */
133
+ readonly clock?: Clock
134
+ }
135
+
136
+ const ok = txOk(undefined)
137
+
138
+ /** The fields of `systemctl show` output (KEY=value lines). */
139
+ function showFields(output: string): Record<string, string> {
140
+ const fields: Record<string, string> = {}
141
+ for (const line of output.split("\n")) {
142
+ const at = line.indexOf("=")
143
+ if (at > 0) fields[line.slice(0, at)] = line.slice(at + 1).trim()
144
+ }
145
+ return fields
146
+ }
147
+
148
+ const WORD = /^[A-Za-z0-9 ()_-]{1,40}$/
149
+ /** A state word from the manager, only when it looks like one (so nothing else of its output can leak). */
150
+ const word = (value: string | undefined) => (value !== undefined && WORD.test(value) ? value : "unknown")
151
+
152
+ export function systemdHealth(output: string): ServiceHealth {
153
+ const fields = showFields(output)
154
+ const active = word(fields.ActiveState)
155
+ const sub = word(fields.SubState)
156
+ const restarts = /^\d{1,9}$/.test(fields.NRestarts ?? "") ? Number(fields.NRestarts) : 0
157
+ const detail = `${active}/${sub}`
158
+ if (active === "active") return { state: sub === "running" ? "running" : "starting", restarts, detail }
159
+ if (active === "activating") return { state: sub.startsWith("auto-restart") ? "crash-looping" : "starting", restarts, detail }
160
+ if (active === "reloading") return { state: "starting", restarts, detail }
161
+ if (active === "failed") return { state: "failed", restarts, detail }
162
+ return { state: "stopped", restarts, detail }
163
+ }
164
+
165
+ /** The top-level `key = value` of a `launchctl print` (one tab deep), else its first occurrence. */
166
+ const printField = (output: string, key: string) => (output.match(new RegExp(`^\t${key} = (.*)$`, "m")) ?? output.match(new RegExp(`^[ \\t]*${key} = (.*)$`, "m")))?.[1]?.trim()
167
+
168
+ export function launchdHealth(output: string): ServiceHealth {
169
+ const state = word(printField(output, "state"))
170
+ const runs = Number(printField(output, "runs") ?? "")
171
+ const restarts = Number.isInteger(runs) && runs > 0 ? runs - 1 : 0
172
+ const exit = printField(output, "last exit code")
173
+ const exited = exit !== undefined && /^-?\d{1,9}$/.test(exit)
174
+ const detail = exited ? `${state}, last exit code ${exit}` : state
175
+ if (state === "running") return { state: "running", restarts, detail }
176
+ if (!exited) return { state: "starting", restarts, detail }
177
+ return { state: state === "not running" ? "stopped" : "crash-looping", restarts, detail }
178
+ }
179
+
180
+ const plural = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
181
+
182
+ /** The ServiceControl for hub's user services, over a (checked) fixed-call runner. */
183
+ export function createServiceControl(options: ServiceControlOptions): NodeServiceControl {
184
+ if (!Number.isInteger(options.uid) || options.uid < 0) throw new Error("the service manager's user must be a uid")
185
+ const clock = options.clock ?? realClock
186
+ const checked = (program: ManagerProgram, ...words: string[]): ManagerCall => {
187
+ const c: ManagerCall = { program, words }
188
+ if (!checkManagerCall(c)) throw new Error(`refused a service manager call outside the fixed set (${program} ${words[0] ?? ""})`)
189
+ return c
190
+ }
191
+ const call = async (program: ManagerProgram, ...words: string[]): Promise<number> => (await options.run(checked(program, ...words))).exitCode
192
+
193
+ /** Watches a just-loaded service until it has run, without a restart, for the stable window (HEALTH_WINDOW). */
194
+ const settle = async (probe: () => Promise<TxResult<ServiceHealth>>, name: string, logs: string): Promise<TxResult<void>> => {
195
+ const failed = (what: string) => txFail("service_failed", `${name} ${what}; see ${logs}`)
196
+ const start = clock.now()
197
+ let since: number | undefined
198
+ let baseline = 0
199
+ for (;;) {
200
+ const h = await probe()
201
+ if (!h.ok) return h
202
+ const { state, restarts, detail } = h.value
203
+ const counted = restarts > 0 ? `${detail}, ${plural(restarts, "restart")}` : detail
204
+ const now = clock.now()
205
+ if (state === "crash-looping") return failed(`is crash-looping (${counted})`)
206
+ if (state === "failed") return failed(`failed (${counted})`)
207
+ if (state === "stopped") return failed(`is not running (${counted})`)
208
+ if (state === "running") {
209
+ if (since === undefined) [since, baseline] = [now, restarts]
210
+ else if (restarts > baseline) return failed(`restarted ${plural(restarts - baseline, "time")} while it was watched (${detail})`)
211
+ if (now - since >= HEALTH_WINDOW.stableMs) return ok
212
+ } else since = undefined
213
+ if (now - start >= HEALTH_WINDOW.timeoutMs) return failed(`did not stay running within ${HEALTH_WINDOW.timeoutMs / 1000} s (${detail})`)
214
+ await clock.sleep(HEALTH_WINDOW.pollMs)
215
+ }
216
+ }
217
+ const guarded = async <T>(f: () => Promise<TxResult<T>>): Promise<TxResult<T>> => {
218
+ try {
219
+ return await f()
220
+ } catch (error) {
221
+ return txFail("internal", (error as Error).message)
222
+ }
223
+ }
224
+ const sctl = (...words: string[]) => call("systemctl", "--user", ...words)
225
+ const step = async (code: Promise<number>, what: string): Promise<TxResult<void>> => {
226
+ const exit = await code
227
+ return exit === 0 ? ok : txFail("internal", `${what} failed (exit ${exit})`)
228
+ }
229
+
230
+ const systemd = {
231
+ async state(service: ServiceID): Promise<TxResult<ServiceRunState>> {
232
+ const unit = serviceName(service, "systemd-user")
233
+ return txOk({ loaded: (await sctl("is-active", "--quiet", unit)) === 0, enabled: (await sctl("is-enabled", "--quiet", unit)) === 0 })
234
+ },
235
+ async activate(service: ServiceID): Promise<TxResult<void>> {
236
+ const unit = serviceName(service, "systemd-user")
237
+ for (const [words, what] of [
238
+ [["daemon-reload"], "systemctl --user daemon-reload"],
239
+ [["enable", unit], `systemctl --user enable ${unit}`],
240
+ [["restart", unit], `systemctl --user restart ${unit}`],
241
+ ] as const) {
242
+ const r = await step(sctl(...words), what)
243
+ if (!r.ok) return r
244
+ }
245
+ return settle(() => systemd.health(service), unit, `journalctl --user -u ${unit}`)
246
+ },
247
+ async health(service: ServiceID): Promise<TxResult<ServiceHealth>> {
248
+ const unit = serviceName(service, "systemd-user")
249
+ const r = await options.run(checked("systemctl", "--user", "show", SHOW_PROPERTIES, unit))
250
+ return r.exitCode === 0 ? txOk(systemdHealth(r.output ?? "")) : txOk({ state: "stopped", restarts: 0, detail: `systemctl --user show exit ${r.exitCode}` })
251
+ },
252
+ async restore(service: ServiceID, prior: ServiceRunState): Promise<TxResult<void>> {
253
+ const unit = serviceName(service, "systemd-user")
254
+ const r = await step(sctl(prior.enabled ? "enable" : "disable", unit), `systemctl --user ${prior.enabled ? "enable" : "disable"} ${unit}`)
255
+ if (!r.ok) return r
256
+ if (!prior.loaded) {
257
+ const stopped = await step(sctl("stop", unit), `systemctl --user stop ${unit}`)
258
+ if (!stopped.ok) return stopped
259
+ }
260
+ return step(sctl("daemon-reload"), "systemctl --user daemon-reload")
261
+ },
262
+ }
263
+
264
+ const domain = `gui/${options.uid}`
265
+ const launchd = {
266
+ async state(service: ServiceID): Promise<TxResult<ServiceRunState>> {
267
+ return txOk({ loaded: (await call("launchctl", "print", `${domain}/${serviceName(service, "launchd")}`)) === 0 })
268
+ },
269
+ async activate(service: ServiceID): Promise<TxResult<void>> {
270
+ const label = serviceName(service, "launchd")
271
+ const file = options.unitPath(service, "launchd")
272
+ if (!file) return txFail("invalid", `this host has no launch-agents directory for ${label}`)
273
+ await call("launchctl", "bootout", `${domain}/${label}`) // not loaded yet is fine
274
+ const boot = await call("launchctl", "bootstrap", domain, file)
275
+ if (boot !== 0) return txFail("internal", `launchctl bootstrap of ${label} failed (exit ${boot})`)
276
+ return settle(() => launchd.health(service), label, `~/.local/state/hub/${service}.log and launchctl print ${domain}/${label}`)
277
+ },
278
+ async health(service: ServiceID): Promise<TxResult<ServiceHealth>> {
279
+ const r = await options.run(checked("launchctl", "print", `${domain}/${serviceName(service, "launchd")}`))
280
+ return r.exitCode === 0 ? txOk(launchdHealth(r.output ?? "")) : txOk({ state: "stopped", restarts: 0, detail: `not loaded, launchctl print exit ${r.exitCode}` })
281
+ },
282
+ async deactivate(service: ServiceID): Promise<TxResult<void>> {
283
+ const label = serviceName(service, "launchd")
284
+ await call("launchctl", "bootout", `${domain}/${label}`) // already unloaded is fine
285
+ return (await call("launchctl", "print", `${domain}/${label}`)) === 0 ? txFail("internal", `${label} is still loaded after launchctl bootout`) : ok
286
+ },
287
+ }
288
+
289
+ const deactivate = (service: ServiceID, manager: ServiceManagerID) =>
290
+ guarded(() => (manager === "launchd" ? launchd.deactivate(service) : systemd.restore(service, { loaded: false, enabled: false })))
291
+ return {
292
+ state: (service, manager) => guarded(() => (manager === "launchd" ? launchd.state(service) : systemd.state(service))),
293
+ activate: (service, manager) => guarded(() => (manager === "launchd" ? launchd.activate(service) : systemd.activate(service))),
294
+ deactivate,
295
+ restore: (service, manager, prior) =>
296
+ prior.loaded ? guarded(async () => (manager === "launchd" ? ok : systemd.restore(service, prior))) : deactivate(service, manager),
297
+ health: (service, manager) => guarded(() => (manager === "launchd" ? launchd.health(service) : systemd.health(service))),
298
+ }
299
+ }
300
+
301
+ /** Spawns one process: no shell; resolves its exit code, and with `capture` also its (bounded) stdout. */
302
+ export type Spawner = (
303
+ file: string,
304
+ words: readonly string[],
305
+ options: { readonly shell: false; readonly env: Readonly<Record<string, string>>; readonly timeoutMs: number; readonly capture?: true },
306
+ ) => Promise<number | { readonly exitCode: number; readonly output: string }>
307
+
308
+ /** At most this much of a state query's output is kept. */
309
+ const MAX_OUTPUT = 64 * 1024
310
+ /** The calls whose output is read: the state queries. */
311
+ const capturesOutput = (call: ManagerCall) => (call.program === "systemctl" ? call.words[1] === "show" : call.words[0] === "print")
312
+
313
+ export interface SpawnRunnerOptions {
314
+ /** Absolute paths of the two programs (discovery's view of this context). */
315
+ readonly programs: Readonly<Record<ManagerProgram, string>>
316
+ /** The caller's environment; only the user bus address is passed on (systemctl --user needs it). */
317
+ readonly env?: Readonly<Record<string, string | undefined>>
318
+ readonly timeoutMs?: number
319
+ /** Injected in tests; the default spawns with node:child_process (never with a shell). */
320
+ readonly spawn?: Spawner
321
+ }
322
+
323
+ const BUS_ENV = ["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] as const
324
+
325
+ const defaultSpawn: Spawner = (file, words, options) =>
326
+ new Promise((resolve) => {
327
+ const child = nodeSpawn(file, [...words], { shell: false, env: { ...options.env }, stdio: ["ignore", options.capture ? "pipe" : "ignore", "ignore"], timeout: options.timeoutMs, killSignal: "SIGKILL" })
328
+ let output = ""
329
+ child.stdout?.setEncoding("utf8")
330
+ child.stdout?.on("data", (chunk: string) => {
331
+ if (output.length < MAX_OUTPUT) output = (output + chunk).slice(0, MAX_OUTPUT)
332
+ })
333
+ const done = (exitCode: number) => resolve(options.capture ? { exitCode, output } : exitCode)
334
+ child.on("error", () => done(127))
335
+ child.on("close", (code, signal) => done(code ?? (signal ? 128 : 1)))
336
+ })
337
+
338
+ /** The real runner: checked calls only, absolute programs, no shell, no inherited environment. */
339
+ export function spawnManagerRunner(options: SpawnRunnerOptions): ManagerRunner {
340
+ for (const program of ["systemctl", "launchctl"] as const) {
341
+ const file = options.programs[program]
342
+ if (!isAbsolutePath(file) || path.basename(file) !== program) throw new Error(`${program} must be given as an absolute path to ${program}`)
343
+ }
344
+ const env: Record<string, string> = {}
345
+ for (const key of BUS_ENV) if (options.env?.[key]) env[key] = options.env[key]!
346
+ const spawn = options.spawn ?? defaultSpawn
347
+ const timeoutMs = options.timeoutMs ?? 30_000
348
+ return async (call: ManagerCall) => {
349
+ if (!checkManagerCall(call)) throw new Error(`refused a service manager call outside the fixed set (${String((call as { program?: unknown }).program)})`)
350
+ const capture = capturesOutput(call)
351
+ const r = await spawn(options.programs[call.program], [...call.words], { shell: false, env, timeoutMs, ...(capture ? { capture: true as const } : {}) })
352
+ if (typeof r === "number") return { exitCode: r }
353
+ return capture ? { exitCode: r.exitCode, output: r.output.slice(0, MAX_OUTPUT) } : { exitCode: r.exitCode }
354
+ }
355
+ }
356
+
357
+ /**
358
+ * activate-service: loads a hub service after the user explicitly confirmed it (the engine also requires
359
+ * --confirm-activation). An external effect; its undo returns the service to the state it had before.
360
+ */
361
+ export function activateServiceExecutor(control?: NodeServiceControl): ActionExecutor<"activate-service"> {
362
+ const priors = new Map<string, ServiceRunState>()
363
+ return {
364
+ kind: "activate-service",
365
+ plan: () => ({ class: "external" }),
366
+ check: async (action) => {
367
+ if (action.confirm !== "explicit") return txFail("needs_user", `${action.id}: a service is only loaded on explicit confirmation`)
368
+ return control ? ok : txFail("internal", "this hubctl was built without a service manager client")
369
+ },
370
+ change: async (action) => {
371
+ if (!control) return txFail("internal", "this hubctl was built without a service manager client")
372
+ return txOk({
373
+ type: "effect",
374
+ perform: async () => {
375
+ const prior = await control.state(action.service, action.manager)
376
+ if (!prior.ok) return prior
377
+ priors.set(action.id, prior.value)
378
+ const activated = await control.activate(action.service, action.manager)
379
+ if (activated.ok) return activated
380
+ // The engine does not undo a step that failed, so a failed activation puts the service back itself.
381
+ const restored = await control.restore(action.service, action.manager, prior.value)
382
+ const after = !restored.ok ? `undoing it failed: ${restored.message}` : prior.value.loaded ? "it was loaded before and stays loaded" : "it was unloaded again"
383
+ return txFail(activated.code, `${activated.message}; ${after}`)
384
+ },
385
+ })
386
+ },
387
+ undo: async (action) => {
388
+ if (!control) return txFail("internal", "this hubctl was built without a service manager client")
389
+ return control.restore(action.service, action.manager, priors.get(action.id) ?? { loaded: false, enabled: false })
390
+ },
391
+ }
392
+ }
@@ -0,0 +1,23 @@
1
+ // The node-services adapter: a persistent launch node (`hub node`) and the harness spokes' user services, planned
2
+ // from the embedded unit templates, loaded only with explicit consent through fixed-argv calls to the user's
3
+ // service manager, and proved by one bounded verification launch.
4
+ //
5
+ // render.ts the units, from EMBEDDED_ASSETS with serviceArgv (golden-tested for systemd --user and launchd)
6
+ // validate.ts narrow node settings (roots, slots, labels) and the same-runtime-context rule
7
+ // plan.ts the typed actions (node.json merge, install-service, activate-service, launch-session)
8
+ // control.ts ServiceControl over systemctl --user / launchctl, and the activate-service executor
9
+ // launch.ts the bounded launch verification and the launch-session executor
10
+ import type { ExecutorMap } from "../../transaction/executor.ts"
11
+ import { activateServiceExecutor, type NodeServiceControl } from "./control.ts"
12
+ import { launchSessionExecutor, type LaunchProbe } from "./launch.ts"
13
+
14
+ export * from "./render.ts"
15
+ export * from "./validate.ts"
16
+ export * from "./plan.ts"
17
+ export * from "./control.ts"
18
+ export * from "./launch.ts"
19
+
20
+ /** The executors this adapter adds to builtinExecutors (pass the same control as BuiltinDeps.services). */
21
+ export function nodeServiceExecutors(deps: { readonly control?: NodeServiceControl; readonly probe?: LaunchProbe }): Pick<ExecutorMap, "activate-service" | "launch-session"> {
22
+ return { "activate-service": activateServiceExecutor(deps.control), "launch-session": launchSessionExecutor(deps.probe) }
23
+ }
@@ -0,0 +1,223 @@
1
+ // Bounded launch verification: the optional last step of a launch-node setup (launch-session). Through an injected
2
+ // LaunchDriver (HBS-13 wires it to the hub's launch API and the node's state; tests use a fake), it
3
+ // 1. launches one session of the harness in a configured workspace, bounded by maxDurationS,
4
+ // 2. waits for the session's response,
5
+ // 3. waits for it to end idle,
6
+ // 4. checks the workspace holds no launch artifacts afterwards,
7
+ // 5. cleans up, and
8
+ // 6. checks the node's slot is free again.
9
+ // A failure, the user's cancel or the deadline stops it and recovers: cancel the launch, clean up, and check the
10
+ // slot. The report lists each stage; its messages are one line, bounded, and never carry a credential (anything
11
+ // credential-shaped from the driver is redacted).
12
+ import { HARNESS_ORDER, isAbsolutePath, isSecretValue, type ErrorCode, type HarnessID } from "../../contracts/index.ts"
13
+ import type { ActionExecutor } from "../../transaction/executor.ts"
14
+ import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
15
+
16
+ export interface LaunchRequest {
17
+ readonly harness: HarnessID
18
+ /** The workspace as the node sees it (absolute, inside a workspace root). */
19
+ readonly workspace: string
20
+ readonly maxDurationS: number
21
+ }
22
+
23
+ /** The launch operations the verification needs; every wait takes the abort signal of the bound. */
24
+ export interface LaunchDriver {
25
+ start(request: LaunchRequest, signal: AbortSignal): Promise<TxResult<{ readonly launchID: string }>>
26
+ awaitResponse(launchID: string, signal: AbortSignal): Promise<TxResult<void>>
27
+ awaitIdle(launchID: string, signal: AbortSignal): Promise<TxResult<void>>
28
+ /** What the launch left in the workspace that should not be there (relative names). */
29
+ inspect(launchID: string): Promise<TxResult<{ readonly leftovers: readonly string[] }>>
30
+ cleanup(launchID: string): Promise<TxResult<void>>
31
+ cancel(launchID: string): Promise<TxResult<void>>
32
+ /** Whether the node's slot for this launch is free again. */
33
+ slotFree(launchID: string): Promise<TxResult<boolean>>
34
+ }
35
+
36
+ export interface LaunchTimer {
37
+ after(ms: number): { readonly promise: Promise<void>; cancel(): void }
38
+ }
39
+
40
+ export const realTimer: LaunchTimer = {
41
+ after(ms) {
42
+ let handle: ReturnType<typeof setTimeout> | undefined
43
+ const promise = new Promise<void>((resolve) => {
44
+ handle = setTimeout(resolve, ms)
45
+ })
46
+ return { promise, cancel: () => clearTimeout(handle) }
47
+ },
48
+ }
49
+
50
+ export type LaunchStage = "launch" | "response" | "idle" | "artifacts" | "cleanup" | "slot" | "recover"
51
+
52
+ export interface StageResult {
53
+ readonly stage: LaunchStage
54
+ readonly ok: boolean
55
+ readonly detail?: string
56
+ }
57
+
58
+ export interface LaunchReport {
59
+ readonly ok: boolean
60
+ readonly launchID?: string
61
+ readonly stages: readonly StageResult[]
62
+ /** After a failure with a launch: whether cancel, cleanup and the slot check all succeeded. */
63
+ readonly recovered?: boolean
64
+ readonly code?: ErrorCode
65
+ readonly message?: string
66
+ }
67
+
68
+ export interface VerifyOptions {
69
+ readonly timer?: LaunchTimer
70
+ /** The user's cancel (Ctrl-C). */
71
+ readonly signal?: AbortSignal
72
+ }
73
+
74
+ // eslint-disable-next-line no-control-regex
75
+ const CONTROL = /[\u0000-\u001f\u007f]+/g
76
+
77
+ /** One bounded, credential-free line from a driver message. */
78
+ export function scrub(text: string): string {
79
+ const words = text
80
+ .replace(CONTROL, " ")
81
+ .replace(/\bBearer\s+\S+/gi, "[redacted]")
82
+ .split(/(\s+)/)
83
+ .map((w) => (isSecretValue(w) ? "[redacted]" : w))
84
+ .join("")
85
+ const line = words.length > 300 ? `${words.slice(0, 297)}...` : words
86
+ return isSecretValue(line) ? "(details withheld)" : line
87
+ }
88
+
89
+ const ABORTED = Symbol("aborted")
90
+
91
+ /** Runs the bounded verification. Never throws for a driver failure: everything ends in the report. */
92
+ export async function verifyLaunch(driver: LaunchDriver, request: LaunchRequest, options: VerifyOptions = {}): Promise<LaunchReport> {
93
+ if (!HARNESS_ORDER.includes(request.harness) || !isAbsolutePath(request.workspace) || !Number.isInteger(request.maxDurationS) || request.maxDurationS < 1 || request.maxDurationS > 86_400)
94
+ return { ok: false, stages: [], code: "invalid", message: "a verification launch needs a known harness, an absolute workspace and a bound of 1 s to 24 h" }
95
+ if (options.signal?.aborted) return { ok: false, stages: [], code: "cancelled", message: "cancelled before the launch" }
96
+
97
+ const stages: StageResult[] = []
98
+ const controller = new AbortController()
99
+ let reason: "cancelled" | "timeout" | undefined
100
+ const stop = (why: "cancelled" | "timeout") => {
101
+ reason ??= why
102
+ controller.abort()
103
+ }
104
+ const deadline = (options.timer ?? realTimer).after(request.maxDurationS * 1000)
105
+ void deadline.promise.then(() => stop("timeout"))
106
+ const onCancel = () => stop("cancelled")
107
+ options.signal?.addEventListener("abort", onCancel)
108
+ const aborted = new Promise<typeof ABORTED>((resolve) => controller.signal.addEventListener("abort", () => resolve(ABORTED)))
109
+ const race = <T>(p: Promise<T>) => Promise.race([p, aborted])
110
+
111
+ const stopped = () =>
112
+ reason === "cancelled"
113
+ ? { code: "cancelled" as const, message: "the verification launch was cancelled" }
114
+ : { code: "harness_not_ready" as const, message: `the verification launch did not finish within ${request.maxDurationS} s` }
115
+ const failure = (r: { readonly message: string }) => ({ code: "harness_not_ready" as const, message: scrub(r.message) })
116
+
117
+ try {
118
+ const started = await race(driver.start(request, controller.signal))
119
+ if (started === ABORTED || !started.ok) {
120
+ const why = started === ABORTED ? stopped() : failure(started)
121
+ stages.push({ stage: "launch", ok: false, detail: why.message })
122
+ return { ok: false, stages, ...why }
123
+ }
124
+ const launchID = started.value.launchID
125
+ stages.push({ stage: "launch", ok: true })
126
+
127
+ const fail = async (stage: LaunchStage, why: { code: ErrorCode; message: string }): Promise<LaunchReport> => {
128
+ stages.push({ stage, ok: false, detail: why.message })
129
+ const recovered = await recover(driver, launchID)
130
+ stages.push({ stage: "recover", ok: recovered.ok, ...(recovered.ok ? {} : { detail: recovered.detail }) })
131
+ const message = recovered.ok ? why.message : `${why.message}; recovery failed (${recovered.detail}): launch ${launchID} may still hold its slot and workspace`
132
+ return { ok: false, launchID, stages, recovered: recovered.ok, code: why.code, message }
133
+ }
134
+
135
+ for (const [stage, wait] of [
136
+ ["response", () => driver.awaitResponse(launchID, controller.signal)],
137
+ ["idle", () => driver.awaitIdle(launchID, controller.signal)],
138
+ ] as const) {
139
+ const r = await race(wait())
140
+ if (r === ABORTED) return fail(stage, stopped())
141
+ if (!r.ok) return fail(stage, failure(r))
142
+ stages.push({ stage, ok: true })
143
+ }
144
+
145
+ const inspected = await driver.inspect(launchID)
146
+ if (!inspected.ok) return fail("artifacts", failure(inspected))
147
+ if (inspected.value.leftovers.length > 0) {
148
+ const names = inspected.value.leftovers.slice(0, 5).map(scrub).join(", ")
149
+ return fail("artifacts", { code: "harness_not_ready", message: `the launch left ${inspected.value.leftovers.length} artifacts behind (${names})` })
150
+ }
151
+ stages.push({ stage: "artifacts", ok: true })
152
+
153
+ const cleaned = await driver.cleanup(launchID)
154
+ if (!cleaned.ok) return fail("cleanup", failure(cleaned))
155
+ stages.push({ stage: "cleanup", ok: true })
156
+
157
+ const free = await driver.slotFree(launchID)
158
+ if (!free.ok || !free.value) {
159
+ const message = `the node's slot is still held by launch ${launchID}${free.ok ? "" : ` (${scrub(free.message)})`}`
160
+ stages.push({ stage: "slot", ok: false, detail: message })
161
+ return { ok: false, launchID, stages, code: "harness_not_ready", message }
162
+ }
163
+ stages.push({ stage: "slot", ok: true })
164
+ return { ok: true, launchID, stages }
165
+ } finally {
166
+ deadline.cancel()
167
+ options.signal?.removeEventListener("abort", onCancel)
168
+ }
169
+ }
170
+
171
+ /** Cancel, clean up and check the slot; every step runs even when an earlier one failed. */
172
+ async function recover(driver: LaunchDriver, launchID: string): Promise<{ ok: true } | { ok: false; detail: string }> {
173
+ const problems: string[] = []
174
+ const settle = async <T>(what: string, p: Promise<TxResult<T>>): Promise<TxResult<T> | undefined> => {
175
+ try {
176
+ const r = await p
177
+ if (!r.ok) problems.push(`${what}: ${scrub(r.message)}`)
178
+ return r
179
+ } catch (error) {
180
+ problems.push(`${what}: ${scrub((error as Error).message)}`)
181
+ return undefined
182
+ }
183
+ }
184
+ await settle("cancel", driver.cancel(launchID))
185
+ await settle("cleanup", driver.cleanup(launchID))
186
+ const free = await settle("slot", driver.slotFree(launchID))
187
+ if (free?.ok && !free.value) problems.push("slot: still held")
188
+ return problems.length === 0 ? { ok: true } : { ok: false, detail: problems.join("; ") }
189
+ }
190
+
191
+ export interface LaunchProbe {
192
+ readonly driver: LaunchDriver
193
+ readonly timer?: LaunchTimer
194
+ readonly signal?: AbortSignal
195
+ /** Receives every report (the CLI prints its stages). */
196
+ readonly onReport?: (report: LaunchReport) => void
197
+ }
198
+
199
+ /**
200
+ * launch-session: the bounded verification as an external effect. It recovers by itself, so it has no undo; a
201
+ * failure fails the step and the applier rolls back what came before (an activation, a unit).
202
+ */
203
+ export function launchSessionExecutor(probe?: LaunchProbe): ActionExecutor<"launch-session"> {
204
+ const missing = () => txFail("internal", "this hubctl was built without a launch driver")
205
+ return {
206
+ kind: "launch-session",
207
+ plan: () => ({ class: "external" }),
208
+ check: async () => (probe ? txOk(undefined) : missing()),
209
+ change: async (action, ctx) => {
210
+ if (!probe) return missing()
211
+ const workspace = ctx.resolve(action.workspace)
212
+ if (!workspace.ok) return workspace
213
+ return txOk({
214
+ type: "effect",
215
+ perform: async () => {
216
+ const report = await verifyLaunch(probe.driver, { harness: action.harness, workspace: workspace.value, maxDurationS: action.maxDurationS }, { timer: probe.timer, signal: probe.signal })
217
+ probe.onReport?.(report)
218
+ return report.ok ? txOk(undefined) : txFail(report.code ?? "harness_not_ready", report.message ?? "the verification launch failed")
219
+ },
220
+ })
221
+ },
222
+ }
223
+ }