@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,530 @@
1
+ /**
2
+ * Parse `git diff` unified output into structured per-file patches.
3
+ *
4
+ * Input is whatever `git diff <merge-base>` (committed + uncommitted) plus
5
+ * appended `--no-index` new-file patches for untracked files produce —
6
+ * multi-file, with headers, mode/rename/binary annotations, and `@@` hunks.
7
+ * The output is a list of `DiffFile`s the UI can render per-file with
8
+ * collapse/expand and syntax-highlighted hunks.
9
+ *
10
+ * Hand-rolled rather than pulled in as a dep — the unified-diff grammar
11
+ * is small and the chat-side Markdown renderer already sets the
12
+ * dependency-free precedent (see components/markdown.tsx).
13
+ */
14
+
15
+ import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
16
+ import type {
17
+ TaskEnvironmentExecRequest,
18
+ TaskEnvironmentExecResult,
19
+ } from "./task-environment/types";
20
+
21
+ export type DiffStatus =
22
+ | "added"
23
+ | "deleted"
24
+ | "modified"
25
+ | "renamed"
26
+ | "copied"
27
+ | "binary";
28
+
29
+ export interface DiffLine {
30
+ /** ' ' for context, '+' for added, '-' for removed. */
31
+ kind: "ctx" | "add" | "del";
32
+ /** Line content without the leading marker. */
33
+ content: string;
34
+ /** Line number in the old file (1-based) for ctx/del lines. */
35
+ oldNo?: number;
36
+ /** Line number in the new file (1-based) for ctx/add lines. */
37
+ newNo?: number;
38
+ }
39
+
40
+ export interface DiffHunk {
41
+ oldStart: number;
42
+ oldLines: number;
43
+ newStart: number;
44
+ newLines: number;
45
+ /** Raw `@@ ... @@` header text, useful for the section label git
46
+ * sometimes includes after the second `@@`. */
47
+ header: string;
48
+ lines: DiffLine[];
49
+ }
50
+
51
+ export interface DiffFile {
52
+ path: string;
53
+ /** Set on rename / copy — the source path before the change. */
54
+ oldPath?: string;
55
+ status: DiffStatus;
56
+ /** Best-effort language hint based on the file extension. */
57
+ language?: string;
58
+ /** A `Binary files … differ` patch has no hunks. */
59
+ binary: boolean;
60
+ hunks: DiffHunk[];
61
+ }
62
+
63
+ /** How every patch is rendered: no colour, no external diff driver, the
64
+ * usual a/ b/ prefixes, paths as they are. */
65
+ const GIT_DIFF_ARGS = "-c core.quotepath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/";
66
+
67
+ const NON_INTERACTIVE_GIT_ENV = {
68
+ GIT_TERMINAL_PROMPT: "0",
69
+ GCM_INTERACTIVE: "never",
70
+ GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
71
+ };
72
+
73
+ /** A diff's bound on what each of a project's producers may give: its
74
+ * tracked patches, and its untracked ones. Past it, the project's diff is
75
+ * an error, never a partial patch. */
76
+ export const TASK_DIFF_PATCH_BYTES = 8 * 1024 * 1024;
77
+ /** A diff's bound on its whole output, however many projects it has: the
78
+ * projects past it are errors. */
79
+ export const TASK_DIFF_OUTPUT_BYTES = 48 * 1024 * 1024;
80
+ /** A diff's bound on the lines it renders, every project of it: each line
81
+ * becomes an object, so a valid patch of millions of tiny lines within the
82
+ * byte bounds would otherwise cost the runner hundreds of MiB to parse. A
83
+ * project that would pass what is left of it is an error, unparsed. */
84
+ export const TASK_DIFF_MAX_LINES = 100_000;
85
+ /** A diff's bound on its result as the runner sends it: JSON-escaped, a
86
+ * patch's control bytes take six bytes each, so patches within the byte and
87
+ * line bounds could still serialize past the bridge's frame limit (100 MiB)
88
+ * and get the runner's connection closed. A project that would pass what is
89
+ * left of it is an error, unparsed. */
90
+ export const TASK_DIFF_RESULT_BYTES = 48 * 1024 * 1024;
91
+ /** What each rendered line adds to the result beyond its own text: its
92
+ * object, keys and line numbers. */
93
+ export const TASK_DIFF_LINE_RESULT_BYTES = 96;
94
+ /** What each project's entry may take in the result besides its files: its
95
+ * id, name, path, base and error, each at its bound and JSON-escaped at
96
+ * worst, and their keys. Reserved from the result bound up front. */
97
+ export const TASK_DIFF_REPO_RESULT_BYTES = 16 * 1024;
98
+ /** A project's reported error, and its base, at most: both are the task's
99
+ * own (its Git's stderr, its remote's default branch). */
100
+ export const TASK_DIFF_ERROR_CHARS = 1024;
101
+ export const TASK_DIFF_BASE_CHARS = 256;
102
+ /** How long a diff may take, every project of it: it is killed inside its
103
+ * runtime past this, everything it started with it. */
104
+ export const TASK_DIFF_TIMEOUT_MS = 300_000;
105
+ /** Cap how many untracked files are rendered as new-file patches, so a
106
+ * pathological untracked directory that slips past .gitignore cannot
107
+ * turn one diff into thousands of patches. */
108
+ export const MAX_UNTRACKED = 200;
109
+
110
+ /**
111
+ * `bounded CMD...` (POSIX sh): runs CMD with its output in `$tmp/out` (at
112
+ * most `$limit` bytes), its errors in `$tmp/err` (at most 4 KiB) and its
113
+ * status in `$tmp/status`. Each stream goes through a pipe whose reader
114
+ * stops at its bound, so a writer past it is stopped (SIGPIPE) and nothing
115
+ * past it is held, in memory or on disk.
116
+ */
117
+ export const TASK_DIFF_BOUNDED = [
118
+ "bounded() {",
119
+ ' echo 1 >"$tmp/status"',
120
+ ' { { "$@"; echo "$?" >"$tmp/status"; } 2>&1 1>&3 3>&- | head -c 4096 >"$tmp/err"; } 3>&1 | head -c "$limit" >"$tmp/out"',
121
+ "}",
122
+ ].join("\n");
123
+
124
+ /**
125
+ * A task's whole diff, as one POSIX sh run in the task's environment (its
126
+ * runtime while it runs, a stopped task's sandbox otherwise; never the
127
+ * host, #cb3j0z): `$1` the bound on each producer's bytes, `$2` the
128
+ * untracked bound, `$3` the bound on the whole output, then each project's
129
+ * directory below the working directory. For each project it resolves the
130
+ * base (the remote's default branch, `origin/HEAD`, else `origin/main`:
131
+ * never the worktree's upstream, which flips to the pushed task branch and
132
+ * would hide all committed work), diffs the merge-base against the working
133
+ * tree (committed + staged + unstaged), and renders each untracked file
134
+ * (honouring .gitignore) as a new-file patch.
135
+ *
136
+ * Whatever runs for it, the task's own config drivers included, is bounded
137
+ * where it writes: its output at one byte past the bound and its errors at
138
+ * 4 KiB, each through a pipe that stops the writer once full, so nothing it
139
+ * produces is held beyond that, in memory or on disk.
140
+ *
141
+ * Every value is a line `<kind> <base64>`, so any bytes survive whatever
142
+ * carries them: `P` a project, then either `E` an error, or `B` its base,
143
+ * `D` its tracked patches and `U` its untracked ones (or `E`). The whole
144
+ * output stops at its bound.
145
+ */
146
+ export const TASK_DIFF_SCRIPT = [
147
+ "set -u",
148
+ 'each=$1; most=$2; cap=$3; shift 3',
149
+ 'limit=$((each + 1))',
150
+ 'tmp=$(mktemp -d "${TMPDIR:-/tmp}/uai-task-diff.XXXXXX") || exit 70',
151
+ "trap 'rm -rf \"$tmp\"' EXIT",
152
+ "root=$(pwd)",
153
+ 'frame() { printf "%s " "$1"; base64 -w0 <"$2"; printf "\\n"; }',
154
+ 'value() { printf "%s" "$2" >"$tmp/value"; frame "$1" "$tmp/value"; }',
155
+ TASK_DIFF_BOUNDED,
156
+ 'over() { [ "$(wc -c <"$tmp/out")" -gt "$each" ]; }',
157
+ "untracked() {",
158
+ ' git ls-files --others --exclude-standard -z 2>/dev/null | head -z -n "$most" \\',
159
+ ` | xargs -0 -r -n 1 sh -c 'git ${GIT_DIFF_ARGS} --no-index -- /dev/null "$1"; [ $? -le 1 ] || exit 255' untracked`,
160
+ "}",
161
+ "projects() {",
162
+ ' for slug in "$@"; do',
163
+ ' value P "$slug"',
164
+ ' if ! cd -- "$root/$slug" 2>/dev/null; then value E "not a project directory: $slug"; continue; fi',
165
+ " base=$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null) || base=",
166
+ ' [ -n "$base" ] || base=origin/main',
167
+ ' value B "$base"',
168
+ ' merge=$(git merge-base "$base" HEAD 2>/dev/null) || merge=',
169
+ ' [ -n "$merge" ] || merge=$base',
170
+ ` bounded git ${GIT_DIFF_ARGS} "$merge"`,
171
+ ' if over; then value E "the diff exceeded $each bytes"',
172
+ ' elif [ "$(cat "$tmp/status")" != 0 ]; then frame E "$tmp/err"',
173
+ " else",
174
+ ' frame D "$tmp/out"',
175
+ " bounded untracked",
176
+ ' if over; then value E "the untracked files exceeded $each bytes"',
177
+ ' elif [ "$(cat "$tmp/status")" != 0 ]; then frame E "$tmp/err"',
178
+ ' else frame U "$tmp/out"; fi',
179
+ " fi",
180
+ ' cd -- "$root"',
181
+ " done",
182
+ "}",
183
+ 'projects "$@" | head -c "$cap"',
184
+ ].join("\n");
185
+
186
+ /**
187
+ * The one exec of a task's diff of `slugs`, below `workspacePath`: killed
188
+ * inside the runtime at its deadline, the shell and everything it started
189
+ * (`timeout` signals their whole group), however the client that started it
190
+ * ended.
191
+ */
192
+ export function taskDiffRequest(
193
+ workspacePath: string,
194
+ slugs: readonly string[],
195
+ ): TaskEnvironmentExecRequest {
196
+ return {
197
+ projectEnvProfile: "maintenance",
198
+ argv: [
199
+ "/usr/bin/timeout",
200
+ "--signal=KILL",
201
+ String(TASK_DIFF_TIMEOUT_MS / 1000),
202
+ "/bin/sh",
203
+ "-c",
204
+ TASK_DIFF_SCRIPT,
205
+ "uai-task-diff",
206
+ String(TASK_DIFF_PATCH_BYTES),
207
+ String(MAX_UNTRACKED),
208
+ String(TASK_DIFF_OUTPUT_BYTES),
209
+ ...slugs,
210
+ ],
211
+ cwd: workspacePath,
212
+ env: { ...RUNTIME_AUTHORITY_ENV, ...NON_INTERACTIVE_GIT_ENV },
213
+ // The client's own bound, past the runtime's.
214
+ timeoutMs: TASK_DIFF_TIMEOUT_MS + 15_000,
215
+ // The script stops at its bound; past it, the exec itself is cut.
216
+ maxOutputBytes: TASK_DIFF_OUTPUT_BYTES + 1024 * 1024,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * The bytes `JSON.stringify(text)` takes, counted without making it: two
222
+ * for each quote or backslash and for each control character with a short
223
+ * escape, six for any other control character or lone surrogate, and each
224
+ * other character's UTF-8 width, plus the quotes around it.
225
+ */
226
+ export function jsonEscapedBytes(text: string): number {
227
+ let bytes = 2;
228
+ for (let index = 0; index < text.length; index += 1) {
229
+ const code = text.charCodeAt(index);
230
+ if (code === 0x22 || code === 0x5c) bytes += 2;
231
+ else if (code < 0x20) {
232
+ bytes += code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6;
233
+ } else if (code < 0x80) bytes += 1;
234
+ else if (code < 0x800) bytes += 2;
235
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length && (text.charCodeAt(index + 1) & 0xfc00) === 0xdc00) {
236
+ bytes += 4;
237
+ index += 1;
238
+ } else if (code >= 0xd800 && code <= 0xdfff) bytes += 6;
239
+ else bytes += 3;
240
+ }
241
+ return bytes;
242
+ }
243
+
244
+ /** How many lines `text` holds, counted without parsing it (the last one
245
+ * counts without its newline). */
246
+ export function countDiffLines(text: string): number {
247
+ let lines = 0;
248
+ let at = text.indexOf("\n");
249
+ while (at !== -1) {
250
+ lines += 1;
251
+ at = text.indexOf("\n", at + 1);
252
+ }
253
+ return text.length > 0 && !text.endsWith("\n") ? lines + 1 : lines;
254
+ }
255
+
256
+ /** One project of a diff's output. */
257
+ export interface TaskDiffProjectOutput {
258
+ slug: string;
259
+ base?: string;
260
+ /** Its tracked patches, then its untracked ones. */
261
+ patches?: string;
262
+ tracked?: boolean;
263
+ untracked?: boolean;
264
+ error?: string;
265
+ }
266
+
267
+ /**
268
+ * The projects of a diff's output (`TASK_DIFF_SCRIPT`), in order. Only whole
269
+ * lines count: a line the bound cut short is dropped, and its project is
270
+ * left without the value.
271
+ */
272
+ export function parseTaskDiffOutput(output: string): TaskDiffProjectOutput[] {
273
+ const projects: TaskDiffProjectOutput[] = [];
274
+ const lines = output.split("\n");
275
+ // The last element follows the last newline: whole only when empty.
276
+ lines.pop();
277
+ for (const line of lines) {
278
+ if (line.length < 2 || line[1] !== " " || !/^[A-Za-z0-9+/]*={0,2}$/.test(line.slice(2))) continue;
279
+ const value = Buffer.from(line.slice(2), "base64").toString("utf8");
280
+ const kind = line[0];
281
+ if (kind === "P") {
282
+ projects.push({ slug: value });
283
+ continue;
284
+ }
285
+ const project = projects.at(-1);
286
+ if (!project) continue;
287
+ if (kind === "B") project.base = value.slice(0, TASK_DIFF_BASE_CHARS);
288
+ else if (kind === "E") project.error = value.trim().slice(0, TASK_DIFF_ERROR_CHARS) || "the diff failed";
289
+ else if (kind === "D") {
290
+ project.patches = value;
291
+ project.tracked = true;
292
+ } else if (kind === "U") {
293
+ if (project.patches && value.length > 0 && !project.patches.endsWith("\n")) project.patches += "\n";
294
+ project.patches = (project.patches ?? "") + value;
295
+ project.untracked = true;
296
+ }
297
+ }
298
+ return projects;
299
+ }
300
+
301
+ /** Why a diff's exec gave nothing to parse: no exit, a failed one, or an
302
+ * output past its bound. Null when it can be parsed. */
303
+ export function taskDiffExecFailure(result: TaskEnvironmentExecResult): string | null {
304
+ if (result.stdoutTruncated || result.stderrTruncated) return "the diff exceeded its output bound";
305
+ if (result.exitCode === 0) return null;
306
+ const stderr = Buffer.from(result.stderr).toString("utf8").trim().slice(0, TASK_DIFF_ERROR_CHARS - 64);
307
+ const outcome =
308
+ result.exitCode === null
309
+ ? result.signal
310
+ ? `was terminated by ${result.signal}`
311
+ : "did not start"
312
+ : `exited ${result.exitCode}`;
313
+ return `the task diff ${outcome}${stderr ? `: ${stderr}` : ""}`;
314
+ }
315
+
316
+ /**
317
+ * Parse the output of `git diff` into structured file patches.
318
+ */
319
+ export function parseGitDiff(text: string): DiffFile[] {
320
+ if (!text) return [];
321
+ const lines = text.split("\n");
322
+ const files: DiffFile[] = [];
323
+ let i = 0;
324
+
325
+ while (i < lines.length) {
326
+ const line = lines[i] ?? "";
327
+ if (!line.startsWith("diff --git ")) {
328
+ i += 1;
329
+ continue;
330
+ }
331
+ // Each file section starts with `diff --git a/<path> b/<path>` and
332
+ // runs to the next `diff --git` line (or EOF).
333
+ const header = parseDiffGitHeader(line);
334
+ let path = header.bPath ?? header.aPath ?? "";
335
+ let oldPath: string | undefined;
336
+ let status: DiffStatus = "modified";
337
+ let binary = false;
338
+ const hunks: DiffHunk[] = [];
339
+
340
+ i += 1;
341
+ while (i < lines.length && !(lines[i] ?? "").startsWith("diff --git ")) {
342
+ const ln = lines[i] ?? "";
343
+ if (ln.startsWith("new file mode")) status = "added";
344
+ else if (ln.startsWith("deleted file mode")) status = "deleted";
345
+ else if (ln.startsWith("rename from ")) {
346
+ status = "renamed";
347
+ oldPath = ln.slice("rename from ".length);
348
+ } else if (ln.startsWith("rename to ")) {
349
+ path = ln.slice("rename to ".length);
350
+ } else if (ln.startsWith("copy from ")) {
351
+ status = "copied";
352
+ oldPath = ln.slice("copy from ".length);
353
+ } else if (ln.startsWith("copy to ")) {
354
+ path = ln.slice("copy to ".length);
355
+ } else if (ln.startsWith("Binary files ")) {
356
+ binary = true;
357
+ status = status === "modified" ? "binary" : status;
358
+ } else if (ln.startsWith("--- ")) {
359
+ const p = stripPathPrefix(ln.slice(4));
360
+ if (p !== "/dev/null" && status === "modified") oldPath = oldPath ?? p;
361
+ if (p === "/dev/null") status = "added";
362
+ } else if (ln.startsWith("+++ ")) {
363
+ const p = stripPathPrefix(ln.slice(4));
364
+ if (p !== "/dev/null") path = p;
365
+ else status = "deleted";
366
+ } else if (ln.startsWith("@@")) {
367
+ const { hunk, consumed } = parseHunk(lines, i);
368
+ if (hunk) hunks.push(hunk);
369
+ i += consumed;
370
+ continue;
371
+ }
372
+ i += 1;
373
+ }
374
+
375
+ files.push({
376
+ path,
377
+ oldPath: oldPath && oldPath !== path ? oldPath : undefined,
378
+ status,
379
+ language: languageFor(path),
380
+ binary,
381
+ hunks,
382
+ });
383
+ }
384
+
385
+ return files;
386
+ }
387
+
388
+ function parseDiffGitHeader(line: string): {
389
+ aPath?: string;
390
+ bPath?: string;
391
+ } {
392
+ // `diff --git a/foo b/foo` — paths may contain spaces if quoted.
393
+ // Quoted form is `diff --git "a/path with space" "b/path with space"`.
394
+ // For simplicity (uai paths are well-behaved), greedy match on the
395
+ // common forms.
396
+ const m = line.match(
397
+ /^diff --git (?:"a\/(.*?)"|a\/(\S+)) (?:"b\/(.*?)"|b\/(\S+))$/,
398
+ );
399
+ if (!m) return {};
400
+ const a = m[1] ?? m[2];
401
+ const b = m[3] ?? m[4];
402
+ return { aPath: a, bPath: b };
403
+ }
404
+
405
+ function stripPathPrefix(s: string): string {
406
+ // `a/foo`, `b/foo`, or `/dev/null`. Quoted form: `"a/foo"`.
407
+ const t = s.trim().replace(/^"(.*)"$/, "$1");
408
+ if (t === "/dev/null") return t;
409
+ if (t.startsWith("a/") || t.startsWith("b/")) return t.slice(2);
410
+ return t;
411
+ }
412
+
413
+ function parseHunk(
414
+ lines: string[],
415
+ start: number,
416
+ ): { hunk: DiffHunk | null; consumed: number } {
417
+ const headerLine = lines[start] ?? "";
418
+ // `@@ -<oldStart>,<oldLines> +<newStart>,<newLines> @@ optional label`
419
+ const m = headerLine.match(
420
+ /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/,
421
+ );
422
+ if (!m) return { hunk: null, consumed: 1 };
423
+ const oldStart = Number(m[1]);
424
+ const oldLines = m[2] === undefined ? 1 : Number(m[2]);
425
+ const newStart = Number(m[3]);
426
+ const newLines = m[4] === undefined ? 1 : Number(m[4]);
427
+ const header = m[5] ?? "";
428
+
429
+ const out: DiffLine[] = [];
430
+ let oldNo = oldStart;
431
+ let newNo = newStart;
432
+ let i = start + 1;
433
+ let oldSeen = 0;
434
+ let newSeen = 0;
435
+
436
+ while (i < lines.length) {
437
+ const ln = lines[i] ?? "";
438
+ // End conditions: next hunk in the same file, next file, or EOF.
439
+ if (ln.startsWith("@@") || ln.startsWith("diff --git ")) break;
440
+ // `` markers — annotate the previous
441
+ // line by not advancing counters. Just skip.
442
+ if (ln.startsWith("\\ ")) {
443
+ i += 1;
444
+ continue;
445
+ }
446
+ const c = ln.charAt(0);
447
+ if (c === "+") {
448
+ out.push({ kind: "add", content: ln.slice(1), newNo });
449
+ newNo += 1;
450
+ newSeen += 1;
451
+ } else if (c === "-") {
452
+ out.push({ kind: "del", content: ln.slice(1), oldNo });
453
+ oldNo += 1;
454
+ oldSeen += 1;
455
+ } else if (c === " " || ln === "") {
456
+ // Context line. Git emits a space prefix for context; an empty
457
+ // line in unified diff is still a context line (an empty file
458
+ // line).
459
+ out.push({
460
+ kind: "ctx",
461
+ content: c === " " ? ln.slice(1) : "",
462
+ oldNo,
463
+ newNo,
464
+ });
465
+ oldNo += 1;
466
+ newNo += 1;
467
+ oldSeen += 1;
468
+ newSeen += 1;
469
+ } else {
470
+ // Anything else — probably the start of metadata for a fresh
471
+ // file we somehow didn't catch. Bail out so the outer loop can
472
+ // re-process it.
473
+ break;
474
+ }
475
+ i += 1;
476
+ // Hunks are self-delimiting by their counts. Once we've consumed
477
+ // them, stop — anything further belongs to the next section.
478
+ if (oldSeen >= oldLines && newSeen >= newLines) break;
479
+ }
480
+
481
+ return {
482
+ hunk: {
483
+ oldStart,
484
+ oldLines,
485
+ newStart,
486
+ newLines,
487
+ header,
488
+ lines: out,
489
+ },
490
+ consumed: i - start,
491
+ };
492
+ }
493
+
494
+ const LANG_BY_EXT: Record<string, string> = {
495
+ ts: "typescript",
496
+ tsx: "typescript",
497
+ js: "javascript",
498
+ jsx: "javascript",
499
+ mjs: "javascript",
500
+ cjs: "javascript",
501
+ json: "json",
502
+ md: "markdown",
503
+ mdx: "markdown",
504
+ css: "css",
505
+ scss: "scss",
506
+ html: "html",
507
+ yml: "yaml",
508
+ yaml: "yaml",
509
+ toml: "toml",
510
+ py: "python",
511
+ rb: "ruby",
512
+ go: "go",
513
+ rs: "rust",
514
+ java: "java",
515
+ c: "c",
516
+ h: "c",
517
+ cpp: "cpp",
518
+ hpp: "cpp",
519
+ sh: "bash",
520
+ bash: "bash",
521
+ sql: "sql",
522
+ swift: "swift",
523
+ kt: "kotlin",
524
+ php: "php",
525
+ };
526
+
527
+ function languageFor(path: string): string | undefined {
528
+ const ext = path.includes(".") ? path.split(".").pop()!.toLowerCase() : "";
529
+ return LANG_BY_EXT[ext];
530
+ }