@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,852 @@
1
+ /**
2
+ * Local host UI HTTP server (ADR-028, docs/host-ui.md).
3
+ *
4
+ * Bound to 127.0.0.1 ONLY — never 0.0.0.0, never the tailnet. No auth (the
5
+ * localhost binding is the perimeter — see ADR-028 security stance), no CORS,
6
+ * read-only. Serves the static UI from `host-agent/ui/` and a small JSON API
7
+ * under `/api/*`. Every JSON body is parsed through its zod schema before send
8
+ * so the API can never emit an off-contract shape.
9
+ */
10
+
11
+ import { existsSync } from "node:fs";
12
+ import { readFile, writeFile } from "node:fs/promises";
13
+ import {
14
+ createServer,
15
+ type IncomingMessage,
16
+ type Server,
17
+ type ServerResponse,
18
+ } from "node:http";
19
+ import type { AddressInfo } from "node:net";
20
+ import { hostname } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import { desc, isNull } from "drizzle-orm";
24
+ import type { ZodType } from "zod";
25
+
26
+ import { agentMode } from "../../lib/agents/mode";
27
+ import { schema, type Db } from "../../lib/db";
28
+ import { whileOnOwnCredentials } from "../../lib/cloud-credential-host";
29
+ import { getOrchestrator } from "../../lib/orchestrator";
30
+ import { parsePreviewPortRuntimes } from "../../lib/preview-ports";
31
+ import { getCloudState } from "../../lib/cloud-state";
32
+ import { dockerCli } from "../../lib/docker-exec";
33
+ import { STATION } from "../station-identity";
34
+ import type { ContainerRuntimeState } from "../../lib/container-runtime";
35
+ import {
36
+ connectEngine,
37
+ disconnectEngine,
38
+ engineCliStatuses,
39
+ installEngineCli,
40
+ engineCatalog,
41
+ engineStatuses,
42
+ isEngineKind,
43
+ type EngineSeams,
44
+ } from "../../lib/engines";
45
+ import {
46
+ addEngineAccount,
47
+ listEngineAccounts,
48
+ removeEngineAccount,
49
+ } from "../../lib/engine-accounts";
50
+ import { ensureStandardImage } from "../../lib/standard-image";
51
+ import {
52
+ CloudResponse,
53
+ ContainerRuntimeResponse,
54
+ EngineOpResponse,
55
+ EnginesResponse,
56
+ EventsResponse,
57
+ StatusResponse,
58
+ TasksResponse,
59
+ UsersResponse,
60
+ type TaskRow,
61
+ } from "./types";
62
+
63
+ const HOST = "127.0.0.1";
64
+ const MAX_PORT_ATTEMPTS = 10;
65
+ export const RUNTIME_RECHECK_CONTROL_HEADER = "runtime-recheck-v1";
66
+
67
+ export interface UiServerOptions {
68
+ /** Host runtime DB (read-only here). */
69
+ db: Db;
70
+ /** Static asset directory (`host-agent/ui`). */
71
+ uiDir: string;
72
+ /** File to persist the chosen port to (`$UAI_DATA_DIR/host.port`). */
73
+ portFilePath: string;
74
+ /**
75
+ * ADR-136 sign-in box: answer only the read-only status trio
76
+ * (`/api/status`, `/api/cloud`, `/api/tasks`) so `uai-host status` — and
77
+ * the managed installer's post-start check, which parses it — can see the
78
+ * box come up and connect. Every write and every other read (engines,
79
+ * users, events, the static UI) is 404: the box holds no credential at
80
+ * rest and runs no tasks, and nobody sits at it.
81
+ */
82
+ monitorOnly?: boolean;
83
+ /** First port to try (5876 or UAI_HOST_UI_PORT). 0 lets the OS pick (tests). */
84
+ startPort: number;
85
+ // Facts for /api/status:
86
+ version: string;
87
+ cloudUrl: string;
88
+ hostId: string;
89
+ logPath: string;
90
+ /** Best-effort container memory by compose project; null on failure. */
91
+ taskMemory?: (composeProject: string) => Promise<number | null>;
92
+ /**
93
+ * Re-advertise host capabilities to the cloud (ADR-021). Wired from main.ts
94
+ * (`sendCapabilities`) so an engine connect/disconnect updates the cloud's
95
+ * task picker promptly. Optional (tests omit it).
96
+ */
97
+ readvertise?: () => void;
98
+ /**
99
+ * Stop a task's containers (resumable) — wired from main.ts to the
100
+ * orchestrator. The host operator's "reclaim my machine" control (ADR-028).
101
+ * Optional (tests omit it).
102
+ */
103
+ stopTask?: (taskId: string) => Promise<{ ok: boolean; error?: string }>;
104
+ /**
105
+ * Engine probe seams for /api/engines. Production omits this and gets the
106
+ * real filesystem + login-shell probes; tests pin `loginShellPath` so the
107
+ * endpoint never spawns a login shell (which is bounded, but real, I/O).
108
+ */
109
+ engineSeams?: Partial<EngineSeams>;
110
+ /** ADR-101 local control/read surface. Optional for embedding/tests. */
111
+ runtimeState?: () => ContainerRuntimeState;
112
+ recheckRuntime?: () => Promise<ContainerRuntimeState>;
113
+ /** False while the selected backend is unavailable or still recovering.
114
+ * Credential edits remain usable, but must not kick Docker maintenance. */
115
+ containersOperational?: () => boolean;
116
+ }
117
+
118
+ export interface UiServerHandle {
119
+ server: Server;
120
+ port: number;
121
+ close: () => Promise<void>;
122
+ }
123
+
124
+ /** The GET routes a `monitorOnly` server still answers (see UiServerOptions). */
125
+ const MONITOR_ONLY_ROUTES: ReadonlySet<string> = new Set([
126
+ "/api/status",
127
+ "/api/cloud",
128
+ "/api/tasks",
129
+ ]);
130
+
131
+ /** Start the local UI server, binding to 127.0.0.1 with port fallback. */
132
+ export async function startUiServer(
133
+ opts: UiServerOptions,
134
+ ): Promise<UiServerHandle> {
135
+ const memory = opts.taskMemory ? taskMemorySampler(opts.taskMemory) : null;
136
+ const server = createServer((req, res) => {
137
+ void handle(req, res, opts, memory);
138
+ });
139
+ const port = await listenWithFallback(server, opts.startPort);
140
+ await writeFile(opts.portFilePath, `${port}\n`, "utf8");
141
+ return {
142
+ server,
143
+ port,
144
+ close: () =>
145
+ new Promise<void>((resolve) => server.close(() => resolve())),
146
+ };
147
+ }
148
+
149
+ async function listenWithFallback(
150
+ server: Server,
151
+ startPort: number,
152
+ ): Promise<number> {
153
+ let lastErr: NodeJS.ErrnoException | null = null;
154
+ for (let i = 0; i < MAX_PORT_ATTEMPTS; i += 1) {
155
+ const port = startPort === 0 ? 0 : startPort + i;
156
+ try {
157
+ await new Promise<void>((resolve, reject) => {
158
+ const onError = (err: NodeJS.ErrnoException): void => {
159
+ server.removeListener("listening", onListening);
160
+ reject(err);
161
+ };
162
+ const onListening = (): void => {
163
+ server.removeListener("error", onError);
164
+ resolve();
165
+ };
166
+ server.once("error", onError);
167
+ server.once("listening", onListening);
168
+ // 127.0.0.1 ONLY. Binding to 0.0.0.0 here would expose the UI off-box.
169
+ server.listen(port, HOST);
170
+ });
171
+ return (server.address() as AddressInfo).port;
172
+ } catch (err) {
173
+ lastErr = err as NodeJS.ErrnoException;
174
+ if (lastErr.code === "EADDRINUSE" && startPort !== 0) continue;
175
+ throw lastErr;
176
+ }
177
+ }
178
+ throw new Error(
179
+ `no free UI port in [${startPort}, ${startPort + MAX_PORT_ATTEMPTS - 1}]` +
180
+ (lastErr ? `: ${lastErr.message}` : ""),
181
+ );
182
+ }
183
+
184
+ /** The local UI's writes of an engine credential (a sign-in, a key, an
185
+ * account): refused once the runner is off its own credentials. */
186
+ const ENGINE_CREDENTIAL_ROUTES: ReadonlySet<string> = new Set([
187
+ "/api/engines/connect",
188
+ "/api/engines/disconnect",
189
+ "/api/engines/accounts/connect",
190
+ "/api/engines/accounts/add",
191
+ "/api/engines/accounts/remove",
192
+ ]);
193
+
194
+ async function handleEngineCredentialWrite(
195
+ path: string,
196
+ req: IncomingMessage,
197
+ res: ServerResponse,
198
+ opts: UiServerOptions,
199
+ ): Promise<void> {
200
+ switch (path) {
201
+ case "/api/engines/connect":
202
+ return handleEngineConnect(req, res, opts);
203
+ case "/api/engines/disconnect":
204
+ return handleEngineDisconnect(req, res, opts);
205
+ case "/api/engines/accounts/connect":
206
+ return handleEngineAccountConnect(req, res, opts);
207
+ case "/api/engines/accounts/add":
208
+ return handleEngineAccountAdd(req, res, opts);
209
+ case "/api/engines/accounts/remove":
210
+ return handleEngineAccountRemove(req, res, opts);
211
+ default:
212
+ return sendError(res, 404, `no such endpoint: ${path}`);
213
+ }
214
+ }
215
+
216
+ async function handle(
217
+ req: IncomingMessage,
218
+ res: ServerResponse,
219
+ opts: UiServerOptions,
220
+ memory: TaskMemorySampler | null,
221
+ ): Promise<void> {
222
+ const path = (req.url ?? "/").split("?")[0] ?? "/";
223
+ const method = req.method ?? "GET";
224
+ try {
225
+ if (opts.monitorOnly && !MONITOR_ONLY_ROUTES.has(path)) {
226
+ return sendError(res, 404, `no such endpoint: ${path}`);
227
+ }
228
+ // POST — engine connect/disconnect (the only writes; localhost-bound, so
229
+ // the loopback binding is the perimeter, ADR-028).
230
+ if (method === "POST") {
231
+ // ADR-154 §13: an engine credential is written here only while the
232
+ // runner is on its own credentials, as work on them that leaving them
233
+ // waits for; off them it keeps none, and Connections in Uai Cloud
234
+ // holds them.
235
+ if (ENGINE_CREDENTIAL_ROUTES.has(path)) {
236
+ const write = whileOnOwnCredentials(() => handleEngineCredentialWrite(path, req, res, opts));
237
+ if (write === null) {
238
+ return sendError(
239
+ res,
240
+ 409,
241
+ "this station reads its credentials from Moondeck Cloud; manage them in Connections",
242
+ );
243
+ }
244
+ return await write;
245
+ }
246
+ switch (path) {
247
+ case "/api/engines/install":
248
+ return await handleEngineInstall(req, res);
249
+ case "/api/tasks/stop":
250
+ return await handleTaskStop(req, res, opts);
251
+ case "/api/runtime/recheck": {
252
+ // Loopback is not a browser CSRF boundary. Requiring a non-simple
253
+ // header is deliberate even though its value is not a secret:
254
+ // hostile pages cannot attach it without a CORS preflight, and this
255
+ // server grants no CORS permission. The local CLI is the caller.
256
+ if (
257
+ req.headers["x-uai-local-control"] !==
258
+ RUNTIME_RECHECK_CONTROL_HEADER
259
+ ) {
260
+ return sendError(res, 403, "local control header required");
261
+ }
262
+ if (!opts.recheckRuntime) {
263
+ return sendError(res, 501, "runtime recheck is unavailable");
264
+ }
265
+ return sendJson(
266
+ res,
267
+ ContainerRuntimeResponse,
268
+ await opts.recheckRuntime(),
269
+ );
270
+ }
271
+ }
272
+ return sendError(res, 404, `no such endpoint: ${path}`);
273
+ }
274
+ if (method !== "GET") {
275
+ return sendError(res, 405, "method not allowed");
276
+ }
277
+ switch (path) {
278
+ case "/api/status":
279
+ return sendJson(res, StatusResponse, {
280
+ pid: process.pid,
281
+ uptime: process.uptime(),
282
+ version: opts.version,
283
+ logPath: opts.logPath,
284
+ cloudUrl: opts.cloudUrl,
285
+ hostName: hostname(),
286
+ hostId: opts.hostId,
287
+ agentMode: agentMode(),
288
+ containerRuntime: opts.runtimeState?.(),
289
+ product: STATION.packageName,
290
+ });
291
+ case "/api/cloud":
292
+ return sendJson(res, CloudResponse, getCloudState());
293
+ case "/api/tasks":
294
+ return sendJson(res, TasksResponse, await tasksBody(opts, memory));
295
+ case "/api/events":
296
+ return sendJson(res, EventsResponse, eventsBody(opts));
297
+ case "/api/users":
298
+ return sendJson(res, UsersResponse, usersBody(opts));
299
+ case "/api/engines":
300
+ return sendJson(res, EnginesResponse, await enginesBody(opts.engineSeams));
301
+ }
302
+ if (path.startsWith("/api/")) {
303
+ return sendError(res, 404, `no such endpoint: ${path}`);
304
+ }
305
+ return await serveStatic(res, opts.uiDir, path);
306
+ } catch (err) {
307
+ return sendError(res, 500, err instanceof Error ? err.message : "internal error");
308
+ }
309
+ }
310
+
311
+ // --- engines ----------------------------------------------------------------
312
+
313
+ /** Engine kinds that participate in the multi-account model (ADR-076). */
314
+ const ACCOUNT_KINDS = ["claude", "codex", "opencode"] as const;
315
+
316
+ async function enginesBody(
317
+ seams: Partial<EngineSeams> = {},
318
+ ): Promise<EnginesResponse> {
319
+ const accounts: Record<string, ReturnType<typeof listEngineAccounts>> = {};
320
+ for (const kind of ACCOUNT_KINDS) {
321
+ accounts[kind] = listEngineAccounts(kind);
322
+ }
323
+ return {
324
+ catalog: engineCatalog(),
325
+ statuses: engineStatuses(seams),
326
+ cli: await engineCliStatuses(seams),
327
+ accounts,
328
+ };
329
+ }
330
+
331
+ /**
332
+ * POST /api/engines/install `{kind}` — run the engine's pinned installer,
333
+ * streaming NDJSON like connect: `{"line":…}` frames, then `{done,ok,message}`.
334
+ * No re-advertise or image rebuild here: installing a CLI connects nothing;
335
+ * that happens on the connect that follows.
336
+ */
337
+ async function handleEngineInstall(
338
+ req: IncomingMessage,
339
+ res: ServerResponse,
340
+ ): Promise<void> {
341
+ const body = await readJsonBody(req);
342
+ const kind = body?.kind;
343
+ if (!isEngineKind(kind)) {
344
+ return sendError(res, 400, "unknown or missing engine kind");
345
+ }
346
+ res.writeHead(200, {
347
+ "content-type": "application/x-ndjson; charset=utf-8",
348
+ "cache-control": "no-store",
349
+ });
350
+ const emit = (obj: unknown): void => {
351
+ res.write(`${JSON.stringify(obj)}\n`);
352
+ };
353
+ let result: { ok: boolean; message: string };
354
+ try {
355
+ result = await installEngineCli(kind, (line) => emit({ line }));
356
+ } catch (err) {
357
+ result = {
358
+ ok: false,
359
+ message: err instanceof Error ? err.message : "install failed",
360
+ };
361
+ }
362
+ emit({ done: true, ok: result.ok, message: result.message });
363
+ res.end();
364
+ }
365
+
366
+ /**
367
+ * POST /api/engines/connect `{kind, apiKey?, pastedToken?}`.
368
+ *
369
+ * Streams the connect as newline-delimited JSON (`application/x-ndjson`): each
370
+ * live CLI log line is `{"line":"…"}`, and a final `{"done":true,"ok":…,
371
+ * "message":"…"}` carries the result. api-key/paste connects emit no log lines
372
+ * and just the final frame. On success we re-advertise and kick a (best-effort)
373
+ * standard-image rebuild so a newly-configured engine's CLI gets installed.
374
+ */
375
+ async function handleEngineConnect(
376
+ req: IncomingMessage,
377
+ res: ServerResponse,
378
+ opts: UiServerOptions,
379
+ ): Promise<void> {
380
+ const body = await readJsonBody(req);
381
+ const kind = body?.kind;
382
+ if (!isEngineKind(kind)) {
383
+ return sendError(res, 400, "unknown or missing engine kind");
384
+ }
385
+ const apiKey = typeof body?.apiKey === "string" ? body.apiKey : undefined;
386
+ const pastedToken =
387
+ typeof body?.pastedToken === "string" ? body.pastedToken : undefined;
388
+
389
+ res.writeHead(200, {
390
+ "content-type": "application/x-ndjson; charset=utf-8",
391
+ "cache-control": "no-store",
392
+ });
393
+ const emit = (obj: unknown): void => {
394
+ res.write(`${JSON.stringify(obj)}\n`);
395
+ };
396
+
397
+ let result: { ok: boolean; message: string };
398
+ try {
399
+ result = await connectEngine(
400
+ kind,
401
+ { apiKey, pastedToken },
402
+ (line) => emit({ line }),
403
+ );
404
+ } catch (err) {
405
+ result = {
406
+ ok: false,
407
+ message: err instanceof Error ? err.message : "connect failed",
408
+ };
409
+ }
410
+ if (result.ok) {
411
+ // Re-advertise so the cloud picker offers the engine; rebuild the image so
412
+ // an optional engine's CLI (kimi/grok/cursor) is installed. Best-effort.
413
+ opts.readvertise?.();
414
+ if (opts.containersOperational?.() !== false) {
415
+ void ensureStandardImage();
416
+ // A re-login only reaches a RUNNING task once its agents re-spawn — the
417
+ // credential is injected at spawn (Claude's token via `docker exec -e`).
418
+ // Refresh running agents of this kind so a reconnect self-heals tasks
419
+ // stuck on a revoked/expired token, instead of needing Stop/Resume.
420
+ void getOrchestrator()
421
+ .refreshEngineAccountAgents(kind)
422
+ .catch(() => {});
423
+ }
424
+ }
425
+ emit({ done: true, ok: result.ok, message: result.message });
426
+ res.end();
427
+ }
428
+
429
+ /** POST /api/engines/disconnect `{kind}` → forget the credential + re-advertise. */
430
+ async function handleEngineDisconnect(
431
+ req: IncomingMessage,
432
+ res: ServerResponse,
433
+ opts: UiServerOptions,
434
+ ): Promise<void> {
435
+ const body = await readJsonBody(req);
436
+ const kind = body?.kind;
437
+ if (!isEngineKind(kind)) {
438
+ return sendError(res, 400, "unknown or missing engine kind");
439
+ }
440
+ disconnectEngine(kind);
441
+ opts.readvertise?.();
442
+ return sendJson(res, EngineOpResponse, { ok: true });
443
+ }
444
+
445
+ /**
446
+ * POST /api/engines/accounts/add `{kind, label, apiKey?|token?}` → register an
447
+ * EXTRA account (ADR-076) by pasting a token / API key, then re-advertise (the
448
+ * kind may not have been available before) + rebuild the image if needed.
449
+ */
450
+ async function handleEngineAccountAdd(
451
+ req: IncomingMessage,
452
+ res: ServerResponse,
453
+ opts: UiServerOptions,
454
+ ): Promise<void> {
455
+ const body = await readJsonBody(req);
456
+ const kind = body?.kind;
457
+ if (!isEngineKind(kind)) {
458
+ return sendError(res, 400, "unknown or missing engine kind");
459
+ }
460
+ const label = typeof body?.label === "string" ? body.label : "";
461
+ const apiKey = typeof body?.apiKey === "string" ? body.apiKey : undefined;
462
+ const token = typeof body?.token === "string" ? body.token : undefined;
463
+ const result = addEngineAccount(kind, label, { apiKey, token });
464
+ if (result.ok) {
465
+ opts.readvertise?.();
466
+ if (opts.containersOperational?.() !== false) {
467
+ void ensureStandardImage();
468
+ }
469
+ }
470
+ return sendJson(res, EngineOpResponse, {
471
+ ok: result.ok,
472
+ message: result.message,
473
+ });
474
+ }
475
+
476
+ /**
477
+ * POST /api/engines/accounts/connect `{kind, label}` → run the engine's
478
+ * token-command sign-in (streamed NDJSON exactly like /api/engines/connect) and
479
+ * store the captured token as an EXTRA account (ADR-076) rather than the default
480
+ * credential. Only meaningful for token-command kinds (Claude); the browser
481
+ * OAuth authorizes whichever account you sign in with, so run it signed-out /
482
+ * incognito for the browser to prompt for the second account.
483
+ */
484
+ async function handleEngineAccountConnect(
485
+ req: IncomingMessage,
486
+ res: ServerResponse,
487
+ opts: UiServerOptions,
488
+ ): Promise<void> {
489
+ const body = await readJsonBody(req);
490
+ const kind = body?.kind;
491
+ if (!isEngineKind(kind)) {
492
+ return sendError(res, 400, "unknown or missing engine kind");
493
+ }
494
+ const label = typeof body?.label === "string" ? body.label.trim() : "";
495
+ if (!label) {
496
+ return sendError(res, 400, "give the account a label");
497
+ }
498
+
499
+ res.writeHead(200, {
500
+ "content-type": "application/x-ndjson; charset=utf-8",
501
+ "cache-control": "no-store",
502
+ });
503
+ const emit = (obj: unknown): void => {
504
+ res.write(`${JSON.stringify(obj)}\n`);
505
+ };
506
+
507
+ let result: { ok: boolean; message: string };
508
+ try {
509
+ result = await connectEngine(
510
+ kind,
511
+ { onToken: (token) => addEngineAccount(kind, label, { token }) },
512
+ (line) => emit({ line }),
513
+ );
514
+ } catch (err) {
515
+ result = {
516
+ ok: false,
517
+ message: err instanceof Error ? err.message : "sign-in failed",
518
+ };
519
+ }
520
+ if (result.ok) {
521
+ opts.readvertise?.();
522
+ if (opts.containersOperational?.() !== false) {
523
+ void ensureStandardImage();
524
+ }
525
+ }
526
+ emit({ done: true, ok: result.ok, message: result.message });
527
+ res.end();
528
+ }
529
+
530
+ /** POST /api/engines/accounts/remove `{id}` → forget an EXTRA account. */
531
+ async function handleEngineAccountRemove(
532
+ req: IncomingMessage,
533
+ res: ServerResponse,
534
+ opts: UiServerOptions,
535
+ ): Promise<void> {
536
+ const body = await readJsonBody(req);
537
+ const id = body?.id;
538
+ if (typeof id !== "string" || id.length === 0) {
539
+ return sendError(res, 400, "missing account id");
540
+ }
541
+ const result = removeEngineAccount(id);
542
+ if (result.ok) opts.readvertise?.();
543
+ return sendJson(res, EngineOpResponse, {
544
+ ok: result.ok,
545
+ message: result.message,
546
+ });
547
+ }
548
+
549
+ /** POST /api/tasks/stop `{taskId}` → compose-stop the task (resumable). */
550
+ async function handleTaskStop(
551
+ req: IncomingMessage,
552
+ res: ServerResponse,
553
+ opts: UiServerOptions,
554
+ ): Promise<void> {
555
+ const body = await readJsonBody(req);
556
+ const taskId = body?.taskId;
557
+ if (typeof taskId !== "string" || taskId.length === 0) {
558
+ return sendError(res, 400, "missing taskId");
559
+ }
560
+ if (!opts.stopTask) {
561
+ return sendError(res, 501, "stop not available on this host");
562
+ }
563
+ const result = await opts.stopTask(taskId);
564
+ return sendJson(res, EngineOpResponse, {
565
+ ok: result.ok,
566
+ message: result.error,
567
+ });
568
+ }
569
+
570
+ /** Read a request body and parse it as a JSON object; null on empty/invalid. */
571
+ async function readJsonBody(
572
+ req: IncomingMessage,
573
+ ): Promise<Record<string, unknown> | null> {
574
+ const chunks: Buffer[] = [];
575
+ let size = 0;
576
+ for await (const chunk of req) {
577
+ const buf = chunk as Buffer;
578
+ size += buf.length;
579
+ if (size > 64 * 1024) throw new Error("request body too large");
580
+ chunks.push(buf);
581
+ }
582
+ if (chunks.length === 0) return null;
583
+ try {
584
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
585
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
586
+ ? (parsed as Record<string, unknown>)
587
+ : null;
588
+ } catch {
589
+ return null;
590
+ }
591
+ }
592
+
593
+ // --- handlers ---------------------------------------------------------------
594
+
595
+ async function tasksBody(
596
+ opts: UiServerOptions,
597
+ memory: TaskMemorySampler | null,
598
+ ): Promise<TasksResponse> {
599
+ const rows = opts.db
600
+ .select()
601
+ .from(schema.hostTasks)
602
+ .where(isNull(schema.hostTasks.endedAt))
603
+ .all();
604
+ const now = Date.now();
605
+ const memoryByProject = memory
606
+ ? await memory.read(rows.flatMap((r) => (r.composeProject ? [r.composeProject] : [])))
607
+ : new Map<string, number | null>();
608
+ const tasks: TaskRow[] = rows.map((r) => ({
609
+ taskId: r.taskId,
610
+ status: r.statusMirror,
611
+ ownerEmail: r.ownerEmail,
612
+ projectSlugs: parseSlugs(r.projectSlugs),
613
+ startedAt: r.startedAt,
614
+ ageMs: r.startedAt != null ? now - r.startedAt : null,
615
+ composeProject: r.composeProject,
616
+ codeServerPort: r.codeServerPort,
617
+ previewPorts: parsePreviewPortRuntimes(r.previewPorts).map((p) => ({
618
+ name: p.name,
619
+ hostPort: p.hostPort ?? null,
620
+ })),
621
+ memoryBytes: r.composeProject ? (memoryByProject.get(r.composeProject) ?? null) : null,
622
+ }));
623
+ return { tasks };
624
+ }
625
+
626
+ /** How long `/api/tasks` waits for fresh memory samples before it answers
627
+ * with the last ones it has (null for a task never sampled yet). */
628
+ export const TASK_MEMORY_WAIT_MS = 250;
629
+ /** A sample this recent is served without asking Docker again. */
630
+ export const TASK_MEMORY_FRESH_MS = 15_000;
631
+
632
+ export interface TaskMemorySampler {
633
+ /** The latest memory sample of each compose project asked for. */
634
+ read(projects: readonly string[]): Promise<Map<string, number | null>>;
635
+ }
636
+
637
+ /**
638
+ * Memory for `/api/tasks` without making the response wait on Docker.
639
+ * `docker stats --no-stream` takes about two seconds per task once tasks
640
+ * run, so `/api/tasks` took ~2 s and `uai-host status`, which allowed it one,
641
+ * said "service unreachable" on a healthy runner (2026-10-30). Each project
642
+ * is sampled in the background, one sample at a time, at most once per
643
+ * `freshMs`; a read waits at most `waitMs` for samples under way, then
644
+ * answers with what it has. A project no longer asked for is forgotten.
645
+ */
646
+ export function taskMemorySampler(
647
+ sample: (composeProject: string) => Promise<number | null>,
648
+ options: { now?: () => number; waitMs?: number; freshMs?: number } = {},
649
+ ): TaskMemorySampler {
650
+ const now = options.now ?? Date.now;
651
+ const waitMs = options.waitMs ?? TASK_MEMORY_WAIT_MS;
652
+ const freshMs = options.freshMs ?? TASK_MEMORY_FRESH_MS;
653
+ const entries = new Map<
654
+ string,
655
+ { value: number | null; sampledAt: number | null; sampling: Promise<void> | null }
656
+ >();
657
+ return {
658
+ async read(projects) {
659
+ const wanted = new Set(projects);
660
+ for (const project of entries.keys()) {
661
+ if (!wanted.has(project)) entries.delete(project);
662
+ }
663
+ const sampling: Promise<void>[] = [];
664
+ for (const project of wanted) {
665
+ let entry = entries.get(project);
666
+ if (!entry) {
667
+ entry = { value: null, sampledAt: null, sampling: null };
668
+ entries.set(project, entry);
669
+ }
670
+ if (entry.sampling === null && (entry.sampledAt === null || now() - entry.sampledAt >= freshMs)) {
671
+ const current = entry;
672
+ current.sampling = sample(project)
673
+ .catch(() => null)
674
+ .then((value) => {
675
+ current.value = value;
676
+ current.sampledAt = now();
677
+ current.sampling = null;
678
+ });
679
+ }
680
+ if (entry.sampling !== null) sampling.push(entry.sampling);
681
+ }
682
+ if (sampling.length > 0) {
683
+ let timer: NodeJS.Timeout | undefined;
684
+ await Promise.race([
685
+ Promise.all(sampling),
686
+ new Promise<void>((resolve) => {
687
+ timer = setTimeout(resolve, waitMs);
688
+ timer.unref?.();
689
+ }),
690
+ ]);
691
+ clearTimeout(timer);
692
+ }
693
+ return new Map([...wanted].map((project) => [project, entries.get(project)?.value ?? null]));
694
+ },
695
+ };
696
+ }
697
+
698
+ function eventsBody(opts: UiServerOptions): EventsResponse {
699
+ const rows = opts.db
700
+ .select()
701
+ .from(schema.hostEvents)
702
+ .orderBy(desc(schema.hostEvents.ts))
703
+ .limit(50)
704
+ .all();
705
+ return {
706
+ events: rows
707
+ .filter((r) => isKnownEventKind(r.kind))
708
+ .map((r) => ({
709
+ id: r.id,
710
+ taskId: r.taskId,
711
+ kind: r.kind as EventsResponse["events"][number]["kind"],
712
+ ts: r.ts,
713
+ detail: r.detail,
714
+ })),
715
+ };
716
+ }
717
+
718
+ function usersBody(opts: UiServerOptions): UsersResponse {
719
+ const rows = opts.db
720
+ .select()
721
+ .from(schema.hostTasks)
722
+ .where(isNull(schema.hostTasks.endedAt))
723
+ .all();
724
+ const byUser = new Map<string, { email: string | null; count: number }>();
725
+ for (const r of rows) {
726
+ if (!r.ownerUserId) continue;
727
+ const cur = byUser.get(r.ownerUserId) ?? { email: r.ownerEmail, count: 0 };
728
+ cur.count += 1;
729
+ if (!cur.email && r.ownerEmail) cur.email = r.ownerEmail;
730
+ byUser.set(r.ownerUserId, cur);
731
+ }
732
+ return {
733
+ users: [...byUser].map(([userId, v]) => ({
734
+ userId,
735
+ email: v.email,
736
+ activeTasks: v.count,
737
+ })),
738
+ };
739
+ }
740
+
741
+ // --- static -----------------------------------------------------------------
742
+
743
+ const STATIC_FILES: Record<string, string> = {
744
+ "/": "index.html",
745
+ "/index.html": "index.html",
746
+ "/style.css": "style.css",
747
+ "/app.js": "app.js",
748
+ "/uai-wheel.svg": "uai-wheel.svg",
749
+ "/uai-favicon.svg": "uai-favicon.svg",
750
+ "/uai-logo-black.svg": "uai-logo-black.svg",
751
+ };
752
+
753
+ async function serveStatic(
754
+ res: ServerResponse,
755
+ uiDir: string,
756
+ path: string,
757
+ ): Promise<void> {
758
+ const file = STATIC_FILES[path];
759
+ if (!file) return sendError(res, 404, `not found: ${path}`);
760
+ const full = join(uiDir, file);
761
+ if (!existsSync(full)) return sendError(res, 404, `not found: ${path}`);
762
+ const body = await readFile(full);
763
+ res.writeHead(200, { "content-type": contentType(file) });
764
+ res.end(body);
765
+ }
766
+
767
+ function contentType(file: string): string {
768
+ if (file.endsWith(".html")) return "text/html; charset=utf-8";
769
+ if (file.endsWith(".css")) return "text/css; charset=utf-8";
770
+ if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
771
+ if (file.endsWith(".svg")) return "image/svg+xml";
772
+ return "application/octet-stream";
773
+ }
774
+
775
+ // --- response helpers -------------------------------------------------------
776
+
777
+ function sendJson<T>(res: ServerResponse, shape: ZodType<T>, body: T): void {
778
+ // Parse before send: the API can never emit an off-contract body.
779
+ const validated = shape.parse(body);
780
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
781
+ res.end(JSON.stringify(validated));
782
+ }
783
+
784
+ function sendError(res: ServerResponse, status: number, message: string): void {
785
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
786
+ res.end(JSON.stringify({ error: message }));
787
+ }
788
+
789
+ // --- misc -------------------------------------------------------------------
790
+
791
+ function parseSlugs(raw: string): string[] {
792
+ try {
793
+ const parsed: unknown = JSON.parse(raw);
794
+ return Array.isArray(parsed)
795
+ ? parsed.filter((s): s is string => typeof s === "string")
796
+ : [];
797
+ } catch {
798
+ return [];
799
+ }
800
+ }
801
+
802
+ function isKnownEventKind(kind: string): boolean {
803
+ return (
804
+ kind === "task.created" ||
805
+ kind === "task.started" ||
806
+ kind === "task.ended" ||
807
+ kind === "task.ship"
808
+ );
809
+ }
810
+
811
+ /**
812
+ * Best-effort container memory for a task's compose project via `docker stats`.
813
+ * Returns null on any failure (docker down, container gone, parse miss).
814
+ */
815
+ export async function dockerMemoryBytes(
816
+ composeProject: string,
817
+ ): Promise<number | null> {
818
+ try {
819
+ // Async (not spawnSync): this runs per task on every status request, on the
820
+ // SAME event loop as the cloud-bridge heartbeat. `docker stats --no-stream`
821
+ // samples for ~1-2s; doing it synchronously for N tasks blocked the loop
822
+ // long enough that the bridge heartbeat timed out and the cloud dropped the
823
+ // host into a reconnect loop.
824
+ const res = await dockerCli(
825
+ ["stats", "--no-stream", "--format", "{{.MemUsage}}", `${composeProject}-app-1`],
826
+ { timeoutMs: 3_000 },
827
+ );
828
+ if (res.status !== 0 || !res.stdout) return null;
829
+ const used = res.stdout.trim().split("/")[0]?.trim();
830
+ return used ? parseMemUsage(used) : null;
831
+ } catch {
832
+ return null;
833
+ }
834
+ }
835
+
836
+ function parseMemUsage(text: string): number | null {
837
+ const m = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(text);
838
+ if (!m || !m[1] || !m[2]) return null;
839
+ const value = Number(m[1]);
840
+ const unit = m[2].toLowerCase();
841
+ const scale: Record<string, number> = {
842
+ b: 1,
843
+ kb: 1e3,
844
+ mb: 1e6,
845
+ gb: 1e9,
846
+ kib: 1024,
847
+ mib: 1024 ** 2,
848
+ gib: 1024 ** 3,
849
+ };
850
+ const factor = scale[unit];
851
+ return factor ? Math.round(value * factor) : null;
852
+ }