@moondeck/station 0.0.0-stage → 1.0.0

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 (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +131 -4
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * ADR-154 §13 on the runner: the activation authority. A runner moves from
3
+ * its own credentials (`legacy`) to cloud ones once, and never back.
4
+ *
5
+ * 1. *Prepare*, changing nothing that cannot be undone. The cloud agrees
6
+ * (`credentials.activate`, phase `prepare`, which the cloud records: from
7
+ * here it honors this runner's commit whatever its switch says later).
8
+ * The runner admits no new start on its own credentials and waits for the
9
+ * starts under way, and plans the retirement of every task runtime
10
+ * (finding every blocker known in advance:
11
+ * lib/legacy-runtime-retirement.ts). A refusal or a blocker admits starts
12
+ * again, on v0, with every runtime and store as it was; the next attempt
13
+ * prepares again.
14
+ * 2. *Local commit*, from which it only goes forward. The durable record
15
+ * `local`, written under the managed-operation lock once the runner's
16
+ * next boot is proven a release that reads it and standalone recovery
17
+ * reads it too (managed-runtime.ts commitCredentialsSwitchLocally): from
18
+ * it, no release built before the cutover runs here again, neither
19
+ * through an update or rollback nor through standalone recovery.
20
+ * Then leaving its own credentials (its refreshers, its v0 lease and every
21
+ * credential write admitted from the bridge stop and drain), the
22
+ * retirement of every task runtime (each task stops and its container
23
+ * goes with its worktree kept), and the credentials floor's own file. It
24
+ * is `pending` from the record on: it runs no task, and a restart resumes
25
+ * here.
26
+ * 3. *Cloud commit*: the cloud records the runner's v1 floor; record
27
+ * `cloud`.
28
+ * 4. *Purge*, then record `active`: the runner advertises v1, reports the
29
+ * purge (main.ts sends it on every connection and tick, since the cloud
30
+ * acknowledges none and keeps the first), and runs its tasks on cloud
31
+ * credentials.
32
+ *
33
+ * Every retired task is owed a stopped report at the exact run epoch it was
34
+ * stopped at, for the cloud may still show it running: the report is
35
+ * best-effort (no acknowledgement; the cloud drops one it fails to write),
36
+ * so the record keeps each owed report through every step, `active`
37
+ * included, and the runner sends them again on every attempt and tick until
38
+ * each is moot (a newer run of the task, or its row gone). The cloud ignores
39
+ * a report about a task that is not running at that epoch, so sending one
40
+ * again is harmless.
41
+ *
42
+ * Leaving its own credentials is one-way in a process: if that fails (a
43
+ * worker's stop threw), the process still retires every task runtime, so no
44
+ * engine goes on running on them, and then asks its supervisor for a
45
+ * restart; it comes back pending, with nothing of its own credentials
46
+ * started, and resumes the switch.
47
+ *
48
+ * Nothing irreversible runs on a record not known to be durable: written
49
+ * whole, renamed into place and its directory synced, in this process. One
50
+ * renamed into place whose directory could not then be synced is the record
51
+ * (it may well be on disk: the runner's own credentials do not resume), but
52
+ * a crash could still lose it, and the runner would boot back onto its own
53
+ * credentials after retiring its tasks. So the switch waits, pending, until
54
+ * the directory syncs. A record found at boot is synced before anything
55
+ * runs on it too: an earlier process may have left one it could not sync.
56
+ * What is synced is proven to be the record this process holds, exactly:
57
+ * one removed, replaced or unreadable since is written again from it.
58
+ *
59
+ * The runner says it is active (`onActive`, and `mode()`, which every v1
60
+ * surface follows: its advertisement, its lease frames, the purge report,
61
+ * task starts) only once its `active` record is durable and the recovery
62
+ * sweep of what the last process left (ADR-154 §5, §6) is done, so that no
63
+ * grant is asked for before it: at boot the container runtime's own pass
64
+ * sweeps (and should it give up, the authority, on every attempt), at the
65
+ * transition the authority does. Should saying so fail, the
66
+ * runner is pending again, withdraws what it advertised, and says so later.
67
+ *
68
+ * What stops the switch is said to the cloud as well as logged (`status`,
69
+ * on every capability frame; `onStatusChange` when it changes): the latest
70
+ * refusal or blocker, who gave it (the cloud's answer, or this runner), each
71
+ * blocker apart and every Compose project the retirement could not prove
72
+ * Uai's. A transient one (the container runtime still preparing after a
73
+ * reconnect, an unanswered ask) never replaces a substantive one: an
74
+ * operator reading Connections sees what to fix, not what fixes itself.
75
+ *
76
+ * The record holds no secret: `{"schemaVersion":1,"step":"local",
77
+ * "stopped":[{"taskId":"…","runEpoch":3}]}`. One the runner cannot read
78
+ * counts as `local`, never as `legacy`: a damaged record may stand for a
79
+ * switch under way. That is why the local step leaves, plans the retirement
80
+ * and claims the floor itself (each idempotent, and a damaged record must
81
+ * not skip them); the reports a damaged record lost are owed again by that
82
+ * retirement, which finds the same runtimes gone.
83
+ */
84
+
85
+ import {
86
+ closeSync,
87
+ constants,
88
+ fstatSync,
89
+ fsyncSync,
90
+ lstatSync,
91
+ openSync,
92
+ readFileSync,
93
+ renameSync,
94
+ writeFileSync,
95
+ } from "node:fs";
96
+ import { dirname } from "node:path";
97
+
98
+ import type { CredentialsActivationPhase } from "../src/cloud-credentials";
99
+ import {
100
+ MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS,
101
+ MAX_CREDENTIALS_SWITCH_BLOCKERS,
102
+ MAX_CREDENTIALS_SWITCH_DETAIL_CHARS,
103
+ MAX_CREDENTIALS_SWITCH_PROJECTS,
104
+ type CredentialsSwitchCapability,
105
+ type CredentialsSwitchStuck,
106
+ } from "../src/protocol";
107
+ import type { CredentialsMode } from "./cloud-credential-host";
108
+ import type { RetirementPlan } from "./legacy-runtime-retirement";
109
+ import { isSafeHostTaskId } from "./task-identity";
110
+
111
+ export type ActivationStep = "local" | "cloud" | "active";
112
+
113
+ /** A retired task's stopped report: its exact run epoch, or null for a task
114
+ * with no assignment (it predates run epochs). */
115
+ export interface StoppedReport {
116
+ taskId: string;
117
+ runEpoch: number | null;
118
+ }
119
+
120
+ export interface ActivationRecord {
121
+ step: ActivationStep;
122
+ /** Reports owed: the cloud may not have taken them yet. */
123
+ stopped: StoppedReport[];
124
+ /** The record on disk could not be read, and this one stands for it. */
125
+ damaged?: true;
126
+ }
127
+
128
+ export type ActivationAnswer = { ok: true } | { ok: false; error: string };
129
+
130
+ const RECORD_SCHEMA_VERSION = 1;
131
+ const STEPS: readonly ActivationStep[] = ["local", "cloud", "active"];
132
+
133
+ export interface ActivationAuthorityDeps<Retirement = RetirementPlan> {
134
+ /** The durable record (non-secret), beside the runner's database. */
135
+ recordPath: string;
136
+ /** The credentials floor's file exists: the switch began, even with no
137
+ * record to say so (managed-runtime.ts credentialsCapabilityFloorExists). */
138
+ floorClaimed: () => boolean;
139
+ /** Whether this runner moves to cloud credentials in this release. */
140
+ eligible: () => boolean;
141
+ /** A marked task machine is born behind the v1 fence: it may ask the
142
+ * cloud to activate, but must never expose a transient `legacy` mode or
143
+ * request a v0 lease while that answer is pending. */
144
+ initiallyPending?: () => boolean;
145
+ /** One phase asked of the cloud. Rejects when it could not be asked or
146
+ * answered (no bridge, a timeout): the next attempt asks again. */
147
+ ask: (phase: CredentialsActivationPhase) => Promise<ActivationAnswer>;
148
+ /** Admit no new start on the runner's own credentials (cloud-credential-host). */
149
+ pauseLegacyStarts: () => { drained: Promise<void>; resume(): void };
150
+ /** The retirement's plan: changes nothing, rejects with every blocker. */
151
+ planRetirement: () => Promise<Retirement>;
152
+ /** Under the managed-operation lock, prove the next boot reads the record
153
+ * and fence standalone recovery, then call `record` to write it
154
+ * (managed-runtime.ts commitCredentialsSwitchLocally). */
155
+ commitLocally: (record: () => void) => Promise<void>;
156
+ /** Claim the credentials floor: no older release runs here again. Idempotent. */
157
+ claimFloor: () => Promise<void> | void;
158
+ /** Leave the runner's own credentials (cloud-credential-host). */
159
+ leaveLegacy: () => Promise<void>;
160
+ /** Carry the plan out (idempotent). `stopped` names each task whose
161
+ * runtime is gone, at its exact run epoch; `problem` says what could not
162
+ * be proven removed. */
163
+ retire: (plan: Retirement) => Promise<{ stopped: StoppedReport[]; problem: string | null }>;
164
+ /** Send each owed report again; returns those still owed (the moot ones
165
+ * dropped: a newer run of the task, or its row gone). */
166
+ reportStopped: (owed: readonly StoppedReport[]) => StoppedReport[];
167
+ /** Delete every runner-local credential store; rejects unless all went. */
168
+ purge: () => Promise<void>;
169
+ /** The recovery sweep has succeeded in this process
170
+ * (cloud-credential-host.ts cloudCredentialsSwept). */
171
+ swept: () => boolean;
172
+ /** Run the recovery sweep, or join the one under way; rejects when it
173
+ * fails (recoverCloudCredentials). */
174
+ sweep: () => Promise<void>;
175
+ /** The runner's tasks now run on cloud credentials: at the transition, or
176
+ * at a boot that found the record `active` (never both in one process).
177
+ * `mode()` is `active` while it runs. May throw: the authority is then
178
+ * pending again, calls `onActiveWithdrawn`, and calls this again later. */
179
+ onActive: (how: "transition" | "boot") => void;
180
+ /** Saying the runner is active failed and it is pending again: withdraw
181
+ * what was advertised. */
182
+ onActiveWithdrawn: () => void;
183
+ /** Ask the runner's supervisor for a restart. */
184
+ restart: (why: string) => void;
185
+ log: (line: string) => void;
186
+ /** `status()` changed: advertise it again. */
187
+ onStatusChange?: () => void;
188
+ /** What `status()` says of a blocker, made fit to leave the machine (the
189
+ * home directory as `~`). */
190
+ redact?: (text: string) => string;
191
+ now?: () => number;
192
+ }
193
+
194
+ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
195
+ readonly #deps: ActivationAuthorityDeps<Retirement>;
196
+ #record: ActivationRecord | null;
197
+ /** `#record` is known to be durable (see the header). */
198
+ #durable = false;
199
+ /** `onActive` has run, and returned, in this process. */
200
+ #announced = false;
201
+ /** `onActive` is running. */
202
+ #announcing = false;
203
+ /** The container runtime's own recovery gave up: the sweep is this
204
+ * authority's to retry (`sweepItself`). */
205
+ #sweepsItself = false;
206
+ /** The record was `active` when this process started. */
207
+ readonly #activeAtBoot: boolean;
208
+ /** A fresh task-machine incarnation starts behind the v1 fence before it
209
+ * has a local activation record. Unlike an owned runner's reversible
210
+ * prepare, a cloud-marked incarnation must never fall back to v0. */
211
+ readonly #initiallyPending: boolean;
212
+ #advancing: Promise<void> | null = null;
213
+ #lastNote: string | null = null;
214
+ /** Leaving the runner's own credentials failed in this process. */
215
+ #stuck = false;
216
+ /** What stops the switch now, as `status()` says it. */
217
+ #blocker: CredentialsSwitchStuck | null = null;
218
+ /** Every Compose project the current blocker names, past the status's
219
+ * bound too: what the log lists, and what tells one blocker from another
220
+ * when only names past that bound changed. */
221
+ #blockerProjects = "";
222
+ /** `status()` as last told to `onStatusChange`'s caller. */
223
+ #published: string | null = null;
224
+
225
+ constructor(deps: ActivationAuthorityDeps<Retirement>) {
226
+ this.#deps = deps;
227
+ this.#initiallyPending = deps.initiallyPending?.() === true;
228
+ this.#record = readActivationRecord(deps.recordPath);
229
+ if (this.#record === null && deps.floorClaimed()) {
230
+ // Only the switch claims the floor, after its record: a floor with no
231
+ // record means the record was lost, never a runner still on v0.
232
+ this.#record = { step: "local", stopped: [], damaged: true };
233
+ deps.log(
234
+ `[credentials] the credentials floor exists but ${deps.recordPath} does not: the station treats it as the local ` +
235
+ "commit, runs no task, and retires every task runtime again before it completes the switch",
236
+ );
237
+ } else if (this.#record?.damaged) {
238
+ deps.log(
239
+ `[credentials] the activation record ${deps.recordPath} could not be read: the station treats it as the local ` +
240
+ "commit, runs no task, and claims the floor and retires every task runtime again before it completes the switch",
241
+ );
242
+ }
243
+ this.#activeAtBoot = this.#record?.step === "active";
244
+ }
245
+
246
+ /** The step the durable record names; null on the runner's own credentials. */
247
+ step(): ActivationStep | null {
248
+ return this.#record?.step ?? null;
249
+ }
250
+
251
+ /** Where the switch stands, for the capability frame (see the header). */
252
+ status(): CredentialsSwitchCapability {
253
+ const state = this.mode();
254
+ const stuck = state === "active" ? null : this.#blocker;
255
+ return stuck === null ? { state } : { state, stuck: { ...stuck } };
256
+ }
257
+
258
+ mode(): CredentialsMode {
259
+ if (this.#record === null) {
260
+ return this.#initiallyPending ? "pending" : "legacy";
261
+ }
262
+ return this.#record.step === "active" && (this.#announced || this.#announcing) ? "active" : "pending";
263
+ }
264
+
265
+ /** At boot, once the composer and the bridge's handlers exist: a runner
266
+ * already active says so to whoever follows it once it may (`advance`
267
+ * again when the boot recovery sweep is done). */
268
+ booted(): void {
269
+ if (this.#record?.step === "active") void this.advance();
270
+ }
271
+
272
+ /** The container runtime's recovery gave up (its bounded retries spent)
273
+ * before the boot sweep succeeded: from now on every attempt runs the
274
+ * sweep itself, so a runner already active is not left pending for good. */
275
+ sweepItself(): void {
276
+ this.#sweepsItself = true;
277
+ void this.advance();
278
+ }
279
+
280
+ /**
281
+ * The owed stopped reports again, then as far as the runner can get now:
282
+ * from `legacy` only when eligible and the cloud agrees; from `pending`
283
+ * always. Single-flight; never rejects (each failure is logged, and the
284
+ * next call tries again).
285
+ */
286
+ advance(): Promise<void> {
287
+ if (this.#advancing === null) {
288
+ this.#advancing = this.#advanceOnce()
289
+ .catch((error: unknown) =>
290
+ this.#block("runner", error, `activation stopped: ${message(error)}; it tries again later`),
291
+ )
292
+ .finally(() => {
293
+ this.#advancing = null;
294
+ this.#publish();
295
+ });
296
+ }
297
+ return this.#advancing;
298
+ }
299
+
300
+ async #advanceOnce(): Promise<void> {
301
+ this.#reportOwed();
302
+ if (this.#stuck) return;
303
+ if (this.#record?.step === "active") {
304
+ await this.#activate();
305
+ return;
306
+ }
307
+ let plan: Retirement | null = null;
308
+ if (this.#record === null) {
309
+ if (!this.#deps.eligible()) {
310
+ this.#blocker = null;
311
+ return;
312
+ }
313
+ const prepared = await this.#ask("prepare");
314
+ if (!prepared.ok) {
315
+ this.#block("cloud", prepared.error, `the cloud did not agree to cloud credentials yet: ${prepared.error}`);
316
+ return;
317
+ }
318
+ const paused = this.#deps.pauseLegacyStarts();
319
+ try {
320
+ await paused.drained;
321
+ plan = await this.#deps.planRetirement();
322
+ // From the record on, the switch only goes forward.
323
+ await this.#deps.commitLocally(() => this.#write({ step: "local", stopped: [] }));
324
+ } catch (error) {
325
+ // A failure after the record was written (the lock's release, say)
326
+ // changes nothing about it: the switch goes on. Whether it was
327
+ // written is read back, never assumed from the error: a rename that
328
+ // failed ambiguously may have landed, and a record on disk, readable
329
+ // or not, is the local commit.
330
+ this.#record ??= readActivationRecord(this.#deps.recordPath);
331
+ if (this.#record === null) {
332
+ paused.resume();
333
+ this.#block(
334
+ "runner",
335
+ error,
336
+ this.#initiallyPending
337
+ ? `the v1-required task machine stays pending, with no v0 fallback: ${message(error)}`
338
+ : `the station stays on its own credentials, nothing changed: ${message(error)}`,
339
+ );
340
+ return;
341
+ }
342
+ }
343
+ this.#blocker = null;
344
+ this.#deps.log("[credentials] switching to cloud credentials: leaving the station's own");
345
+ }
346
+ if (!this.#synced()) return;
347
+ if (this.#record?.step === "local") {
348
+ // A process that started pending has nothing of its own credentials
349
+ // running; leaving them is then immediate.
350
+ try {
351
+ await this.#deps.leaveLegacy();
352
+ } catch (error) {
353
+ this.#stuck = true;
354
+ this.#deps.log(
355
+ `[credentials] the station's own credentials did not stop (${message(error)}): it retires every task runtime ` +
356
+ "now, then restarts to resume the switch",
357
+ );
358
+ try {
359
+ await this.#retire(plan ?? (await this.#deps.planRetirement()));
360
+ } finally {
361
+ this.#deps.restart("the station's own credentials did not stop while it switched to cloud credentials");
362
+ }
363
+ return;
364
+ }
365
+ plan ??= await this.#deps.planRetirement();
366
+ await this.#retire(plan);
367
+ await this.#deps.claimFloor();
368
+ const committed = await this.#ask("commit");
369
+ if (!committed.ok) {
370
+ this.#block(
371
+ "cloud",
372
+ committed.error,
373
+ `the cloud did not record this station's switch yet: ${committed.error}; it runs no task meanwhile`,
374
+ );
375
+ return;
376
+ }
377
+ this.#blocker = null;
378
+ this.#write({ ...this.#record, step: "cloud" });
379
+ }
380
+ if (this.#record?.step === "cloud") {
381
+ await this.#deps.purge();
382
+ this.#write({ ...this.#record, step: "active" });
383
+ this.#deps.log("[credentials] local credential stores purged");
384
+ await this.#activate();
385
+ }
386
+ }
387
+
388
+ /** Whether the record is durable, proving it if that is not known yet:
389
+ * the record this process holds, exactly, synced at its path. One removed,
390
+ * replaced or unreadable since is written again from it (no process
391
+ * holds one behind the one on disk). Noted (once) while it cannot be. */
392
+ #synced(): boolean {
393
+ if (this.#durable) return true;
394
+ const record = this.#record;
395
+ if (record === null) return false;
396
+ try {
397
+ if (sameRecord(readActivationRecord(this.#deps.recordPath), record)) {
398
+ syncRecordExactly(this.#deps.recordPath, record);
399
+ this.#durable = true;
400
+ } else {
401
+ this.#write(record);
402
+ }
403
+ } catch (error) {
404
+ this.#block(
405
+ "runner",
406
+ error,
407
+ `the activation record is not known to be on disk yet (${message(error)}): the switch waits, running no task, ` +
408
+ "until it is",
409
+ );
410
+ return false;
411
+ }
412
+ return this.#durable;
413
+ }
414
+
415
+ /** Says `active` once the runner may: its `active` record durable, and the
416
+ * recovery sweep done (see the header). */
417
+ async #activate(): Promise<void> {
418
+ if (this.#announced || !this.#synced()) return;
419
+ if (!this.#deps.swept()) {
420
+ // At boot the container runtime's pass sweeps, before it reattaches
421
+ // anything; this joins in only once that pass gave up.
422
+ if (this.#activeAtBoot && !this.#sweepsItself) {
423
+ this.#note("the station says it is active once the boot recovery sweep is done; it runs no task until then");
424
+ return;
425
+ }
426
+ try {
427
+ await this.#deps.sweep();
428
+ } catch (error) {
429
+ this.#block(
430
+ "runner",
431
+ error,
432
+ `the recovery sweep failed (${message(error)}): the station stays pending, running no task, and sweeps again later`,
433
+ );
434
+ return;
435
+ }
436
+ }
437
+ this.#announce();
438
+ }
439
+
440
+ /** `active`, said once per process: at boot for a runner that started
441
+ * active, else the transition. A failure leaves it pending, withdrawn. */
442
+ #announce(): void {
443
+ if (this.#announced) return;
444
+ this.#announcing = true;
445
+ let failure: unknown = null;
446
+ try {
447
+ this.#deps.onActive(this.#activeAtBoot ? "boot" : "transition");
448
+ } catch (error) {
449
+ failure = error ?? new Error("onActive threw");
450
+ } finally {
451
+ this.#announcing = false;
452
+ }
453
+ if (failure === null) {
454
+ this.#announced = true;
455
+ this.#blocker = null;
456
+ this.#deps.log("[credentials] tasks run on cloud credentials");
457
+ return;
458
+ }
459
+ this.#block(
460
+ "runner",
461
+ failure,
462
+ `saying the station is active failed (${message(failure)}): it is pending again, and tries again later`,
463
+ );
464
+ try {
465
+ this.#deps.onActiveWithdrawn();
466
+ } catch (error) {
467
+ this.#note(`withdrawing what was advertised failed: ${message(error)}`);
468
+ }
469
+ }
470
+
471
+ /** The retirement, its reports owed durably before they are sent, and
472
+ * sent; rejects when anything could not be proven removed. */
473
+ async #retire(plan: Retirement): Promise<void> {
474
+ const { stopped, problem } = await this.#deps.retire(plan);
475
+ const owed = new Map((this.#record?.stopped ?? []).map((report) => [report.taskId, report]));
476
+ for (const report of stopped) owed.set(report.taskId, report);
477
+ this.#write({ step: this.#record?.step ?? "local", stopped: [...owed.values()] });
478
+ this.#reportOwed();
479
+ if (problem !== null) throw new Error(problem);
480
+ }
481
+
482
+ #reportOwed(): void {
483
+ const record = this.#record;
484
+ if (record === null || record.stopped.length === 0) return;
485
+ const owed = this.#deps.reportStopped(record.stopped);
486
+ if (owed.length !== record.stopped.length) this.#write({ ...record, stopped: owed });
487
+ }
488
+
489
+ /** From its rename on it is the record, even if syncing its directory
490
+ * then fails, so a failure past the rename is never taken for no record
491
+ * at all; but it is known to be durable only once that sync is done, and
492
+ * nothing irreversible runs on it before (`#synced`). */
493
+ #write(record: ActivationRecord): void {
494
+ const written: ActivationRecord = { step: record.step, stopped: record.stopped };
495
+ writeActivationRecord(this.#deps.recordPath, written, () => {
496
+ this.#record = written;
497
+ this.#durable = false;
498
+ this.#lastNote = null;
499
+ });
500
+ syncRecordExactly(this.#deps.recordPath, written);
501
+ this.#durable = true;
502
+ }
503
+
504
+ /** One phase asked of the cloud. An ask that could not be made or was not
505
+ * answered says nothing about the switch: transient. */
506
+ async #ask(phase: CredentialsActivationPhase): Promise<ActivationAnswer> {
507
+ try {
508
+ return await this.#deps.ask(phase);
509
+ } catch (error) {
510
+ throw new TransientActivationError(message(error));
511
+ }
512
+ }
513
+
514
+ /** A refusal or blocker: logged, and what `status()` says unless it is
515
+ * transient and a substantive one is already said (see the header). */
516
+ #block(by: CredentialsSwitchStuck["by"], why: unknown, line: string): void {
517
+ this.#note(line);
518
+ const transient = isTransient(why);
519
+ if (transient && this.#blocker !== null && this.#blocker.transient !== true) return;
520
+ const redact = this.#deps.redact ?? ((text: string) => text);
521
+ const detail = bounded(redact(oneLine(typeof why === "string" ? why : message(why))), MAX_CREDENTIALS_SWITCH_DETAIL_CHARS);
522
+ const next: CredentialsSwitchStuck = { by, detail, since: (this.#deps.now ?? Date.now)() };
523
+ const structured = why as { blockers?: unknown; unprovenProjects?: unknown } | null;
524
+ const blockers = stringList(structured?.blockers);
525
+ if (blockers.length > 0) {
526
+ next.blockers = blockers
527
+ .slice(0, MAX_CREDENTIALS_SWITCH_BLOCKERS)
528
+ .map((blocker) => bounded(redact(oneLine(blocker)), MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS));
529
+ }
530
+ const projects = stringList(structured?.unprovenProjects);
531
+ if (projects.length > 0) {
532
+ next.composeProjects = { names: projects.slice(0, MAX_CREDENTIALS_SWITCH_PROJECTS), total: projects.length };
533
+ }
534
+ if (transient) next.transient = true;
535
+ const previous = this.#blocker;
536
+ const allProjects = projects.join(" ");
537
+ // The same blocker again keeps when it was first seen. "The same" counts
538
+ // every project, not only the ones the status has room for.
539
+ if (previous !== null && sameBlocker(previous, next) && allProjects === this.#blockerProjects) return;
540
+ this.#blocker = next;
541
+ this.#blockerProjects = allProjects;
542
+ // The status names at most MAX_CREDENTIALS_SWITCH_PROJECTS, and the
543
+ // line above is cut short: the log names every one, once per blocker,
544
+ // for the operator Connections sends here for the rest.
545
+ if (projects.length > 0) {
546
+ this.#deps.log(
547
+ `[credentials] Compose projects nothing proves Moondeck's (${projects.length}), each to remove ` +
548
+ `(docker compose -p <name> down) or rename: ${allProjects}`,
549
+ );
550
+ }
551
+ }
552
+
553
+ /** `onStatusChange` when `status()` says something new. */
554
+ #publish(): void {
555
+ const now = JSON.stringify(this.status());
556
+ if (now === this.#published) return;
557
+ this.#published = now;
558
+ try {
559
+ this.#deps.onStatusChange?.();
560
+ } catch (error) {
561
+ this.#note(`advertising the switch's status failed: ${message(error)}`);
562
+ }
563
+ }
564
+
565
+ /** The same note twice in a row is logged once: a refusal is asked about
566
+ * again every few minutes. */
567
+ #note(line: string): void {
568
+ if (line === this.#lastNote) return;
569
+ this.#lastNote = line;
570
+ this.#deps.log(`[credentials] ${line}`);
571
+ }
572
+ }
573
+
574
+ class TransientActivationError extends Error {
575
+ readonly transient = true;
576
+ }
577
+
578
+ function isTransient(why: unknown): boolean {
579
+ return why !== null && typeof why === "object" && (why as { transient?: unknown }).transient === true;
580
+ }
581
+
582
+ function sameBlocker(a: CredentialsSwitchStuck, b: CredentialsSwitchStuck): boolean {
583
+ const { since: _a, ...left } = a;
584
+ const { since: _b, ...right } = b;
585
+ return JSON.stringify(left) === JSON.stringify(right);
586
+ }
587
+
588
+ function stringList(value: unknown): string[] {
589
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item !== "") : [];
590
+ }
591
+
592
+ function oneLine(text: string): string {
593
+ return text.replace(/\s*\n\s*(?:- )?/g, "; ").trim();
594
+ }
595
+
596
+ function bounded(text: string, max: number): string {
597
+ const nonEmpty = text === "" ? "unknown" : text;
598
+ return nonEmpty.length <= max ? nonEmpty : `${nonEmpty.slice(0, max - 1)}…`;
599
+ }
600
+
601
+ /** The record; null when there is none. One the runner cannot read is
602
+ * `local` with nothing owed (see the header). */
603
+ export function readActivationRecord(path: string): ActivationRecord | null {
604
+ let raw: string;
605
+ try {
606
+ raw = readFileSync(path, "utf8");
607
+ } catch (error) {
608
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
609
+ return { step: "local", stopped: [], damaged: true };
610
+ }
611
+ return parseActivationRecord(raw);
612
+ }
613
+
614
+ /** A record's text as the runner reads it: unreadable is `local`, damaged. */
615
+ function parseActivationRecord(raw: string): ActivationRecord {
616
+ try {
617
+ const parsed = JSON.parse(raw) as { schemaVersion?: unknown; step?: unknown; stopped?: unknown };
618
+ const step = parsed.step as ActivationStep;
619
+ if (parsed.schemaVersion === RECORD_SCHEMA_VERSION && STEPS.includes(step)) {
620
+ const stopped = Array.isArray(parsed.stopped) && parsed.stopped.every(isStoppedReport) ? parsed.stopped : null;
621
+ if (stopped !== null) return { step, stopped };
622
+ }
623
+ } catch {
624
+ // Falls through: unreadable.
625
+ }
626
+ return { step: "local", stopped: [], damaged: true };
627
+ }
628
+
629
+ function isStoppedReport(value: unknown): value is StoppedReport {
630
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
631
+ const report = value as Record<string, unknown>;
632
+ return (
633
+ Object.keys(report).length === 2 &&
634
+ isSafeHostTaskId(report.taskId) &&
635
+ (report.runEpoch === null || (Number.isSafeInteger(report.runEpoch) && (report.runEpoch as number) >= 1))
636
+ );
637
+ }
638
+
639
+ /** Written whole or not at all, and durable before it counts. `renamed`
640
+ * runs the moment the new record is in place, before its directory is
641
+ * made durable. */
642
+ export function writeActivationRecord(path: string, record: ActivationRecord, renamed?: () => void): void {
643
+ const body = { schemaVersion: RECORD_SCHEMA_VERSION, step: record.step, stopped: record.stopped };
644
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
645
+ writeFileSync(tmp, `${JSON.stringify(body)}\n`, { mode: 0o600 });
646
+ const file = openSync(tmp, "r");
647
+ try {
648
+ fsyncSync(file);
649
+ } finally {
650
+ closeSync(file);
651
+ }
652
+ renameSync(tmp, path);
653
+ renamed?.();
654
+ syncDirectoryOf(path);
655
+ }
656
+
657
+ /** Both say the same record; a damaged one is none. */
658
+ function sameRecord(a: ActivationRecord | null, b: ActivationRecord): boolean {
659
+ return (
660
+ a !== null &&
661
+ !a.damaged &&
662
+ !b.damaged &&
663
+ a.step === b.step &&
664
+ JSON.stringify(a.stopped) === JSON.stringify(b.stopped)
665
+ );
666
+ }
667
+
668
+ /** Proves `path` names `expected` on disk: the regular file it names (no
669
+ * link followed) says exactly that, its bytes are synced, its directory
670
+ * is synced, and it is still that file at that name afterwards (what the
671
+ * directory sync made durable is it, not a removal or a replacement). */
672
+ function syncRecordExactly(path: string, expected: ActivationRecord): void {
673
+ const file = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
674
+ try {
675
+ const opened = fstatSync(file);
676
+ if (!opened.isFile()) throw new Error(`${path} is not a regular file`);
677
+ const onDisk = parseActivationRecord(readFileSync(file, "utf8"));
678
+ if (!sameRecord(onDisk, expected)) throw new Error(`${path} is not the record this station holds`);
679
+ fsyncSync(file);
680
+ syncDirectoryOf(path);
681
+ const named = lstatSync(path);
682
+ if (named.dev !== opened.dev || named.ino !== opened.ino) {
683
+ throw new Error(`${path} was replaced while it was synced`);
684
+ }
685
+ } finally {
686
+ closeSync(file);
687
+ }
688
+ }
689
+
690
+ /** Makes the entries of `path`'s directory durable: a rename into it. */
691
+ function syncDirectoryOf(path: string): void {
692
+ const dir = openSync(dirname(path), "r");
693
+ try {
694
+ fsyncSync(dir);
695
+ } finally {
696
+ closeSync(dir);
697
+ }
698
+ }
699
+
700
+ /** An error as the runner's log says it. A purge's names everything it could
701
+ * not remove and what to do about each: all of them are listed, one to a
702
+ * line, so that the operator can resolve them in one go. */
703
+ function message(error: unknown): string {
704
+ if (error instanceof AggregateError) {
705
+ const each = error.errors.map((inner: unknown) => `\n - ${message(inner)}`).join("");
706
+ return `${error.message}:${each}`.slice(0, 8_000);
707
+ }
708
+ return (error instanceof Error ? error.message : String(error)).slice(0, 600);
709
+ }