@moondeck/station 0.0.1 → 1.0.1

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 (330) 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/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,1033 @@
1
+ import { and, eq, inArray, isNull, notInArray, or } from "drizzle-orm";
2
+
3
+ import { getDb, schema, type Db } from "./db";
4
+ import { isTerminal, TERMINAL_STATUSES } from "./task-status";
5
+ import { newId } from "./ulid";
6
+ import type { HostTask } from "../db/schema";
7
+ import type { TaskLaunchInput, TaskUpResult } from "../src/protocol";
8
+ import {
9
+ parseTaskEnvironmentLocator,
10
+ serializeTaskEnvironmentLocator,
11
+ type ProjectEnvMode,
12
+ type TaskEnvironmentLocator,
13
+ } from "./task-environment/types";
14
+
15
+ type DbExecutor = Pick<Db, "select" | "insert" | "update" | "delete">;
16
+
17
+ /** Lifecycle event kinds recorded for the local UI's `/api/events` (ADR-028). */
18
+ export type HostEventKind =
19
+ | "task.created"
20
+ | "task.started"
21
+ | "task.ended"
22
+ | "task.ship";
23
+
24
+ export function getHostTask(taskId: string): HostTask | null {
25
+ return (
26
+ getDb()
27
+ .select()
28
+ .from(schema.hostTasks)
29
+ .where(eq(schema.hostTasks.taskId, taskId))
30
+ .get() ?? null
31
+ );
32
+ }
33
+
34
+ /** Loader tasks whose authenticated gateway generation may still have a live
35
+ * runtime after this host process restarts. Terminal/stopped rows are never
36
+ * reactivated merely because an old credential file survived. */
37
+ export function activeLoaderHostTasks(): HostTask[] {
38
+ return getDb()
39
+ .select()
40
+ .from(schema.hostTasks)
41
+ .where(
42
+ and(
43
+ eq(schema.hostTasks.envMode, "loader"),
44
+ inArray(schema.hostTasks.statusMirror, ["starting", "running"]),
45
+ ),
46
+ )
47
+ .all();
48
+ }
49
+
50
+ /** ADR-154: the runs whose runtime survives this process's start running:
51
+ * each task marked running whose admitted run is open and established. */
52
+ export function runningTaskRuns(db: Db = getDb()): Array<{ taskId: string; runEpoch: number }> {
53
+ const rows = db
54
+ .select({ taskId: schema.hostTasks.taskId })
55
+ .from(schema.hostTasks)
56
+ .where(eq(schema.hostTasks.statusMirror, "running"))
57
+ .all();
58
+ return rows.flatMap(({ taskId }) => {
59
+ const run = readTaskRunEpochState(taskId, db);
60
+ return run !== null && run.assignmentOpen && run.runtimeEstablishedEpoch === run.highestRunEpoch && run.highestRunEpoch > 0
61
+ ? [{ taskId, runEpoch: run.highestRunEpoch }]
62
+ : [];
63
+ });
64
+ }
65
+
66
+ /** Remove a provisional first-seen row when admission loses a lifecycle gate. */
67
+ export function deleteHostTask(taskId: string, db: Db = getDb()): void {
68
+ // Row deletions are rare and load-bearing; a silent one cost a live
69
+ // machine-task forensic session (2026-08-27). Name the caller.
70
+ console.warn(
71
+ `[runtime-state] deleting host task row ${taskId}\n${(new Error().stack ?? "").split("\n").slice(2, 5).join("\n")}`,
72
+ );
73
+ db
74
+ .delete(schema.hostTasks)
75
+ .where(eq(schema.hostTasks.taskId, taskId))
76
+ .run();
77
+ }
78
+
79
+ /** Final orphan-GC metadata purge. Runtime/filesystem and credential proofs
80
+ * happen before this transaction; a SQLite failure leaves every task-owned
81
+ * row intact rather than acknowledging a partial metadata deletion. */
82
+ export function purgeHostTaskState(taskId: string, db: Db = getDb()): void {
83
+ console.warn(`[runtime-state] purging all host state for task ${taskId}`);
84
+ db.transaction((tx) => {
85
+ closeCurrentTaskRunAssignmentIn(tx, taskId);
86
+ tx.delete(schema.hostAgentSessions)
87
+ .where(eq(schema.hostAgentSessions.taskId, taskId))
88
+ .run();
89
+ tx.delete(schema.hostEvents)
90
+ .where(eq(schema.hostEvents.taskId, taskId))
91
+ .run();
92
+ tx.delete(schema.hostTasks)
93
+ .where(eq(schema.hostTasks.taskId, taskId))
94
+ .run();
95
+ });
96
+ }
97
+
98
+ /** A managed runtime may restart only when no task lifecycle is active. */
99
+ export function hasActiveHostTasks(): boolean {
100
+ return Boolean(
101
+ getDb()
102
+ .select({ taskId: schema.hostTasks.taskId })
103
+ .from(schema.hostTasks)
104
+ .where(inArray(schema.hostTasks.statusMirror, ["starting", "running"]))
105
+ .limit(1)
106
+ .get(),
107
+ );
108
+ }
109
+
110
+ /**
111
+ * A task lifecycle mutation is mid-flight: a taskUp between admission and its
112
+ * recorded result. Steady-state RUNNING tasks are deliberately not counted —
113
+ * durable sessions survive a managed service restart, so a version update
114
+ * needs only the mutations drained. The strict all-tasks predicate above
115
+ * remains for legacy conversion (which takes over a different service's live
116
+ * workload) and rollback. Live 2026-08-17: using the strict predicate for
117
+ * plain updates made every update fail while any task merely existed.
118
+ */
119
+ export function hasInFlightHostTaskLifecycle(): boolean {
120
+ return Boolean(
121
+ getDb()
122
+ .select({ taskId: schema.hostTasks.taskId })
123
+ .from(schema.hostTasks)
124
+ .where(eq(schema.hostTasks.statusMirror, "starting"))
125
+ .limit(1)
126
+ .get(),
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Whether a persisted workload makes destructive host maintenance unsafe.
132
+ * Only the closed set of cloud terminal states is removable. Null and future /
133
+ * malformed values fail closed so a newer or partially-written lifecycle row
134
+ * cannot be destroyed by an older CLI that does not understand it.
135
+ */
136
+ export function hostTaskBlocksMaintenance(status: string | null): boolean {
137
+ return status === null || !isTerminal(status);
138
+ }
139
+
140
+ /** Uninstall must preserve every local workload that is not provably terminal. */
141
+ export function hasNonTerminalHostTasks(): boolean {
142
+ return Boolean(
143
+ getDb()
144
+ .select({ taskId: schema.hostTasks.taskId })
145
+ .from(schema.hostTasks)
146
+ .where(
147
+ or(
148
+ isNull(schema.hostTasks.statusMirror),
149
+ notInArray(schema.hostTasks.statusMirror, [...TERMINAL_STATUSES]),
150
+ ),
151
+ )
152
+ .limit(1)
153
+ .get(),
154
+ );
155
+ }
156
+
157
+ export function upsertHostTask(
158
+ taskId: string,
159
+ values: Partial<Omit<HostTask, "taskId" | "updatedAt">>,
160
+ ): void {
161
+ upsertHostTaskIn(getDb(), taskId, values);
162
+ }
163
+
164
+ function upsertHostTaskIn(
165
+ db: DbExecutor,
166
+ taskId: string,
167
+ values: Partial<Omit<HostTask, "taskId" | "updatedAt">>,
168
+ ): void {
169
+ const now = Date.now();
170
+ db
171
+ .insert(schema.hostTasks)
172
+ .values({
173
+ taskId,
174
+ previewPorts: "[]",
175
+ ...values,
176
+ updatedAt: now,
177
+ })
178
+ .onConflictDoUpdate({
179
+ target: schema.hostTasks.taskId,
180
+ set: {
181
+ ...values,
182
+ updatedAt: now,
183
+ },
184
+ })
185
+ .run();
186
+ }
187
+
188
+ export function recordTaskUpResult(
189
+ taskId: string,
190
+ result: TaskUpResult,
191
+ environment: TaskEnvironmentLocator,
192
+ envMode: ProjectEnvMode,
193
+ db: Db = getDb(),
194
+ ): void {
195
+ upsertHostTaskIn(db, taskId, {
196
+ codeServerPort: result.codeServerPort ?? null,
197
+ previewPorts: JSON.stringify(result.previewPorts ?? []),
198
+ composeProject: result.composeProject,
199
+ worktreePath: result.worktreePath,
200
+ environmentProvider: environment.provider,
201
+ environmentLocator: serializeTaskEnvironmentLocator(environment),
202
+ envMode,
203
+ statusMirror: "running",
204
+ lockedAt: null,
205
+ startedAt: Date.now(),
206
+ endedAt: null,
207
+ });
208
+ }
209
+
210
+ export type TaskRunEpochAdmission =
211
+ | { kind: "legacy" }
212
+ | {
213
+ kind: "accepted";
214
+ relation: "advanced" | "retry";
215
+ previousRunEpoch: number;
216
+ hadTask: boolean;
217
+ }
218
+ | {
219
+ kind: "stale";
220
+ currentRunEpoch: number;
221
+ reason: "older" | "closed";
222
+ };
223
+
224
+ export interface TaskRunEpochState {
225
+ /** Highest v1 dispatch this runner has ever admitted for the task. */
226
+ highestRunEpoch: number;
227
+ /** False after Stop/teardown; only a strictly higher epoch may reopen it. */
228
+ assignmentOpen: boolean;
229
+ /** Zero until a runtime has successfully completed task-up for this epoch. */
230
+ runtimeEstablishedEpoch: number;
231
+ }
232
+
233
+ export type CloseTaskRunAssignmentResult =
234
+ | { kind: "missing" }
235
+ | { kind: "mismatch"; currentRunEpoch: number }
236
+ | { kind: "already_closed"; runEpoch: number }
237
+ | { kind: "closed"; runEpoch: number };
238
+
239
+ export type StoppedTaskRunAssignment =
240
+ | { kind: "legacy" }
241
+ | { kind: "closed"; runEpoch: number };
242
+
243
+ function persistedRunEpoch(value: number): number {
244
+ if (!Number.isSafeInteger(value) || value < 1) {
245
+ throw new Error("persisted assignment run epoch is invalid");
246
+ }
247
+ return value;
248
+ }
249
+
250
+ function taskRunEpochStateIn(
251
+ db: DbExecutor,
252
+ taskId: string,
253
+ ): TaskRunEpochState | null {
254
+ const assignment = db
255
+ .select()
256
+ .from(schema.hostTaskRunAssignments)
257
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
258
+ .get();
259
+ if (!assignment) return null;
260
+ const task = db
261
+ .select({ runEpoch: schema.hostTasks.runEpoch })
262
+ .from(schema.hostTasks)
263
+ .where(eq(schema.hostTasks.taskId, taskId))
264
+ .get();
265
+ return {
266
+ highestRunEpoch: persistedRunEpoch(assignment.highestRunEpoch),
267
+ assignmentOpen: assignment.assignmentOpen,
268
+ runtimeEstablishedEpoch: hostTaskRunEpoch(task ?? {}),
269
+ };
270
+ }
271
+
272
+ /** Read all three durable assignment facts from one SQLite snapshot. */
273
+ export function readTaskRunEpochState(
274
+ taskId: string,
275
+ db: Db = getDb(),
276
+ ): TaskRunEpochState | null {
277
+ return db.transaction((tx) => taskRunEpochStateIn(tx, taskId));
278
+ }
279
+
280
+ /**
281
+ * A runtime may be reused only when both authorities name this exact run:
282
+ * the assignment is still open at its monotonic floor, and task-up recorded
283
+ * the runtime as fully established. Legacy reuse remains available only on a
284
+ * task that has never crossed the v1 floor.
285
+ */
286
+ export function canReuseTaskRuntimeForRun(
287
+ taskId: string,
288
+ runEpoch: number | undefined,
289
+ db: Db = getDb(),
290
+ ): boolean {
291
+ if (
292
+ runEpoch !== undefined &&
293
+ (!Number.isSafeInteger(runEpoch) || runEpoch < 1)
294
+ ) {
295
+ throw new Error("task run epoch must be a positive safe integer");
296
+ }
297
+ return db.transaction((tx) => {
298
+ const state = taskRunEpochStateIn(tx, taskId);
299
+ const task = tx
300
+ .select({
301
+ runEpoch: schema.hostTasks.runEpoch,
302
+ statusMirror: schema.hostTasks.statusMirror,
303
+ })
304
+ .from(schema.hostTasks)
305
+ .where(eq(schema.hostTasks.taskId, taskId))
306
+ .get();
307
+ if (runEpoch === undefined) {
308
+ return (
309
+ state === null &&
310
+ task !== undefined &&
311
+ hostTaskRunEpoch(task) === 0 &&
312
+ task.statusMirror === "running"
313
+ );
314
+ }
315
+ return (
316
+ state !== null &&
317
+ state.assignmentOpen &&
318
+ state.highestRunEpoch === runEpoch &&
319
+ state.runtimeEstablishedEpoch === runEpoch &&
320
+ task?.statusMirror === "running"
321
+ );
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Persist the assignment fence as soon as a v1 task-up reaches the runner.
327
+ * The floor is deliberately independent of the removable runtime row. A
328
+ * same-epoch retry is admitted only while the assignment remains open; a
329
+ * higher epoch atomically reopens it, and nothing can lower it.
330
+ */
331
+ export function admitTaskRunEpoch(
332
+ taskId: string,
333
+ runEpoch: number | undefined,
334
+ db: Db = getDb(),
335
+ ): TaskRunEpochAdmission {
336
+ if (
337
+ runEpoch !== undefined &&
338
+ (!Number.isSafeInteger(runEpoch) || runEpoch < 1)
339
+ ) {
340
+ throw new Error("task run epoch must be a positive safe integer");
341
+ }
342
+ return db.transaction((tx) => {
343
+ const existingTask = tx
344
+ .select({ taskId: schema.hostTasks.taskId })
345
+ .from(schema.hostTasks)
346
+ .where(eq(schema.hostTasks.taskId, taskId))
347
+ .get();
348
+ const assignment = tx
349
+ .select()
350
+ .from(schema.hostTaskRunAssignments)
351
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
352
+ .get();
353
+ if (runEpoch === undefined) {
354
+ if (assignment !== undefined) {
355
+ throw new Error(
356
+ `legacy task-up is refused after run ${String(assignment.highestRunEpoch)} was admitted`,
357
+ );
358
+ }
359
+ return { kind: "legacy" } as const;
360
+ }
361
+ if (assignment === undefined) {
362
+ tx.insert(schema.hostTaskRunAssignments)
363
+ .values({
364
+ taskId,
365
+ highestRunEpoch: runEpoch,
366
+ assignmentOpen: true,
367
+ updatedAt: Date.now(),
368
+ })
369
+ .run();
370
+ return {
371
+ kind: "accepted",
372
+ relation: "advanced",
373
+ previousRunEpoch: 0,
374
+ hadTask: existingTask !== undefined,
375
+ } as const;
376
+ }
377
+ const currentRunEpoch = persistedRunEpoch(assignment.highestRunEpoch);
378
+ if (runEpoch < currentRunEpoch) {
379
+ return { kind: "stale", currentRunEpoch, reason: "older" } as const;
380
+ }
381
+ if (runEpoch === currentRunEpoch) {
382
+ if (!assignment.assignmentOpen) {
383
+ return { kind: "stale", currentRunEpoch, reason: "closed" } as const;
384
+ }
385
+ return {
386
+ kind: "accepted",
387
+ relation: "retry",
388
+ previousRunEpoch: currentRunEpoch,
389
+ hadTask: existingTask !== undefined,
390
+ } as const;
391
+ }
392
+ tx.update(schema.hostTaskRunAssignments)
393
+ .set({
394
+ highestRunEpoch: runEpoch,
395
+ assignmentOpen: true,
396
+ updatedAt: Date.now(),
397
+ })
398
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
399
+ .run();
400
+ return {
401
+ kind: "accepted",
402
+ relation: "advanced",
403
+ previousRunEpoch: currentRunEpoch,
404
+ hadTask: existingTask !== undefined,
405
+ } as const;
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Close one exact assignment. A delayed Stop for an older run is harmless.
411
+ * The established-runtime epoch remains available for exact cleanup and
412
+ * forensics; the independent closed bit is what makes reuse impossible.
413
+ */
414
+ export function closeTaskRunAssignment(
415
+ taskId: string,
416
+ runEpoch: number,
417
+ db: Db = getDb(),
418
+ ): CloseTaskRunAssignmentResult {
419
+ if (!Number.isSafeInteger(runEpoch) || runEpoch < 1) {
420
+ throw new Error("task run epoch must be a positive safe integer");
421
+ }
422
+ return db.transaction((tx) => {
423
+ const assignment = tx
424
+ .select()
425
+ .from(schema.hostTaskRunAssignments)
426
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
427
+ .get();
428
+ if (!assignment) return { kind: "missing" } as const;
429
+ const currentRunEpoch = persistedRunEpoch(assignment.highestRunEpoch);
430
+ if (currentRunEpoch !== runEpoch) {
431
+ return { kind: "mismatch", currentRunEpoch } as const;
432
+ }
433
+ if (!assignment.assignmentOpen) {
434
+ return { kind: "already_closed", runEpoch } as const;
435
+ }
436
+ closeCurrentTaskRunAssignmentIn(tx, taskId);
437
+ return { kind: "closed", runEpoch } as const;
438
+ });
439
+ }
440
+
441
+ /**
442
+ * Close the exact assignment named by Stop. While the caller holds the task
443
+ * lifecycle slot, this may also adopt a pre-v1 runtime whose durable task row
444
+ * still has epoch zero. Persisting that adopted epoch closed prevents a
445
+ * delayed same-epoch task-up from reviving the runtime after Stop.
446
+ */
447
+ export function closeTaskRunAssignmentForStop(
448
+ taskId: string,
449
+ runEpoch: number,
450
+ db: Db = getDb(),
451
+ ): CloseTaskRunAssignmentResult {
452
+ if (!Number.isSafeInteger(runEpoch) || runEpoch < 1) {
453
+ throw new Error("task run epoch must be a positive safe integer");
454
+ }
455
+ return db.transaction((tx) => {
456
+ const assignment = tx
457
+ .select()
458
+ .from(schema.hostTaskRunAssignments)
459
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
460
+ .get();
461
+ if (assignment) {
462
+ const currentRunEpoch = persistedRunEpoch(assignment.highestRunEpoch);
463
+ if (currentRunEpoch !== runEpoch) {
464
+ return { kind: "mismatch", currentRunEpoch } as const;
465
+ }
466
+ if (!assignment.assignmentOpen) {
467
+ return { kind: "already_closed", runEpoch } as const;
468
+ }
469
+ closeCurrentTaskRunAssignmentIn(tx, taskId);
470
+ return { kind: "closed", runEpoch } as const;
471
+ }
472
+
473
+ const task = tx
474
+ .select({ runEpoch: schema.hostTasks.runEpoch })
475
+ .from(schema.hostTasks)
476
+ .where(eq(schema.hostTasks.taskId, taskId))
477
+ .get();
478
+ if (task === undefined || hostTaskRunEpoch(task) !== 0) {
479
+ return { kind: "missing" } as const;
480
+ }
481
+
482
+ const now = Date.now();
483
+ tx.insert(schema.hostTaskRunAssignments)
484
+ .values({
485
+ taskId,
486
+ highestRunEpoch: runEpoch,
487
+ assignmentOpen: false,
488
+ createdAt: now,
489
+ updatedAt: now,
490
+ })
491
+ .run();
492
+ return { kind: "closed", runEpoch } as const;
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Close and identify the assignment whose runtime was just proven stopped.
498
+ * The caller MUST hold the task lifecycle slot: that is what makes "current"
499
+ * the exact run associated with the physical stop, rather than whichever run
500
+ * happens to be current when a later status publisher wakes up.
501
+ */
502
+ export function closeCurrentTaskRunAssignmentForStoppedRuntime(
503
+ taskId: string,
504
+ db: Db = getDb(),
505
+ ): StoppedTaskRunAssignment {
506
+ return db.transaction((tx) => {
507
+ const assignment = tx
508
+ .select()
509
+ .from(schema.hostTaskRunAssignments)
510
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
511
+ .get();
512
+ if (!assignment) return { kind: "legacy" } as const;
513
+ const runEpoch = persistedRunEpoch(assignment.highestRunEpoch);
514
+ if (assignment.assignmentOpen) {
515
+ closeCurrentTaskRunAssignmentIn(tx, taskId);
516
+ }
517
+ return { kind: "closed", runEpoch } as const;
518
+ });
519
+ }
520
+
521
+ function closeCurrentTaskRunAssignmentIn(
522
+ db: DbExecutor,
523
+ taskId: string,
524
+ ): void {
525
+ const now = Date.now();
526
+ db.update(schema.hostTaskRunAssignments)
527
+ .set({ assignmentOpen: false, updatedAt: now })
528
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
529
+ .run();
530
+ }
531
+
532
+ /** Record successful task-up only for the exact still-open assignment. */
533
+ export function recordTaskRuntimeEstablished(
534
+ taskId: string,
535
+ runEpoch: number,
536
+ db: Db = getDb(),
537
+ ): void {
538
+ if (!Number.isSafeInteger(runEpoch) || runEpoch < 1) {
539
+ throw new Error("task run epoch must be a positive safe integer");
540
+ }
541
+ db.transaction((tx) => {
542
+ const state = taskRunEpochStateIn(tx, taskId);
543
+ if (
544
+ state === null ||
545
+ !state.assignmentOpen ||
546
+ state.highestRunEpoch !== runEpoch
547
+ ) {
548
+ throw new Error("task runtime does not match the open assignment");
549
+ }
550
+ const task = tx
551
+ .select({ statusMirror: schema.hostTasks.statusMirror })
552
+ .from(schema.hostTasks)
553
+ .where(eq(schema.hostTasks.taskId, taskId))
554
+ .get();
555
+ if (task?.statusMirror !== "running") {
556
+ throw new Error("task runtime has not completed task-up");
557
+ }
558
+ tx.update(schema.hostTasks)
559
+ .set({ runEpoch, updatedAt: Date.now() })
560
+ .where(eq(schema.hostTasks.taskId, taskId))
561
+ .run();
562
+ });
563
+ }
564
+
565
+ export function recordTaskStarting(
566
+ taskId: string,
567
+ runEpoch?: number,
568
+ db: Db = getDb(),
569
+ ): void {
570
+ if (
571
+ runEpoch !== undefined &&
572
+ (!Number.isSafeInteger(runEpoch) || runEpoch < 1)
573
+ ) {
574
+ throw new Error("task run epoch must be a positive safe integer");
575
+ }
576
+ db.transaction((tx) => {
577
+ const assignment = taskRunEpochStateIn(tx, taskId);
578
+ if (runEpoch === undefined) {
579
+ if (assignment !== null) {
580
+ throw new Error("legacy task start is refused after v1 admission");
581
+ }
582
+ } else if (
583
+ assignment === null ||
584
+ !assignment.assignmentOpen ||
585
+ assignment.highestRunEpoch !== runEpoch
586
+ ) {
587
+ throw new Error("task run epoch does not match the open assignment");
588
+ }
589
+ upsertHostTaskIn(tx, taskId, {
590
+ statusMirror: "starting",
591
+ lockedAt: Date.now(),
592
+ startedAt: Date.now(),
593
+ endedAt: null,
594
+ // A starting row is not proof that provisioning completed. The exact
595
+ // epoch is installed only by recordTaskRuntimeEstablished.
596
+ ...(runEpoch === undefined ? {} : { runEpoch: 0 }),
597
+ });
598
+ });
599
+ }
600
+
601
+ /** The successfully established runtime epoch; zero includes legacy rows and
602
+ * v1 rows still provisioning. Anything malformed fails closed. */
603
+ export function hostTaskRunEpoch(task: {
604
+ runEpoch?: number | null;
605
+ }): number {
606
+ const value = task.runEpoch ?? 0;
607
+ if (!Number.isSafeInteger(value) || value < 0) {
608
+ throw new Error("persisted task run epoch is invalid");
609
+ }
610
+ return value;
611
+ }
612
+
613
+ /** Persist the provider-owned crash-recovery identity before provisioning may
614
+ * create worktrees, containers, networks, or volumes. Status remains
615
+ * `starting`; the enriched locator is written by `recordTaskUpResult`. */
616
+ export function recordTaskEnvironmentPrepared(
617
+ taskId: string,
618
+ environment: TaskEnvironmentLocator,
619
+ envMode: ProjectEnvMode,
620
+ ): void {
621
+ upsertHostTask(taskId, {
622
+ environmentProvider: environment.provider,
623
+ environmentLocator: serializeTaskEnvironmentLocator(environment),
624
+ envMode,
625
+ });
626
+ }
627
+
628
+ export function recordTaskDown(
629
+ taskId: string,
630
+ status: string | undefined,
631
+ db: Db = getDb(),
632
+ ): void {
633
+ db.transaction((tx) => {
634
+ closeCurrentTaskRunAssignmentIn(tx, taskId);
635
+ upsertHostTaskIn(tx, taskId, {
636
+ codeServerPort: null,
637
+ previewPorts: "[]",
638
+ composeProject: null,
639
+ worktreePath: null,
640
+ environmentProvider: null,
641
+ environmentLocator: null,
642
+ envMode: null,
643
+ projectEnvSpec: null,
644
+ statusMirror: status ?? "killed",
645
+ lockedAt: null,
646
+ endedAt: Date.now(),
647
+ });
648
+ applyTaskTerminalHistoryRetentionInTransaction(tx, taskId);
649
+ });
650
+ }
651
+
652
+ export function recordTaskError(taskId: string): void {
653
+ upsertHostTask(taskId, {
654
+ statusMirror: "error",
655
+ lockedAt: null,
656
+ endedAt: Date.now(),
657
+ });
658
+ }
659
+
660
+ /**
661
+ * Persist the owner + project slugs the host received in TaskLaunchInput, so
662
+ * the local UI can show "who/what" for active tasks (ADR-028). The cloud
663
+ * remains the audit authority; this is a local read-convenience mirror.
664
+ */
665
+ export function recordTaskOwner(
666
+ taskId: string,
667
+ ownerUserId: string,
668
+ ownerEmail: string | null,
669
+ ownerName: string | null,
670
+ projectSlugs: string[],
671
+ engineAccountScope: string | null = null,
672
+ /** Lead project first — the order project env merges in. */
673
+ projectIds: string[] = [],
674
+ ): void {
675
+ upsertHostTask(taskId, {
676
+ ownerUserId,
677
+ ownerEmail,
678
+ ownerName,
679
+ projectSlugs: JSON.stringify(projectSlugs),
680
+ projectIds: JSON.stringify(projectIds),
681
+ engineAccountScope,
682
+ });
683
+ }
684
+
685
+ /** Legacy baked-machine project ids, lead first; `[]` for rows that predate
686
+ * the column or carry an unreadable value. Loader resolution uses projectEnvSpec. */
687
+ export function hostTaskProjectIds(task: { projectIds?: string | null }): string[] {
688
+ try {
689
+ const parsed: unknown = JSON.parse(task.projectIds ?? "[]");
690
+ return Array.isArray(parsed)
691
+ ? parsed.filter((id): id is string => typeof id === "string" && id.length > 0)
692
+ : [];
693
+ } catch {
694
+ return [];
695
+ }
696
+ }
697
+
698
+ /** Null is the durable meaning of a task created before ADR-153 PR2. */
699
+ export function hostTaskEnvMode(task: {
700
+ envMode?: string | null;
701
+ }): ProjectEnvMode {
702
+ if (task.envMode === null || task.envMode === undefined) return "baked";
703
+ if (task.envMode === "baked" || task.envMode === "loader") {
704
+ return task.envMode;
705
+ }
706
+ throw new Error(`unsupported task project env mode: ${task.envMode}`);
707
+ }
708
+
709
+ export type TaskSharedFilesMode = "off" | "ro" | "rw";
710
+
711
+ /**
712
+ * Normalize the trusted task-launch setting before it is persisted. The wire
713
+ * default has always been read-only; recovery records that decision rather
714
+ * than consulting the task-writable `.uai/files-mounted` marker.
715
+ */
716
+ export function normalizeTaskSharedFilesMode(
717
+ value: string | undefined,
718
+ ): TaskSharedFilesMode {
719
+ if (value === undefined) return "ro";
720
+ if (value === "off" || value === "ro" || value === "rw") return value;
721
+ throw new Error(`unsupported task shared-files mode: ${value}`);
722
+ }
723
+
724
+ /** Persist the normalized mode while the authoritative task launch exists. */
725
+ export function recordTaskSharedFilesMode(
726
+ taskId: string,
727
+ task: { sharedFiles?: string },
728
+ db: Db = getDb(),
729
+ ): TaskSharedFilesMode {
730
+ const mode = normalizeTaskSharedFilesMode(task.sharedFiles);
731
+ upsertHostTaskIn(db, taskId, { sharedFilesMode: mode });
732
+ return mode;
733
+ }
734
+
735
+ /**
736
+ * Read the trusted recovery authority. Null identifies a pre-Files-v1 row;
737
+ * malformed or newer values fail closed instead of becoming read-only.
738
+ */
739
+ export function hostTaskSharedFilesMode(task: {
740
+ sharedFilesMode?: string | null;
741
+ }): TaskSharedFilesMode | null {
742
+ const mode = task.sharedFilesMode;
743
+ if (mode === null || mode === undefined) return null;
744
+ if (mode === "off" || mode === "ro" || mode === "rw") return mode;
745
+ throw new Error(`unsupported persisted task shared-files mode: ${mode}`);
746
+ }
747
+
748
+ export interface TaskFilesRecoveryBinding {
749
+ locator: TaskEnvironmentLocator;
750
+ workspacePath: string;
751
+ mode: TaskSharedFilesMode;
752
+ }
753
+
754
+ /** Files may be staged before a recovered grant opens only from an exact,
755
+ * established v1 runtime row. A fresh/starting/different run has no binding;
756
+ * an exact running row with incomplete or non-self metadata is corruption and
757
+ * fails closed instead of letting root operate on a guessed workspace. */
758
+ export function taskFilesRecoveryBinding(
759
+ task: Pick<
760
+ HostTask,
761
+ | "statusMirror"
762
+ | "runEpoch"
763
+ | "environmentLocator"
764
+ | "worktreePath"
765
+ | "sharedFilesMode"
766
+ > | null,
767
+ assignment: { taskId: string; runEpoch: number },
768
+ ): TaskFilesRecoveryBinding | null {
769
+ if (
770
+ task === null ||
771
+ task.statusMirror !== "running" ||
772
+ hostTaskRunEpoch(task) !== assignment.runEpoch
773
+ ) {
774
+ return null;
775
+ }
776
+ if (task.environmentLocator === null || task.worktreePath === null) {
777
+ throw new Error("the recovered task has no complete runtime locator");
778
+ }
779
+ const mode = hostTaskSharedFilesMode(task);
780
+ if (mode === null) throw new Error("the recovered task predates Files v1");
781
+ const locator = parseTaskEnvironmentLocator(task.environmentLocator);
782
+ const value = locator.value;
783
+ if (
784
+ locator.provider !== "self" ||
785
+ typeof value !== "object" ||
786
+ value === null ||
787
+ Array.isArray(value) ||
788
+ (value as Record<string, unknown>).workspacePath !== "/workspace" ||
789
+ (value as Record<string, unknown>).taskId !== assignment.taskId
790
+ ) {
791
+ throw new Error("the recovered task does not have the canonical Files-v1 runtime");
792
+ }
793
+ return { locator, workspacePath: "/workspace", mode };
794
+ }
795
+
796
+ export interface TaskProjectEnvSpec {
797
+ schemaVersion: 1;
798
+ projects: Array<{
799
+ projectId: string;
800
+ position: number;
801
+ declaredNames: string[];
802
+ }>;
803
+ /** Cloud-computed, explicitly non-secret task literals (preview/theme). */
804
+ previewEnv: Record<string, string>;
805
+ }
806
+
807
+ const PROJECT_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
808
+
809
+ /** Build the value-free resolver input while the authoritative launch frame is present. */
810
+ export function taskProjectEnvSpec(
811
+ input: Pick<TaskLaunchInput, "projects" | "task">,
812
+ ): TaskProjectEnvSpec {
813
+ const projects = input.projects
814
+ .map((project) => {
815
+ let parsed: unknown = [];
816
+ try {
817
+ parsed = JSON.parse(project.env);
818
+ } catch {
819
+ // Match task-up's established malformed-env behavior: no declarations.
820
+ }
821
+ const names: string[] = [];
822
+ const seen = new Set<string>();
823
+ if (Array.isArray(parsed)) {
824
+ for (const declaration of parsed) {
825
+ if (
826
+ !declaration ||
827
+ typeof declaration !== "object" ||
828
+ Array.isArray(declaration)
829
+ ) {
830
+ continue;
831
+ }
832
+ const name = (declaration as { key?: unknown }).key;
833
+ if (
834
+ typeof name === "string" &&
835
+ PROJECT_ENV_NAME.test(name) &&
836
+ !seen.has(name)
837
+ ) {
838
+ seen.add(name);
839
+ names.push(name);
840
+ }
841
+ }
842
+ }
843
+ return {
844
+ projectId: project.id,
845
+ position: project.position,
846
+ declaredNames: names,
847
+ };
848
+ })
849
+ .sort(
850
+ (left, right) =>
851
+ left.position - right.position ||
852
+ left.projectId.localeCompare(right.projectId),
853
+ );
854
+ const previewEnv = Object.fromEntries(
855
+ Object.entries(input.task.previewEnv ?? {}).sort(([left], [right]) =>
856
+ left.localeCompare(right),
857
+ ),
858
+ );
859
+ return { schemaVersion: 1, projects, previewEnv };
860
+ }
861
+
862
+ /** Persist resolver metadata before environment provisioning starts. */
863
+ export function recordTaskProjectEnvSpec(
864
+ taskId: string,
865
+ input: Pick<TaskLaunchInput, "projects" | "task">,
866
+ ): void {
867
+ upsertHostTask(taskId, {
868
+ projectEnvSpec: JSON.stringify(taskProjectEnvSpec(input)),
869
+ });
870
+ }
871
+
872
+ /** Null is a pre-feature row; malformed/newer persisted state fails closed. */
873
+ export function hostTaskProjectEnvSpec(task: {
874
+ projectEnvSpec?: string | null;
875
+ }): TaskProjectEnvSpec | null {
876
+ if (task.projectEnvSpec === null || task.projectEnvSpec === undefined) {
877
+ return null;
878
+ }
879
+ let parsed: unknown;
880
+ try {
881
+ parsed = JSON.parse(task.projectEnvSpec);
882
+ } catch {
883
+ throw new Error("persisted task project env spec is malformed");
884
+ }
885
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
886
+ throw new Error("persisted task project env spec is malformed");
887
+ }
888
+ const record = parsed as Record<string, unknown>;
889
+ if (
890
+ record.schemaVersion !== 1 ||
891
+ !Array.isArray(record.projects) ||
892
+ !record.previewEnv ||
893
+ typeof record.previewEnv !== "object" ||
894
+ Array.isArray(record.previewEnv)
895
+ ) {
896
+ throw new Error("unsupported persisted task project env spec");
897
+ }
898
+ const projects = record.projects.map((entry) => {
899
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
900
+ throw new Error("persisted task project env spec has a malformed project");
901
+ }
902
+ const project = entry as Record<string, unknown>;
903
+ if (
904
+ typeof project.projectId !== "string" ||
905
+ project.projectId.length === 0 ||
906
+ !Number.isSafeInteger(project.position) ||
907
+ !Array.isArray(project.declaredNames) ||
908
+ project.declaredNames.some(
909
+ (name) => typeof name !== "string" || !PROJECT_ENV_NAME.test(name),
910
+ )
911
+ ) {
912
+ throw new Error("persisted task project env spec has a malformed project");
913
+ }
914
+ return {
915
+ projectId: project.projectId,
916
+ position: project.position as number,
917
+ declaredNames: [...new Set(project.declaredNames as string[])],
918
+ };
919
+ });
920
+ const previewEnv: Record<string, string> = {};
921
+ for (const [name, value] of Object.entries(record.previewEnv)) {
922
+ if (!PROJECT_ENV_NAME.test(name) || typeof value !== "string") {
923
+ throw new Error("persisted task project env spec has malformed preview env");
924
+ }
925
+ previewEnv[name] = value;
926
+ }
927
+ return { schemaVersion: 1, projects, previewEnv };
928
+ }
929
+
930
+ /** Append a task lifecycle event to the host event log (local `/api/events`). */
931
+ export function recordHostEvent(
932
+ taskId: string,
933
+ kind: HostEventKind,
934
+ detail?: string | null,
935
+ ): void {
936
+ // `recordTaskDown` prunes the earlier lifecycle history before this terminal
937
+ // event is appended. Do not immediately recreate that owner-facing history
938
+ // when retention is disabled; the correctness tombstone remains in
939
+ // `uai_host_tasks` either way.
940
+ if (kind === "task.ended" && !retainsTerminalHistory()) return;
941
+ getDb()
942
+ .insert(schema.hostEvents)
943
+ .values({ id: newId(), taskId, kind, detail: detail ?? null })
944
+ .run();
945
+ }
946
+
947
+ function retainsTerminalHistory(db: DbExecutor = getDb()): boolean {
948
+ return (
949
+ db
950
+ .select({
951
+ retainTerminalHistory: schema.hostSettings.retainTerminalHistory,
952
+ })
953
+ .from(schema.hostSettings)
954
+ .where(eq(schema.hostSettings.id, "host"))
955
+ .get()?.retainTerminalHistory ?? true
956
+ );
957
+ }
958
+
959
+ /**
960
+ * Apply the owner-facing terminal-history policy without deleting the durable
961
+ * lifecycle tombstone used by idempotence, maintenance admission, and orphan
962
+ * GC. Active/stopped/error rows and their history are deliberately untouched.
963
+ *
964
+ * Returns whether terminal history remains enabled. The optional DB keeps the
965
+ * operation testable and lets the config manager apply a newly-disabled policy
966
+ * to the same host database immediately.
967
+ */
968
+ export function applyTerminalHistoryRetention(db: Db = getDb()): boolean {
969
+ let retained = true;
970
+ db.transaction((tx) => {
971
+ retained = applyTerminalHistoryRetentionInTransaction(tx);
972
+ });
973
+ return retained;
974
+ }
975
+
976
+ /** Same policy, for callers that already own the crash-consistency boundary. */
977
+ export function applyTerminalHistoryRetentionInTransaction(
978
+ db: DbExecutor,
979
+ ): boolean {
980
+ if (retainsTerminalHistory(db)) return true;
981
+
982
+ const terminalTasks = db
983
+ .select({ taskId: schema.hostTasks.taskId })
984
+ .from(schema.hostTasks)
985
+ .where(inArray(schema.hostTasks.statusMirror, [...TERMINAL_STATUSES]))
986
+ .all();
987
+
988
+ for (const { taskId } of terminalTasks) {
989
+ scrubTerminalTaskHistory(db, taskId);
990
+ }
991
+
992
+ return false;
993
+ }
994
+
995
+ /**
996
+ * Apply disabled retention to the task that is transitioning terminal.
997
+ *
998
+ * The full sweep above is reserved for boot reconciliation and the one-time
999
+ * config transition. Re-scanning every permanent tombstone on each task-down
1000
+ * would make teardown cost grow with all historical tasks.
1001
+ */
1002
+ export function applyTaskTerminalHistoryRetentionInTransaction(
1003
+ db: DbExecutor,
1004
+ taskId: string,
1005
+ ): boolean {
1006
+ if (retainsTerminalHistory(db)) return true;
1007
+ const status = db
1008
+ .select({ statusMirror: schema.hostTasks.statusMirror })
1009
+ .from(schema.hostTasks)
1010
+ .where(eq(schema.hostTasks.taskId, taskId))
1011
+ .get()?.statusMirror;
1012
+ if (status && isTerminal(status)) scrubTerminalTaskHistory(db, taskId);
1013
+ return false;
1014
+ }
1015
+
1016
+ function scrubTerminalTaskHistory(db: DbExecutor, taskId: string): void {
1017
+ db.delete(schema.hostAgentSessions)
1018
+ .where(eq(schema.hostAgentSessions.taskId, taskId))
1019
+ .run();
1020
+ db.delete(schema.hostEvents)
1021
+ .where(eq(schema.hostEvents.taskId, taskId))
1022
+ .run();
1023
+ db.update(schema.hostTasks)
1024
+ .set({
1025
+ ownerUserId: null,
1026
+ ownerEmail: null,
1027
+ ownerName: null,
1028
+ projectSlugs: "[]",
1029
+ startedAt: null,
1030
+ })
1031
+ .where(eq(schema.hostTasks.taskId, taskId))
1032
+ .run();
1033
+ }