@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,424 @@
1
+ /**
2
+ * ADR-154 §13: pre-v1 task runtimes are retired as the runner moves to
3
+ * cloud credentials. Every runtime it has was created on its own
4
+ * credentials (the mode was `legacy` for each one's whole life), and holds
5
+ * them: Compose interpolated the default slot's engine tokens into the
6
+ * container env, and legacy copies of Codex, Grok, OpenCode, `gh` and SSH
7
+ * credentials sit in its home, recorded nowhere.
8
+ *
9
+ * Two halves, because removing a runtime cannot be undone:
10
+ *
11
+ * - `planLegacyTaskRuntimeRetirement` changes nothing. From one snapshot
12
+ * (the task rows, the identity fence on those same rows, the pinned
13
+ * engine) it finds every blocker known in advance, and rejects with all of
14
+ * them, so a runner that cannot finish stays on v0 exactly as it was: the
15
+ * container runtime not operational; a task row of another backend or
16
+ * engine than the one pinned (the persisted identity fence); a machine
17
+ * (ADR-121) or self runtime, which cannot be removed with its worktree
18
+ * kept; a row naming a Compose project that is not its own; a container at
19
+ * a task's name that cannot be proven that task's; a `task-*` Compose
20
+ * project no row with a runtime names, which nothing proves Uai's
21
+ * (`task-*` alone is no ownership, see orchestrator.ts's runtime
22
+ * isolation), and which the operator removes or renames. A row with no
23
+ * runtime (a task that went down keeps its row, its runtime fields
24
+ * cleared) neither blocks nor vouches for anything. The plan records the
25
+ * engine it was made against. Its rejection is a `RetirementBlockedError`:
26
+ * each blocker apart, and every unproven project named, so the cloud can
27
+ * show the operator all of them with the command that clears each
28
+ * (ADR-154 §13 build notes, 2026-10-27). Such a project is never removed
29
+ * here: a Compose label proves neither who made a stack nor that nothing
30
+ * uses it now (another runner may share this Docker and workspace root).
31
+ * - `retireLegacyTaskRuntimes` carries a plan out, only after the runner has
32
+ * committed to the switch. Each task's check, Stop and removal share its
33
+ * lifecycle slot: before its assignment closes and again before anything
34
+ * is removed, the live engine is re-probed and the task's row checked
35
+ * against the plan's engine, and so again before each command of the
36
+ * removal after its first and once more before the task counts as
37
+ * retired, so an absence answered by another engine never counts. A daemon
38
+ * replaced since (even at the same socket), or a row gone or cleared since
39
+ * (its task went down), is a problem, never a removal: the next plan
40
+ * decides. Each task stops, and its
41
+ * container goes with its worktree kept, exactly as task-up's step 2
42
+ * removes a previous container before a Resume (Docker by the Compose
43
+ * project its task id names, proven by an empty label query; Apple by
44
+ * name, its exact identity proven before it is deleted, and its absence
45
+ * by the CLI's not-found answer). An Apple task container no row names
46
+ * goes too when its label names it exactly (apple-uninstall-teardown.ts's
47
+ * proof). It is idempotent: a runtime already gone counts as removed, so
48
+ * a later attempt finishes what one left. Every removal is attempted; what
49
+ * could not be proven is in `problems`, so the caller can still report the
50
+ * tasks that did go.
51
+ */
52
+
53
+ import type { DockerResult } from "./docker-exec";
54
+ import { SAFE_HOST_TASK_ID, isSafeHostTaskId } from "./task-identity";
55
+
56
+ /** A task row, as far as its retirement goes. */
57
+ export interface RetirementTask {
58
+ taskId: string;
59
+ /** `uai_host_tasks.environment_provider`; null on rows that predate it
60
+ * (Docker Compose). */
61
+ provider: string | null;
62
+ composeProject: string | null;
63
+ /** The task directory (task-up's `$task_dir`). */
64
+ worktreePath: string | null;
65
+ /** It names a runtime (a locator, or a legacy Compose project). */
66
+ hasRuntime: boolean;
67
+ }
68
+
69
+ /** The container engine the runner has pinned. */
70
+ export interface RetirementMachine {
71
+ backend: string;
72
+ endpoint: string;
73
+ engineId: string;
74
+ }
75
+
76
+ /** What one plan reads, all at once. */
77
+ export interface RetirementSnapshot {
78
+ tasks: RetirementTask[];
79
+ /** Why the container runtime cannot be used now; null when it can. */
80
+ runtimeProblem: string | null;
81
+ /** The persisted identity fence over these same rows: every row's runtime
82
+ * is on `machine`. */
83
+ fence: { ok: boolean; detail?: string };
84
+ machine: RetirementMachine | null;
85
+ }
86
+
87
+ type Cli = (args: string[], opts?: { timeoutMs?: number; maxOutputBytes?: number }) => Promise<DockerResult>;
88
+
89
+ export interface RetirementPlanDeps {
90
+ snapshot: () => RetirementSnapshot;
91
+ /** The pinned container backend. */
92
+ apple: boolean;
93
+ docker: Cli;
94
+ /** The Apple container CLI (only called when `apple`). */
95
+ appleCli: Cli;
96
+ fileExists: (path: string) => boolean;
97
+ }
98
+
99
+ export type PlannedRemoval =
100
+ | { taskId: string; kind: "docker"; project: string; composeFile: string | null }
101
+ | { taskId: string; kind: "apple"; container: string };
102
+
103
+ export interface RetirementPlan {
104
+ /** The engine the plan was made against. */
105
+ machine: RetirementMachine;
106
+ /** Every runtime a task row names, whether or not anything is left of it. */
107
+ removals: PlannedRemoval[];
108
+ /** Apple task containers no row names, each labelled with the task its
109
+ * name names. */
110
+ appleOrphans: string[];
111
+ }
112
+
113
+ export interface RetirementDeps extends RetirementPlanDeps {
114
+ /** Why the live engine, or this task's row (null: the engine alone), no
115
+ * longer matches the plan; null while it does. Called inside the task's
116
+ * lifecycle slot, right before its Stop and right before its removal. */
117
+ current: (taskId: string | null) => Promise<string | null>;
118
+ /** The task's Stop, for a caller holding its lifecycle slot. */
119
+ stop: (taskId: string) => Promise<{ ok: true } | { ok: false; error: string }>;
120
+ /** `work` in the task's lifecycle slot. */
121
+ inLifecycle: <T>(taskId: string, work: () => Promise<T>) => Promise<T>;
122
+ log: (line: string) => void;
123
+ }
124
+
125
+ /**
126
+ * The plan's refusal: every blocker apart (`blockers`), and apart from them
127
+ * the `task-*` Compose projects nothing proves Uai's (all of them, for the
128
+ * operator to remove or rename). The message names both, as the log has.
129
+ * `transient`: nothing is wrong yet, the runner only cannot look now (its
130
+ * container runtime is still preparing); the next attempt looks again.
131
+ */
132
+ export class RetirementBlockedError extends Error {
133
+ readonly blockers: string[];
134
+ readonly unprovenProjects: string[];
135
+ readonly transient: boolean;
136
+ constructor(message: string, options: { blockers?: string[]; unprovenProjects?: string[]; transient?: boolean } = {}) {
137
+ super(message);
138
+ this.name = "RetirementBlockedError";
139
+ this.blockers = options.blockers ?? [];
140
+ this.unprovenProjects = options.unprovenProjects ?? [];
141
+ this.transient = options.transient === true;
142
+ }
143
+ }
144
+
145
+ export interface RetirementReport {
146
+ /** Tasks whose runtime is gone (or was already). */
147
+ retired: string[];
148
+ /** Apple task containers no row named, removed. */
149
+ orphans: string[];
150
+ /** What could not be proven removed: the retirement is not done. */
151
+ problems: string[];
152
+ }
153
+
154
+ const DOCKER_PROVIDER = "docker-compose";
155
+ const APPLE_PROVIDER = "apple-container";
156
+ const TASK_ID = SAFE_HOST_TASK_ID.source.slice(1, -1);
157
+ const TASK_PROJECT = new RegExp(`^task-${TASK_ID}$`);
158
+ const APPLE_TASK_CONTAINER = new RegExp(`^moondeck-task-(${TASK_ID})-app$`);
159
+
160
+ export async function planLegacyTaskRuntimeRetirement(deps: RetirementPlanDeps): Promise<RetirementPlan> {
161
+ const blockers: string[] = [];
162
+ const snapshot = deps.snapshot();
163
+ if (snapshot.runtimeProblem !== null || snapshot.machine === null) {
164
+ throw new RetirementBlockedError(
165
+ `task runtimes cannot be retired now: the container runtime is not ready (${snapshot.runtimeProblem ?? "no engine is pinned"})`,
166
+ { transient: true },
167
+ );
168
+ }
169
+ if (!snapshot.fence.ok) blockers.push(snapshot.fence.detail ?? "the task rows do not match the pinned container runtime");
170
+
171
+ const removals: PlannedRemoval[] = [];
172
+ // A row with no runtime takes no part: it blocks nothing, and vouches for
173
+ // no Compose project or container of its task's name.
174
+ const rows = snapshot.tasks.filter((task) => task.hasRuntime);
175
+ for (const task of rows) {
176
+ if (!isSafeHostTaskId(task.taskId)) {
177
+ blockers.push(`a task row has an unsafe id ${JSON.stringify(task.taskId)}`);
178
+ continue;
179
+ }
180
+ const provider = task.provider ?? DOCKER_PROVIDER;
181
+ if (provider !== DOCKER_PROVIDER && provider !== APPLE_PROVIDER) {
182
+ blockers.push(`task ${task.taskId}: its ${provider} runtime cannot be removed with its worktree kept; close the task first`);
183
+ continue;
184
+ }
185
+ if ((provider === APPLE_PROVIDER) !== deps.apple) {
186
+ blockers.push(`task ${task.taskId}: its runtime belongs to another container backend`);
187
+ continue;
188
+ }
189
+ if (provider === DOCKER_PROVIDER) {
190
+ const project = `task-${task.taskId}`;
191
+ if (task.composeProject !== null && task.composeProject !== project) {
192
+ blockers.push(`task ${task.taskId}: its row names Compose project ${JSON.stringify(task.composeProject)}, not its own`);
193
+ continue;
194
+ }
195
+ removals.push({ taskId: task.taskId, kind: "docker", project, composeFile: composeFile(deps, task) });
196
+ } else {
197
+ // appleTaskContainerName, without its assertion: rows are checked above.
198
+ const container = `moondeck-task-${task.taskId}-app`;
199
+ try {
200
+ if ((await appleIdentity(deps.appleCli, container)) === "foreign") {
201
+ blockers.push(`task ${task.taskId}: a container named ${container} exists but cannot be proven this task's; remove or rename it`);
202
+ continue;
203
+ }
204
+ } catch (error) {
205
+ blockers.push(`task ${task.taskId}: ${message(error)}`);
206
+ continue;
207
+ }
208
+ removals.push({ taskId: task.taskId, kind: "apple", container });
209
+ }
210
+ }
211
+
212
+ const appleOrphans: string[] = [];
213
+ const unprovenProjects: string[] = [];
214
+ try {
215
+ if (deps.apple) {
216
+ const named = new Set(rows.map((task) => `moondeck-task-${task.taskId}-app`));
217
+ for (const container of await listOwnedAppleTaskContainers(deps.appleCli)) {
218
+ if (!named.has(container)) appleOrphans.push(container);
219
+ }
220
+ } else {
221
+ const named = new Set(rows.map((task) => `task-${task.taskId}`));
222
+ for (const project of await listTaskProjects(deps.docker)) {
223
+ if (named.has(project)) continue;
224
+ unprovenProjects.push(project);
225
+ }
226
+ }
227
+ } catch (error) {
228
+ blockers.push(`the task runtime inventory failed: ${message(error)}`);
229
+ }
230
+
231
+ if (blockers.length > 0 || unprovenProjects.length > 0) {
232
+ const all = [
233
+ ...blockers,
234
+ ...unprovenProjects.map(
235
+ (project) =>
236
+ `Compose project ${project} has containers but no task on this station: nothing proves it Moondeck's, so it is not removed. ` +
237
+ `If it was a task's, remove it (docker compose -p ${project} down); otherwise rename it`,
238
+ ),
239
+ ];
240
+ throw new RetirementBlockedError(`task runtimes cannot all be retired: ${all.join("; ").slice(0, 1500)}`, {
241
+ blockers,
242
+ unprovenProjects,
243
+ });
244
+ }
245
+ return { machine: snapshot.machine, removals, appleOrphans };
246
+ }
247
+
248
+ export async function retireLegacyTaskRuntimes(plan: RetirementPlan, deps: RetirementDeps): Promise<RetirementReport> {
249
+ const problems: string[] = [];
250
+ const retired: string[] = [];
251
+ for (const removal of plan.removals) {
252
+ try {
253
+ await deps.inLifecycle(removal.taskId, async () => {
254
+ const staleBeforeStop = await deps.current(removal.taskId);
255
+ if (staleBeforeStop !== null) throw new Error(`not stopped: ${staleBeforeStop}`);
256
+ const stopped = await deps.stop(removal.taskId);
257
+ if (!stopped.ok && stopped.error !== "task has no running stack") {
258
+ // Its removal below proves what matters: the runtime gone.
259
+ deps.log(`[credentials] task ${removal.taskId}: stop before retirement failed (${stopped.error}); removing its runtime`);
260
+ }
261
+ const fence = async (): Promise<void> => {
262
+ const stale = await deps.current(removal.taskId);
263
+ if (stale !== null) throw new Error(`not removed: ${stale}`);
264
+ };
265
+ await (removal.kind === "apple"
266
+ ? removeAppleContainer(deps.appleCli, removal.container, fence)
267
+ : removeComposeProject(deps.docker, removal.project, removal.composeFile, fence));
268
+ });
269
+ retired.push(removal.taskId);
270
+ } catch (error) {
271
+ problems.push(`task ${removal.taskId}: ${message(error)}`);
272
+ }
273
+ }
274
+ const orphans: string[] = [];
275
+ for (const container of plan.appleOrphans) {
276
+ try {
277
+ const fence = async (): Promise<void> => {
278
+ const stale = await deps.current(null);
279
+ if (stale !== null) throw new Error(`${container} not removed: ${stale}`);
280
+ };
281
+ await removeAppleContainer(deps.appleCli, container, fence);
282
+ orphans.push(container);
283
+ } catch (error) {
284
+ problems.push(message(error));
285
+ }
286
+ }
287
+ return { retired, orphans, problems };
288
+ }
289
+
290
+ function composeFile(deps: RetirementPlanDeps, task: RetirementTask): string | null {
291
+ if (task.worktreePath === null) return null;
292
+ const file = `${task.worktreePath}/.uai/docker-compose.yml`;
293
+ return deps.fileExists(file) ? file : null;
294
+ }
295
+
296
+ /** task-up's step 2 for Docker, and then whatever Compose left, by label.
297
+ * `fence` re-proves the engine before every command and once more after
298
+ * the last, so each command and the absence proof are the planned
299
+ * engine's. */
300
+ async function removeComposeProject(
301
+ docker: Cli,
302
+ project: string,
303
+ file: string | null,
304
+ fence: () => Promise<void>,
305
+ ): Promise<void> {
306
+ if (!TASK_PROJECT.test(project)) throw new Error(`refusing to remove Compose project ${JSON.stringify(project)}`);
307
+ await fence();
308
+ await docker(["compose", "-p", project, ...(file === null ? [] : ["-f", file]), "down", "--remove-orphans"], {
309
+ timeoutMs: 180_000,
310
+ maxOutputBytes: 1024 * 1024,
311
+ });
312
+ await fence();
313
+ let left = await projectContainers(docker, project);
314
+ if (left.length > 0) {
315
+ await fence();
316
+ await docker(["rm", "--force", ...left], { timeoutMs: 120_000, maxOutputBytes: 256 * 1024 });
317
+ await fence();
318
+ left = await projectContainers(docker, project);
319
+ }
320
+ await fence();
321
+ if (left.length > 0) throw new Error(`${left.length} container(s) of ${project} are still present`);
322
+ }
323
+
324
+ async function projectContainers(docker: Cli, project: string): Promise<string[]> {
325
+ const listed = await docker(["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`], {
326
+ timeoutMs: 30_000,
327
+ maxOutputBytes: 256 * 1024,
328
+ });
329
+ if (listed.status !== 0) {
330
+ throw new Error(`could not list ${project}'s containers: ${listed.stderr.trim().slice(0, 200) || "docker ps failed"}`);
331
+ }
332
+ return lines(listed.stdout);
333
+ }
334
+
335
+ /** Every Compose project of a task-shaped name, whatever its containers' state. */
336
+ async function listTaskProjects(docker: Cli): Promise<string[]> {
337
+ const listed = await docker(
338
+ ["ps", "-a", "--filter", "label=com.docker.compose.project", "--format", '{{.Label "com.docker.compose.project"}}'],
339
+ { timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 },
340
+ );
341
+ if (listed.status !== 0) throw new Error(listed.stderr.trim().slice(0, 200) || "docker ps failed");
342
+ return [...new Set(lines(listed.stdout).filter((project) => TASK_PROJECT.test(project)))];
343
+ }
344
+
345
+ /**
346
+ * Whether the container at a task's name is that task's: absent, or exactly
347
+ * one record whose id is the name, labelled `io.moondeck.task` with the task id
348
+ * the name derives from (task-down.sh's proof). Anything else is foreign.
349
+ * Throws when the CLI gives no answer that settles it.
350
+ */
351
+ async function appleIdentity(cli: Cli, name: string): Promise<"absent" | "owned" | "foreign"> {
352
+ const taskId = APPLE_TASK_CONTAINER.exec(name)?.[1];
353
+ if (taskId === undefined) return "foreign";
354
+ const inspected = await cli(["inspect", name], { timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 });
355
+ if (inspected.status !== 0) {
356
+ if (
357
+ inspected.stdout.trim() === "" &&
358
+ inspected.stderr.split("\n").some((line) => line.trim() === `Error: container not found: ${name}`)
359
+ ) {
360
+ return "absent";
361
+ }
362
+ throw new Error(`could not inspect ${name}: ${inspected.stderr.trim().slice(0, 200) || "inspect failed"}`);
363
+ }
364
+ let records: unknown;
365
+ try {
366
+ records = JSON.parse(inspected.stdout);
367
+ } catch {
368
+ return "foreign";
369
+ }
370
+ if (!Array.isArray(records) || records.length !== 1) return "foreign";
371
+ const record = records[0] as { id?: unknown; configuration?: { labels?: Record<string, unknown> } } | null;
372
+ return record?.id === name && record.configuration?.labels?.["io.moondeck.task"] === taskId ? "owned" : "foreign";
373
+ }
374
+
375
+ /** task-up's step 2 for Apple: its identity proven, then stop, delete, and
376
+ * the not-found answer, `fence` re-proving the engine before every command
377
+ * and once more after the last. */
378
+ async function removeAppleContainer(cli: Cli, name: string, fence: () => Promise<void>): Promise<void> {
379
+ await fence();
380
+ const identity = await appleIdentity(cli, name);
381
+ await fence();
382
+ if (identity === "absent") return;
383
+ if (identity === "foreign") throw new Error(`container ${name} cannot be proven Moondeck's; it was not removed`);
384
+ await cli(["stop", "--time", "10", name], { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 });
385
+ await fence();
386
+ await cli(["delete", "--force", name], { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 });
387
+ await fence();
388
+ const after = await appleIdentity(cli, name).catch(() => "unknown");
389
+ await fence();
390
+ if (after !== "absent") throw new Error(`container ${name} could not be proven removed`);
391
+ }
392
+
393
+ /** Uai's task containers no matter their state: labelled `io.moondeck.task`
394
+ * with exactly the task their name names. */
395
+ async function listOwnedAppleTaskContainers(cli: Cli): Promise<string[]> {
396
+ const listed = await cli(["list", "--all", "--format", "json"], { timeoutMs: 30_000, maxOutputBytes: 8 * 1024 * 1024 });
397
+ if (listed.status !== 0) throw new Error(listed.stderr.trim().slice(0, 200) || "container list failed");
398
+ let rows: unknown;
399
+ try {
400
+ rows = JSON.parse(listed.stdout);
401
+ } catch {
402
+ throw new Error("the container list is not JSON");
403
+ }
404
+ if (!Array.isArray(rows)) throw new Error("the container list is not an array");
405
+ const names: string[] = [];
406
+ for (const row of rows as Array<{ configuration?: { id?: unknown; labels?: Record<string, unknown> } }>) {
407
+ const id = row?.configuration?.id;
408
+ if (typeof id !== "string") continue;
409
+ const taskId = APPLE_TASK_CONTAINER.exec(id)?.[1];
410
+ if (taskId !== undefined && row.configuration?.labels?.["io.moondeck.task"] === taskId) names.push(id);
411
+ }
412
+ return names;
413
+ }
414
+
415
+ function lines(text: string): string[] {
416
+ return text
417
+ .split("\n")
418
+ .map((line) => line.trim())
419
+ .filter(Boolean);
420
+ }
421
+
422
+ function message(error: unknown): string {
423
+ return (error instanceof Error ? error.message : String(error)).slice(0, 300);
424
+ }