@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,682 @@
1
+ /**
2
+ * ADR-100 L2 authenticated task inventory.
3
+ *
4
+ * This is deliberately positive evidence: an unavailable Docker query can
5
+ * omit resources, but can never turn that omission into permission to delete.
6
+ * One immutable, sorted snapshot is retained for the active scan so duplicate
7
+ * cursor requests are idempotent while cleanup mutates the underlying host.
8
+ */
9
+
10
+ import { lstatSync, opendirSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+
13
+ import { sql } from "drizzle-orm";
14
+
15
+ import { dockerCli, type DockerResult } from "./docker-exec";
16
+ import { containerRuntimeProblem } from "./container-runtime";
17
+ import { env } from "./env";
18
+ import { getDb, schema } from "./db";
19
+ import {
20
+ MAX_HOST_TASK_INVENTORY_PAGE_SIZE,
21
+ MAX_HOST_TASK_INVENTORY_SCAN_ID_CHARS,
22
+ MAX_HOST_TASK_INVENTORY_TASK_ID_CHARS,
23
+ type HostTaskInventoryEntry,
24
+ type HostTaskInventorySource,
25
+ } from "../src/protocol";
26
+ import {
27
+ assertSafeHostTaskId,
28
+ isSafeHostTaskId,
29
+ } from "./task-identity";
30
+ import {
31
+ taskContainerBackend,
32
+ taskContainerCli,
33
+ } from "./task-container-cli";
34
+ import {
35
+ appleTaskContainerName,
36
+ appleTaskStoreVolumeName,
37
+ } from "./task-environment/apple-container";
38
+
39
+ export {
40
+ assertSafeHostTaskId,
41
+ isSafeHostTaskId,
42
+ SAFE_HOST_TASK_ID,
43
+ } from "./task-identity";
44
+
45
+ const SOURCE_ORDER: readonly HostTaskInventorySource[] = [
46
+ "db",
47
+ "session",
48
+ "event",
49
+ "workspace",
50
+ "compose",
51
+ "network",
52
+ "volume",
53
+ "preview",
54
+ ];
55
+
56
+ const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
57
+ const PREVIEW_TASK_LABEL = "io.moondeck.preview-sidecar.task";
58
+ export const MAX_HOST_TASK_INVENTORY_SNAPSHOT_ENTRIES = 10_000;
59
+ const MAX_HOST_TASK_INVENTORY_DOCKER_OUTPUT_BYTES = 2 * 1024 * 1024;
60
+
61
+ export function isSafeInventoryScanId(value: unknown): value is string {
62
+ return (
63
+ typeof value === "string" &&
64
+ value.length > 0 &&
65
+ value.length <= MAX_HOST_TASK_INVENTORY_SCAN_ID_CHARS &&
66
+ // Scan ids are correlation values only, but controls/whitespace make logs
67
+ // and duplicate-page diagnosis ambiguous. Keep the wire boundary exact.
68
+ /^[A-Za-z0-9_-]+$/.test(value)
69
+ );
70
+ }
71
+
72
+ interface InventorySnapshot {
73
+ entries: HostTaskInventoryEntry[];
74
+ runtimeEnumerated: boolean;
75
+ }
76
+
77
+ export interface HostTaskInventoryPage {
78
+ entries: HostTaskInventoryEntry[];
79
+ nextCursor: string | null;
80
+ runtimeEnumerated: boolean;
81
+ }
82
+
83
+ export interface HostTaskInventoryDeps {
84
+ hostTaskIds(): string[];
85
+ sessionTaskIds(): string[];
86
+ eventTaskIds(): string[];
87
+ workspaceTaskIds(): string[];
88
+ runtimeReady(): boolean;
89
+ docker(args: string[]): Promise<DockerResult>;
90
+ /** Whether the pinned backend is apple-container (round 4: inventory and
91
+ * the orphan proof were Docker-only, so apple orphans were invisible and
92
+ * apple GC could never prune local state). */
93
+ appleBackend(): boolean;
94
+ /** Apple container inventory: `list --all --format json`. */
95
+ appleList(): Promise<DockerResult>;
96
+ /** Apple exact-name/image probes through the bundled CLI. */
97
+ appleCli(args: string[]): Promise<DockerResult>;
98
+ }
99
+
100
+ function databaseTaskIds(
101
+ table:
102
+ | typeof schema.hostTasks
103
+ | typeof schema.hostAgentSessions
104
+ | typeof schema.hostEvents,
105
+ ): string[] {
106
+ const rows = getDb()
107
+ .selectDistinct({ taskId: table.taskId })
108
+ .from(table)
109
+ .where(
110
+ sql`length(${table.taskId}) between 1 and ${MAX_HOST_TASK_INVENTORY_TASK_ID_CHARS}`,
111
+ )
112
+ .limit(MAX_HOST_TASK_INVENTORY_SNAPSHOT_ENTRIES + 1)
113
+ .all();
114
+ if (rows.length > MAX_HOST_TASK_INVENTORY_SNAPSHOT_ENTRIES) {
115
+ throw new Error("host inventory DB source exceeds snapshot limit");
116
+ }
117
+ return rows.map((row) => row.taskId);
118
+ }
119
+
120
+ function workspaceTaskIds(): string[] {
121
+ const root = resolve(env.workspaceRoot, "tasks");
122
+ let directory: ReturnType<typeof opendirSync>;
123
+ try {
124
+ directory = opendirSync(root);
125
+ } catch {
126
+ return [];
127
+ }
128
+ const taskIds: string[] = [];
129
+ try {
130
+ for (;;) {
131
+ const entry = directory.readSync();
132
+ if (!entry) break;
133
+ const taskId = entry.name;
134
+ if (!isSafeHostTaskId(taskId)) continue;
135
+ let realDirectory = false;
136
+ try {
137
+ const stat = lstatSync(resolve(root, taskId));
138
+ realDirectory = stat.isDirectory() && !stat.isSymbolicLink();
139
+ } catch {
140
+ // A concurrent taskDown removed it. Positive omission is safe.
141
+ }
142
+ if (!realDirectory) continue;
143
+ taskIds.push(taskId);
144
+ if (taskIds.length > MAX_HOST_TASK_INVENTORY_SNAPSHOT_ENTRIES) {
145
+ throw new Error("host inventory workspace exceeds snapshot limit");
146
+ }
147
+ }
148
+ } finally {
149
+ directory.closeSync();
150
+ }
151
+ return taskIds;
152
+ }
153
+
154
+ const defaultDeps: HostTaskInventoryDeps = {
155
+ hostTaskIds: () => databaseTaskIds(schema.hostTasks),
156
+ sessionTaskIds: () => databaseTaskIds(schema.hostAgentSessions),
157
+ eventTaskIds: () => databaseTaskIds(schema.hostEvents),
158
+ workspaceTaskIds,
159
+ runtimeReady: () => containerRuntimeProblem() === null,
160
+ docker: (args) =>
161
+ dockerCli(args, {
162
+ timeoutMs: 30_000,
163
+ maxOutputBytes: MAX_HOST_TASK_INVENTORY_DOCKER_OUTPUT_BYTES,
164
+ }),
165
+ appleBackend: () => taskContainerBackend().apple,
166
+ appleList: () =>
167
+ taskContainerCli(["list", "--all", "--format", "json"], {
168
+ timeoutMs: 30_000,
169
+ maxOutputBytes: MAX_HOST_TASK_INVENTORY_DOCKER_OUTPUT_BYTES,
170
+ }),
171
+ appleCli: (args) =>
172
+ taskContainerCli(args, {
173
+ timeoutMs: 30_000,
174
+ maxOutputBytes: MAX_HOST_TASK_INVENTORY_DOCKER_OUTPUT_BYTES,
175
+ }),
176
+ };
177
+
178
+ function addEvidence(
179
+ evidence: Map<string, Set<HostTaskInventorySource>>,
180
+ taskId: string,
181
+ source: HostTaskInventorySource,
182
+ ): void {
183
+ if (!isSafeHostTaskId(taskId)) return;
184
+ let sources = evidence.get(taskId);
185
+ if (!sources) {
186
+ if (evidence.size >= MAX_HOST_TASK_INVENTORY_SNAPSHOT_ENTRIES) {
187
+ throw new Error("host inventory snapshot exceeds task limit");
188
+ }
189
+ sources = new Set();
190
+ evidence.set(taskId, sources);
191
+ }
192
+ sources.add(source);
193
+ }
194
+
195
+ type AppleInventoryRow = { id: string; taskLabel: string | null };
196
+
197
+ /** Strict apple inventory shape (rounds 5+6). Every row must be structurally
198
+ * valid — an object whose configuration.id is a non-empty string and whose
199
+ * io.moondeck.task label, when present, is a string. Anything else invalidates
200
+ * the WHOLE inventory (returns null): a [null] entry or a malformed label
201
+ * could be hiding exactly the labeled container a caller needs to see, so a
202
+ * partially-readable answer is no answer. */
203
+ function parseAppleInventoryRows(stdout: string): AppleInventoryRow[] | null {
204
+ let parsed: unknown;
205
+ try {
206
+ parsed = JSON.parse(stdout);
207
+ } catch {
208
+ return null;
209
+ }
210
+ if (!Array.isArray(parsed)) return null;
211
+ const rows: AppleInventoryRow[] = [];
212
+ for (const row of parsed) {
213
+ if (typeof row !== "object" || row === null) return null;
214
+ const configuration = (row as { configuration?: unknown }).configuration;
215
+ if (typeof configuration !== "object" || configuration === null) {
216
+ return null;
217
+ }
218
+ const id = (configuration as { id?: unknown }).id;
219
+ if (typeof id !== "string" || id === "") return null;
220
+ const labels = (configuration as { labels?: unknown }).labels;
221
+ if (labels === undefined || labels === null) {
222
+ rows.push({ id, taskLabel: null });
223
+ continue;
224
+ }
225
+ if (typeof labels !== "object" || Array.isArray(labels)) return null;
226
+ const label = (labels as Record<string, unknown>)["io.moondeck.task"];
227
+ if (label === undefined) {
228
+ rows.push({ id, taskLabel: null });
229
+ continue;
230
+ }
231
+ if (typeof label !== "string") return null;
232
+ rows.push({ id, taskLabel: label });
233
+ }
234
+ return rows;
235
+ }
236
+
237
+ type AppleVolumeInventoryRow = { name: string; taskLabel: string | null };
238
+
239
+ /** ADR-118: strict volume-inventory shape, same discipline as containers —
240
+ * `volume ls --format json` rows carry configuration.name (volumes have no
241
+ * configuration.id) and optional labels; any malformed row invalidates the
242
+ * WHOLE inventory, because a partially-readable answer could be hiding
243
+ * exactly the labeled volume the orphan proof exists to find. */
244
+ function parseAppleVolumeInventoryRows(
245
+ stdout: string,
246
+ ): AppleVolumeInventoryRow[] | null {
247
+ let parsed: unknown;
248
+ try {
249
+ parsed = JSON.parse(stdout);
250
+ } catch {
251
+ return null;
252
+ }
253
+ if (!Array.isArray(parsed)) return null;
254
+ const rows: AppleVolumeInventoryRow[] = [];
255
+ for (const row of parsed) {
256
+ if (typeof row !== "object" || row === null) return null;
257
+ const configuration = (row as { configuration?: unknown }).configuration;
258
+ if (typeof configuration !== "object" || configuration === null) {
259
+ return null;
260
+ }
261
+ const name = (configuration as { name?: unknown }).name;
262
+ if (typeof name !== "string" || name === "") return null;
263
+ const labels = (configuration as { labels?: unknown }).labels;
264
+ if (labels === undefined || labels === null) {
265
+ rows.push({ name, taskLabel: null });
266
+ continue;
267
+ }
268
+ if (typeof labels !== "object" || Array.isArray(labels)) return null;
269
+ const label = (labels as Record<string, unknown>)["io.moondeck.task"];
270
+ if (label === undefined) {
271
+ rows.push({ name, taskLabel: null });
272
+ continue;
273
+ }
274
+ if (typeof label !== "string") return null;
275
+ rows.push({ name, taskLabel: label });
276
+ }
277
+ return rows;
278
+ }
279
+
280
+ function composeTaskId(project: string): string | null {
281
+ if (!project.startsWith("task-")) return null;
282
+ const taskId = project.slice("task-".length);
283
+ return isSafeHostTaskId(taskId) ? taskId : null;
284
+ }
285
+
286
+ function outputLines(result: DockerResult): string[] {
287
+ return result.stdout
288
+ .split(/\r?\n/)
289
+ .map((line) => line.trim())
290
+ .filter(Boolean);
291
+ }
292
+
293
+ async function buildSnapshot(
294
+ deps: HostTaskInventoryDeps,
295
+ ): Promise<InventorySnapshot> {
296
+ const evidence = new Map<string, Set<HostTaskInventorySource>>();
297
+ for (const taskId of deps.hostTaskIds()) addEvidence(evidence, taskId, "db");
298
+ for (const taskId of deps.sessionTaskIds()) {
299
+ addEvidence(evidence, taskId, "session");
300
+ }
301
+ for (const taskId of deps.eventTaskIds()) {
302
+ addEvidence(evidence, taskId, "event");
303
+ }
304
+ for (const taskId of deps.workspaceTaskIds()) {
305
+ addEvidence(evidence, taskId, "workspace");
306
+ }
307
+
308
+ // The container-runtime module owns the authoritative readiness verdict.
309
+ // When it is already degraded, four potentially expensive CLI timeouts
310
+ // cannot add trustworthy negative evidence, so return local positives now.
311
+ if (!deps.runtimeReady()) {
312
+ return {
313
+ entries: inventoryEntries(evidence),
314
+ runtimeEnumerated: false,
315
+ };
316
+ }
317
+
318
+ if (deps.appleBackend()) {
319
+ // One inventory call covers containers; apple tasks have no compose
320
+ // networks/volumes or preview sidecars. One ownership rule everywhere
321
+ // (round 5): a task-shaped NAME is evidence only when the io.moondeck.task
322
+ // label derives it — a foreign container occupying the reserved shape
323
+ // must not feed the GC that deletes by that name. The label alone is
324
+ // evidence regardless of the name (a renamed container still carries
325
+ // it). Round 6: a structurally invalid inventory (any malformed row)
326
+ // is no inventory — it could be hiding a labeled container.
327
+ const listed = await deps.appleList();
328
+ let runtimeEnumerated = false;
329
+ if (listed.status === 0) {
330
+ const rows = parseAppleInventoryRows(listed.stdout);
331
+ if (rows !== null) {
332
+ runtimeEnumerated = true;
333
+ for (const row of rows) {
334
+ if (row.taskLabel !== null) {
335
+ addEvidence(evidence, row.taskLabel, "compose");
336
+ }
337
+ }
338
+ }
339
+ }
340
+ return {
341
+ entries: inventoryEntries(evidence),
342
+ runtimeEnumerated,
343
+ };
344
+ }
345
+
346
+ const [compose, networks, volumes, previews] = await Promise.all([
347
+ deps.docker([
348
+ "ps",
349
+ "-a",
350
+ "--filter",
351
+ `label=${COMPOSE_PROJECT_LABEL}`,
352
+ "--format",
353
+ `{{.Label \"${COMPOSE_PROJECT_LABEL}\"}}`,
354
+ ]),
355
+ deps.docker([
356
+ "network",
357
+ "ls",
358
+ "--filter",
359
+ `label=${COMPOSE_PROJECT_LABEL}`,
360
+ "--format",
361
+ `{{.Label \"${COMPOSE_PROJECT_LABEL}\"}}`,
362
+ ]),
363
+ deps.docker([
364
+ "volume",
365
+ "ls",
366
+ "--filter",
367
+ `label=${COMPOSE_PROJECT_LABEL}`,
368
+ "--format",
369
+ `{{.Label \"${COMPOSE_PROJECT_LABEL}\"}}`,
370
+ ]),
371
+ deps.docker([
372
+ "ps",
373
+ "-a",
374
+ "--filter",
375
+ `label=${PREVIEW_TASK_LABEL}`,
376
+ "--format",
377
+ `{{.Label \"${PREVIEW_TASK_LABEL}\"}}`,
378
+ ]),
379
+ ]);
380
+
381
+ for (const project of outputLines(compose)) {
382
+ const taskId = composeTaskId(project);
383
+ if (taskId) addEvidence(evidence, taskId, "compose");
384
+ }
385
+ for (const project of outputLines(networks)) {
386
+ const taskId = composeTaskId(project);
387
+ if (taskId) addEvidence(evidence, taskId, "network");
388
+ }
389
+ for (const project of outputLines(volumes)) {
390
+ const taskId = composeTaskId(project);
391
+ if (taskId) addEvidence(evidence, taskId, "volume");
392
+ }
393
+ for (const taskId of outputLines(previews)) {
394
+ addEvidence(evidence, taskId, "preview");
395
+ }
396
+
397
+ return {
398
+ entries: inventoryEntries(evidence),
399
+ runtimeEnumerated: [compose, networks, volumes, previews].every(
400
+ (result) => result.status === 0,
401
+ ),
402
+ };
403
+ }
404
+
405
+ function inventoryEntries(
406
+ evidence: Map<string, Set<HostTaskInventorySource>>,
407
+ ): HostTaskInventoryEntry[] {
408
+ return [...evidence.entries()]
409
+ .map(([taskId, sources]) => ({
410
+ taskId,
411
+ sources: SOURCE_ORDER.filter((source) => sources.has(source)),
412
+ }))
413
+ .sort((left, right) =>
414
+ left.taskId < right.taskId ? -1 : left.taskId > right.taskId ? 1 : 0,
415
+ );
416
+ }
417
+
418
+ /** One-active-scan snapshot cache. A new scan supersedes the old connection's
419
+ * state; duplicate requests for the same scan share the same build promise. */
420
+ export class HostTaskInventorySnapshots {
421
+ private scanId: string | null = null;
422
+ private snapshot: Promise<InventorySnapshot> | null = null;
423
+
424
+ constructor(private readonly deps: HostTaskInventoryDeps = defaultDeps) {}
425
+
426
+ clear(): void {
427
+ this.scanId = null;
428
+ this.snapshot = null;
429
+ }
430
+
431
+ async page(request: {
432
+ scanId: string;
433
+ cursor: string | null;
434
+ limit: number;
435
+ }): Promise<HostTaskInventoryPage> {
436
+ if (!isSafeInventoryScanId(request.scanId)) {
437
+ throw new Error("invalid host inventory scan id");
438
+ }
439
+ if (
440
+ !Number.isSafeInteger(request.limit) ||
441
+ request.limit < 1 ||
442
+ request.limit > MAX_HOST_TASK_INVENTORY_PAGE_SIZE
443
+ ) {
444
+ throw new Error("invalid host inventory page limit");
445
+ }
446
+ if (request.cursor !== null && !isSafeHostTaskId(request.cursor)) {
447
+ throw new Error("invalid host inventory cursor");
448
+ }
449
+
450
+ if (request.cursor === null) {
451
+ if (this.scanId !== request.scanId || !this.snapshot) {
452
+ this.scanId = request.scanId;
453
+ this.snapshot = buildSnapshot(this.deps);
454
+ }
455
+ } else if (this.scanId !== request.scanId || !this.snapshot) {
456
+ throw new Error("unknown host inventory scan");
457
+ }
458
+
459
+ const snapshot = await this.snapshot;
460
+ let start = 0;
461
+ if (request.cursor !== null) {
462
+ const cursorIndex = snapshot.entries.findIndex(
463
+ (entry) => entry.taskId === request.cursor,
464
+ );
465
+ if (cursorIndex < 0) throw new Error("unknown host inventory cursor");
466
+ start = cursorIndex + 1;
467
+ }
468
+ const entries = snapshot.entries.slice(start, start + request.limit);
469
+ const hasMore = start + entries.length < snapshot.entries.length;
470
+ return {
471
+ entries,
472
+ nextCursor: hasMore ? (entries.at(-1)?.taskId ?? null) : null,
473
+ runtimeEnumerated: snapshot.runtimeEnumerated,
474
+ };
475
+ }
476
+ }
477
+
478
+ export const hostTaskInventorySnapshots = new HostTaskInventorySnapshots();
479
+
480
+ /** Final orphan-GC proof. Never interpret a failed Docker query as absence. */
481
+ export async function proveOrphanTaskResourcesAbsent(
482
+ taskId: string,
483
+ docker: HostTaskInventoryDeps["docker"] = defaultDeps.docker,
484
+ appleDeps: Pick<HostTaskInventoryDeps, "appleBackend" | "appleCli"> = defaultDeps,
485
+ ): Promise<void> {
486
+ assertSafeHostTaskId(taskId);
487
+ if (appleDeps.appleBackend()) {
488
+ // Apple task resources: the fixed-name container, the derived image, and
489
+ // the ADR-118 store volume. No compose networks or anonymous volumes
490
+ // exist there. Absence is proven only by the exact not-found grammars (a
491
+ // failed query is never absence).
492
+ // Derive the apple name from the branch's own backend decision, not the
493
+ // global pin — this proof runs exactly when appleBackend() says apple.
494
+ const containerName = appleTaskContainerName(taskId);
495
+ const inspectedContainer = await appleDeps.appleCli([
496
+ "inspect",
497
+ containerName,
498
+ ]);
499
+ if (inspectedContainer.status === 0) {
500
+ throw new Error(`orphan proof: container ${containerName} still exists`);
501
+ }
502
+ // Round 6: absence needs a SETTLED nonzero exit (a killed probe with a
503
+ // stale stderr line proves nothing) plus empty stdout and the exact
504
+ // whole-line grammar.
505
+ const containerAbsent =
506
+ typeof inspectedContainer.status === "number" &&
507
+ inspectedContainer.stdout.trim() === "" &&
508
+ inspectedContainer.stderr
509
+ .split("\n")
510
+ .some(
511
+ (line) =>
512
+ line.trim() === `Error: container not found: ${containerName}`,
513
+ );
514
+ if (!containerAbsent) {
515
+ throw new Error(
516
+ `orphan proof: container ${containerName} could not be proven absent (exit ${inspectedContainer.status ?? "killed"})`,
517
+ );
518
+ }
519
+ const imageRef = `moondeck-task-${taskId.toLowerCase()}`;
520
+ const inspected = await appleDeps.appleCli(["image", "inspect", imageRef]);
521
+ const imageAbsent =
522
+ typeof inspected.status === "number" &&
523
+ inspected.status !== 0 &&
524
+ inspected.stdout.trim() === "" &&
525
+ inspected.stderr
526
+ .split("\n")
527
+ .some((line) => line.trim() === `Error: image not found: ${imageRef}`);
528
+ if (!imageAbsent) {
529
+ throw new Error(
530
+ inspected.status === 0
531
+ ? `orphan proof: derived image ${imageRef} still exists`
532
+ : `orphan proof: derived image ${imageRef} could not be proven absent (exit ${inspected.status ?? "killed"})`,
533
+ );
534
+ }
535
+ // ADR-118: the store volume must be gone too — GC purging local state
536
+ // while the volume survives would leak it forever (nothing else ever
537
+ // deletes by this name; task-down is the remediation the caller retries).
538
+ const storeVolume = appleTaskStoreVolumeName(taskId);
539
+ const inspectedVolume = await appleDeps.appleCli([
540
+ "volume",
541
+ "inspect",
542
+ storeVolume,
543
+ ]);
544
+ const volumeAbsent =
545
+ typeof inspectedVolume.status === "number" &&
546
+ inspectedVolume.status !== 0 &&
547
+ inspectedVolume.stdout.trim() === "" &&
548
+ inspectedVolume.stderr
549
+ .split("\n")
550
+ .some(
551
+ (line) => line.trim() === `Error: volume not found: ${storeVolume}`,
552
+ );
553
+ if (!volumeAbsent) {
554
+ throw new Error(
555
+ inspectedVolume.status === 0
556
+ ? `orphan proof: store volume ${storeVolume} still exists`
557
+ : `orphan proof: store volume ${storeVolume} could not be proven absent (exit ${inspectedVolume.status ?? "killed"})`,
558
+ );
559
+ }
560
+ // The same ownership set the enumeration uses (round 5): a RENAMED
561
+ // container still carrying this task's label is this task's resource,
562
+ // and the exact-name check above cannot see it. An unreadable inventory
563
+ // proves nothing.
564
+ const listed = await appleDeps.appleCli(["list", "--all", "--format", "json"]);
565
+ if (listed.status !== 0) {
566
+ throw new Error(
567
+ `orphan proof: container inventory failed (exit ${listed.status ?? "killed"})`,
568
+ );
569
+ }
570
+ const rows = parseAppleInventoryRows(listed.stdout);
571
+ if (rows === null) {
572
+ // Round 6: [null] entries or malformed labels invalidate the whole
573
+ // answer — a partially-readable inventory could be hiding exactly the
574
+ // labeled container this proof exists to find.
575
+ throw new Error("orphan proof: container inventory was unparseable");
576
+ }
577
+ for (const row of rows) {
578
+ if (row.taskLabel === taskId) {
579
+ throw new Error(
580
+ `orphan proof: container ${row.id} still carries this task's ownership label`,
581
+ );
582
+ }
583
+ }
584
+ // ADR-118, same ownership rule for volumes: a RENAMED volume still
585
+ // carrying this task's label is this task's store, and the exact-name
586
+ // probe above cannot see it. An unreadable inventory proves nothing.
587
+ const volumesListed = await appleDeps.appleCli([
588
+ "volume",
589
+ "ls",
590
+ "--format",
591
+ "json",
592
+ ]);
593
+ if (volumesListed.status !== 0) {
594
+ throw new Error(
595
+ `orphan proof: volume inventory failed (exit ${volumesListed.status ?? "killed"})`,
596
+ );
597
+ }
598
+ const volumeRows = parseAppleVolumeInventoryRows(volumesListed.stdout);
599
+ if (volumeRows === null) {
600
+ throw new Error("orphan proof: volume inventory was unparseable");
601
+ }
602
+ for (const row of volumeRows) {
603
+ if (row.taskLabel === taskId) {
604
+ throw new Error(
605
+ `orphan proof: volume ${row.name} still carries this task's ownership label`,
606
+ );
607
+ }
608
+ }
609
+ // Workspace absence, exactly like the Docker proof below.
610
+ const appleTaskDir = resolve(env.workspaceRoot, "tasks", taskId);
611
+ try {
612
+ lstatSync(appleTaskDir);
613
+ } catch (error) {
614
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
615
+ throw error;
616
+ }
617
+ throw new Error("task workspace remains after task teardown");
618
+ }
619
+ const composeProject = `task-${taskId}`;
620
+ const checks: Array<{ label: string; args: string[] }> = [
621
+ {
622
+ label: "compose containers",
623
+ args: [
624
+ "ps",
625
+ "-aq",
626
+ "--filter",
627
+ `label=${COMPOSE_PROJECT_LABEL}=${composeProject}`,
628
+ ],
629
+ },
630
+ {
631
+ label: "compose networks",
632
+ args: [
633
+ "network",
634
+ "ls",
635
+ "-q",
636
+ "--filter",
637
+ `label=${COMPOSE_PROJECT_LABEL}=${composeProject}`,
638
+ ],
639
+ },
640
+ {
641
+ label: "compose volumes",
642
+ args: [
643
+ "volume",
644
+ "ls",
645
+ "-q",
646
+ "--filter",
647
+ `label=${COMPOSE_PROJECT_LABEL}=${composeProject}`,
648
+ ],
649
+ },
650
+ {
651
+ label: "preview sidecars",
652
+ args: [
653
+ "ps",
654
+ "-aq",
655
+ "--filter",
656
+ `label=${PREVIEW_TASK_LABEL}=${taskId}`,
657
+ ],
658
+ },
659
+ ];
660
+ const results = await Promise.all(checks.map((check) => docker(check.args)));
661
+ for (let index = 0; index < checks.length; index++) {
662
+ const check = checks[index]!;
663
+ const result = results[index]!;
664
+ if (result.status !== 0) {
665
+ throw new Error(
666
+ `could not prove ${check.label} absent: ${result.stderr.trim().slice(0, 200) || "docker did not answer"}`,
667
+ );
668
+ }
669
+ if (outputLines(result).length > 0) {
670
+ throw new Error(`${check.label} remain after task teardown`);
671
+ }
672
+ }
673
+
674
+ const taskDir = resolve(env.workspaceRoot, "tasks", taskId);
675
+ try {
676
+ lstatSync(taskDir);
677
+ } catch (error) {
678
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
679
+ throw error;
680
+ }
681
+ throw new Error("task workspace remains after task teardown");
682
+ }