@jr2/orchestrator 0.3.0 → 0.5.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 (354) hide show
  1. package/README.md +20 -2
  2. package/bin/server.ts +7 -4
  3. package/console/canvas.ts +27 -14
  4. package/console/components/app.ts +12 -1
  5. package/console/components/fleet.ts +52 -6
  6. package/console/components/machine-pane.ts +128 -2
  7. package/console/components/nav.ts +1 -1
  8. package/console/components/schema-form.ts +12 -4
  9. package/console/json-field.ts +23 -0
  10. package/console/main.ts +122 -39
  11. package/console/store.ts +321 -21
  12. package/console/style.css +111 -0
  13. package/docs/agents-block.md +5 -0
  14. package/docs/concepts/agents-and-turns.md +442 -0
  15. package/docs/concepts/architecture.md +230 -0
  16. package/docs/concepts/charts.md +143 -0
  17. package/docs/concepts/composition.md +357 -0
  18. package/docs/concepts/converge.md +254 -0
  19. package/docs/concepts/durable-runs.md +189 -0
  20. package/docs/concepts/effects.md +603 -0
  21. package/docs/concepts/gates.md +208 -0
  22. package/docs/concepts/machines-and-workflows.md +289 -0
  23. package/docs/concepts/pools-and-sources.md +230 -0
  24. package/docs/concepts/secrets-and-trust.md +222 -0
  25. package/docs/concepts/the-menu.md +378 -0
  26. package/docs/concepts/workspaces.md +678 -0
  27. package/docs/get-started/a-model-decides.md +563 -0
  28. package/docs/get-started/an-agent-in-a-sandbox.md +381 -0
  29. package/docs/get-started/connect-a-model.md +248 -0
  30. package/docs/get-started/create-an-instance.md +194 -0
  31. package/docs/get-started/have-your-assistant-write-a-machine.md +922 -0
  32. package/docs/get-started/install.md +126 -0
  33. package/docs/guides/add-tools-to-the-sandbox.md +295 -0
  34. package/docs/guides/answer-a-gate.md +304 -0
  35. package/docs/guides/bind-open-parts.md +223 -0
  36. package/docs/guides/cancel-a-run.md +146 -0
  37. package/docs/guides/clean-up.md +206 -0
  38. package/docs/guides/fix-a-failed-converge.md +361 -0
  39. package/docs/guides/give-the-cluster-git-credentials.md +222 -0
  40. package/docs/guides/iterate-on-a-machine-on-kind.md +190 -0
  41. package/docs/guides/let-an-agent-write-a-chart.md +226 -0
  42. package/docs/guides/look-inside-a-sandbox.md +174 -0
  43. package/docs/guides/mirror-the-kit-images.md +176 -0
  44. package/docs/guides/move-from-kind-to-a-shared-cluster.md +302 -0
  45. package/docs/guides/open-the-console.md +91 -0
  46. package/docs/guides/publish-a-machine-as-an-npm-package.md +350 -0
  47. package/docs/guides/push-and-sign-a-branch.md +486 -0
  48. package/docs/guides/run-a-chart-by-name.md +270 -0
  49. package/docs/guides/run-a-command-or-call-a-url.md +544 -0
  50. package/docs/guides/run-one-worker-per-item.md +321 -0
  51. package/docs/guides/set-an-agents-access-and-dials.md +375 -0
  52. package/docs/guides/size-a-workspace.md +204 -0
  53. package/docs/guides/start-a-run-from-a-run.md +237 -0
  54. package/docs/guides/start-a-run.md +207 -0
  55. package/docs/guides/upgrade-the-kit-version.md +240 -0
  56. package/docs/guides/use-a-hosted-provider.md +208 -0
  57. package/docs/guides/use-a-local-model.md +313 -0
  58. package/docs/guides/watch-a-run.md +326 -0
  59. package/docs/guides/write-a-workflow.md +318 -0
  60. package/docs/index.md +44 -0
  61. package/docs/llms.txt +216 -0
  62. package/docs/reference/authoring-surface/agent.md +128 -0
  63. package/docs/reference/authoring-surface/answer.md +140 -0
  64. package/docs/reference/authoring-surface/arg.md +60 -0
  65. package/docs/reference/authoring-surface/basic.md +62 -0
  66. package/docs/reference/authoring-surface/bearer.md +53 -0
  67. package/docs/reference/authoring-surface/chartschema.md +23 -0
  68. package/docs/reference/authoring-surface/chartschemafor.md +48 -0
  69. package/docs/reference/authoring-surface/command.md +191 -0
  70. package/docs/reference/authoring-surface/customize.md +64 -0
  71. package/docs/reference/authoring-surface/defineconfig.md +41 -0
  72. package/docs/reference/authoring-surface/defineevent.md +114 -0
  73. package/docs/reference/authoring-surface/dispatch.md +126 -0
  74. package/docs/reference/authoring-surface/fail.md +83 -0
  75. package/docs/reference/authoring-surface/file.md +122 -0
  76. package/docs/reference/authoring-surface/frompick.md +277 -0
  77. package/docs/reference/authoring-surface/http.md +159 -0
  78. package/docs/reference/authoring-surface/isfailure.md +58 -0
  79. package/docs/reference/authoring-surface/jr2setup.md +237 -0
  80. package/docs/reference/authoring-surface/open.md +74 -0
  81. package/docs/reference/authoring-surface/openbody.md +86 -0
  82. package/docs/reference/authoring-surface/paramsof.md +125 -0
  83. package/docs/reference/authoring-surface/pool.md +186 -0
  84. package/docs/reference/authoring-surface/push.md +112 -0
  85. package/docs/reference/authoring-surface/secret.md +118 -0
  86. package/docs/reference/authoring-surface/sentby.md +83 -0
  87. package/docs/reference/authoring-surface/source.md +51 -0
  88. package/docs/reference/authoring-surface/text.md +62 -0
  89. package/docs/reference/authoring-surface/type-agentdeclaration.md +27 -0
  90. package/docs/reference/authoring-surface/type-agentdefinition.md +80 -0
  91. package/docs/reference/authoring-surface/type-agentlogic.md +15 -0
  92. package/docs/reference/authoring-surface/type-agentslots.md +12 -0
  93. package/docs/reference/authoring-surface/type-answeraction.md +12 -0
  94. package/docs/reference/authoring-surface/type-arg.md +19 -0
  95. package/docs/reference/authoring-surface/type-argnames.md +12 -0
  96. package/docs/reference/authoring-surface/type-binding.md +62 -0
  97. package/docs/reference/authoring-surface/type-bindingof.md +12 -0
  98. package/docs/reference/authoring-surface/type-bodyacceptsdoor.md +15 -0
  99. package/docs/reference/authoring-surface/type-bodyacceptsslots.md +17 -0
  100. package/docs/reference/authoring-surface/type-ceilings.md +41 -0
  101. package/docs/reference/authoring-surface/type-chainlink.md +19 -0
  102. package/docs/reference/authoring-surface/type-chartaction.md +46 -0
  103. package/docs/reference/authoring-surface/type-chartagent.md +28 -0
  104. package/docs/reference/authoring-surface/type-chartevent.md +20 -0
  105. package/docs/reference/authoring-surface/type-chartguard.md +20 -0
  106. package/docs/reference/authoring-surface/type-chartinvoke.md +28 -0
  107. package/docs/reference/authoring-surface/type-chartjson.md +14 -0
  108. package/docs/reference/authoring-surface/type-chartstate.md +46 -0
  109. package/docs/reference/authoring-surface/type-charttext.md +40 -0
  110. package/docs/reference/authoring-surface/type-charttransition.md +22 -0
  111. package/docs/reference/authoring-surface/type-charttransitions.md +14 -0
  112. package/docs/reference/authoring-surface/type-chartvalue.md +15 -0
  113. package/docs/reference/authoring-surface/type-childat.md +12 -0
  114. package/docs/reference/authoring-surface/type-childslots.md +12 -0
  115. package/docs/reference/authoring-surface/type-commandenv.md +15 -0
  116. package/docs/reference/authoring-surface/type-commandinput.md +19 -0
  117. package/docs/reference/authoring-surface/type-commandoptions.md +78 -0
  118. package/docs/reference/authoring-surface/type-commandoutput.md +104 -0
  119. package/docs/reference/authoring-surface/type-customize.md +109 -0
  120. package/docs/reference/authoring-surface/type-dispatchdefinition.md +39 -0
  121. package/docs/reference/authoring-surface/type-dispatchinput.md +14 -0
  122. package/docs/reference/authoring-surface/type-dispatchopenparts.md +14 -0
  123. package/docs/reference/authoring-surface/type-dispatchoutput.md +65 -0
  124. package/docs/reference/authoring-surface/type-duration.md +15 -0
  125. package/docs/reference/authoring-surface/type-effectbinding.md +93 -0
  126. package/docs/reference/authoring-surface/type-effectlogic.md +12 -0
  127. package/docs/reference/authoring-surface/type-envopenparts.md +14 -0
  128. package/docs/reference/authoring-surface/type-eventaudience.md +27 -0
  129. package/docs/reference/authoring-surface/type-eventdef.md +45 -0
  130. package/docs/reference/authoring-surface/type-eventfrom.md +16 -0
  131. package/docs/reference/authoring-surface/type-eventsemantics.md +26 -0
  132. package/docs/reference/authoring-surface/type-execoutcome.md +34 -0
  133. package/docs/reference/authoring-surface/type-failslot.md +14 -0
  134. package/docs/reference/authoring-surface/type-failure.md +19 -0
  135. package/docs/reference/authoring-surface/type-failureinput.md +20 -0
  136. package/docs/reference/authoring-surface/type-filebody.md +41 -0
  137. package/docs/reference/authoring-surface/type-frominput.md +14 -0
  138. package/docs/reference/authoring-surface/type-hostinjectedinput.md +58 -0
  139. package/docs/reference/authoring-surface/type-httpdefinition.md +101 -0
  140. package/docs/reference/authoring-surface/type-httpinput.md +12 -0
  141. package/docs/reference/authoring-surface/type-httpmethod.md +14 -0
  142. package/docs/reference/authoring-surface/type-httpopenparts.md +12 -0
  143. package/docs/reference/authoring-surface/type-httpoutput.md +89 -0
  144. package/docs/reference/authoring-surface/type-httprequestparts.md +33 -0
  145. package/docs/reference/authoring-surface/type-jr2config.md +126 -0
  146. package/docs/reference/authoring-surface/type-lends.md +38 -0
  147. package/docs/reference/authoring-surface/type-logicat.md +12 -0
  148. package/docs/reference/authoring-surface/type-markedif.md +12 -0
  149. package/docs/reference/authoring-surface/type-mergedactors.md +12 -0
  150. package/docs/reference/authoring-surface/type-namedsecret.md +25 -0
  151. package/docs/reference/authoring-surface/type-noslotspec.md +26 -0
  152. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +32 -0
  153. package/docs/reference/authoring-surface/type-openbodydeclaration.md +40 -0
  154. package/docs/reference/authoring-surface/type-openbodylogic.md +14 -0
  155. package/docs/reference/authoring-surface/type-openbodyoutput.md +42 -0
  156. package/docs/reference/authoring-surface/type-openbodyslot.md +18 -0
  157. package/docs/reference/authoring-surface/type-opencommandinput.md +17 -0
  158. package/docs/reference/authoring-surface/type-openeffect.md +12 -0
  159. package/docs/reference/authoring-surface/type-openeffectslots.md +12 -0
  160. package/docs/reference/authoring-surface/type-openof.md +12 -0
  161. package/docs/reference/authoring-surface/type-opensecret.md +23 -0
  162. package/docs/reference/authoring-surface/type-opensecretmark.md +12 -0
  163. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +31 -0
  164. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +32 -0
  165. package/docs/reference/authoring-surface/type-poolmachine.md +28 -0
  166. package/docs/reference/authoring-surface/type-poolspec.md +60 -0
  167. package/docs/reference/authoring-surface/type-pushinput.md +15 -0
  168. package/docs/reference/authoring-surface/type-pushoptions.md +79 -0
  169. package/docs/reference/authoring-surface/type-pushoutput.md +124 -0
  170. package/docs/reference/authoring-surface/type-pushwireoutput.md +12 -0
  171. package/docs/reference/authoring-surface/type-quantity.md +14 -0
  172. package/docs/reference/authoring-surface/type-reached.md +12 -0
  173. package/docs/reference/authoring-surface/type-reposlot.md +70 -0
  174. package/docs/reference/authoring-surface/type-reposlotsof.md +16 -0
  175. package/docs/reference/authoring-surface/type-sandboxoptions.md +77 -0
  176. package/docs/reference/authoring-surface/type-secret.md +25 -0
  177. package/docs/reference/authoring-surface/type-sender.md +19 -0
  178. package/docs/reference/authoring-surface/type-sentbyguards.md +12 -0
  179. package/docs/reference/authoring-surface/type-size.md +85 -0
  180. package/docs/reference/authoring-surface/type-slothandles.md +32 -0
  181. package/docs/reference/authoring-surface/type-sourcenextresult.md +17 -0
  182. package/docs/reference/authoring-surface/type-sourcespec.md +44 -0
  183. package/docs/reference/authoring-surface/type-syncstate.md +12 -0
  184. package/docs/reference/authoring-surface/type-text.md +19 -0
  185. package/docs/reference/authoring-surface/type-textpart.md +24 -0
  186. package/docs/reference/authoring-surface/type-thinkinglevel.md +39 -0
  187. package/docs/reference/authoring-surface/type-toparameterizedobject.md +14 -0
  188. package/docs/reference/authoring-surface/type-toprovidedactor.md +14 -0
  189. package/docs/reference/authoring-surface/type-unknown.md +21 -0
  190. package/docs/reference/authoring-surface/type-usercontainer.md +77 -0
  191. package/docs/reference/authoring-surface/type-withoutemptyindex.md +20 -0
  192. package/docs/reference/authoring-surface/type-workflowevent.md +46 -0
  193. package/docs/reference/authoring-surface/type-workspaceaccess.md +16 -0
  194. package/docs/reference/authoring-surface/type-workspaced.md +94 -0
  195. package/docs/reference/authoring-surface/type-workspacehandles.md +23 -0
  196. package/docs/reference/authoring-surface/type-workspacemachine.md +29 -0
  197. package/docs/reference/authoring-surface/type-workspaceof.md +13 -0
  198. package/docs/reference/authoring-surface/type-workspaceoptions.md +39 -0
  199. package/docs/reference/authoring-surface/type-workspacespec.md +37 -0
  200. package/docs/reference/authoring-surface/workspace.md +259 -0
  201. package/docs/reference/authoring-surface.md +197 -0
  202. package/docs/reference/cli.md +433 -0
  203. package/docs/reference/config.md +756 -0
  204. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +113 -0
  205. package/docs/reference/decisions/0002-duplex-streaming-actor.md +110 -0
  206. package/docs/reference/decisions/0004-worktree-provisioning-model.md +124 -0
  207. package/docs/reference/decisions/0005-sandbox-pod-composition.md +230 -0
  208. package/docs/reference/decisions/0006-agent-control-surface.md +47 -0
  209. package/docs/reference/decisions/0007-durable-machine-state.md +109 -0
  210. package/docs/reference/decisions/0008-orchestrator-as-deployed-app.md +60 -0
  211. package/docs/reference/decisions/0009-cli-and-instance-interface.md +275 -0
  212. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +160 -0
  213. package/docs/reference/decisions/0011-workflow-defined-events.md +141 -0
  214. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +103 -0
  215. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +157 -0
  216. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +95 -0
  217. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +265 -0
  218. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +124 -0
  219. package/docs/reference/decisions/0017-pool-over-a-source-port.md +69 -0
  220. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +111 -0
  221. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +275 -0
  222. package/docs/reference/decisions/0020-private-ca-trust-is-a-named-concept.md +60 -0
  223. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +100 -0
  224. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +103 -0
  225. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +176 -0
  226. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +439 -0
  227. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +65 -0
  228. package/docs/reference/decisions/0026-a-turn-that-is-over-is-never-prompted.md +62 -0
  229. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +237 -0
  230. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +89 -0
  231. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +154 -0
  232. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +100 -0
  233. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +190 -0
  234. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +61 -0
  235. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +127 -0
  236. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +57 -0
  237. package/docs/reference/decisions/0035-a-runaway-turn-is-ended-rerolled-once-then-a-fault.md +92 -0
  238. package/docs/reference/decisions/0036-a-turn-compacts-its-own-context-mid-flight.md +138 -0
  239. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +196 -0
  240. package/docs/reference/decisions/0038-jr2-up-builds-every-image-it-deploys.md +170 -0
  241. package/docs/reference/decisions/0039-image-garbage-collects-by-reachability.md +115 -0
  242. package/docs/reference/decisions/0041-a-build-the-host-already-holds-is-not-spent-again.md +64 -0
  243. package/docs/reference/decisions/0042-ready-is-not-routable-an-admission-retries-its-connection.md +142 -0
  244. package/docs/reference/decisions/0043-the-kit-is-tested-as-installed-a-local-registry-stands-in-for-npm.md +170 -0
  245. package/docs/reference/decisions/0044-kit-images-live-at-a-canonical-home-a-self-host-mirrors-it.md +85 -0
  246. package/docs/reference/decisions/0045-the-platform-joins-the-image-address-and-the-cluster-chooses-it.md +82 -0
  247. package/docs/reference/decisions/0046-a-converge-failure-carries-its-diagnosis.md +34 -0
  248. package/docs/reference/decisions/0047-the-git-ssh-key-source-is-the-users-choice.md +47 -0
  249. package/docs/reference/decisions/0048-the-orchestrator-boots-without-its-repos.md +36 -0
  250. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +126 -0
  251. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +73 -0
  252. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +227 -0
  253. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +81 -0
  254. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +111 -0
  255. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +126 -0
  256. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +107 -0
  257. package/docs/reference/decisions/0056-a-global-jr2-hands-off-to-the-instances-own-and-an-instance-has-one-kit-version.md +62 -0
  258. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +152 -0
  259. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +106 -0
  260. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +384 -0
  261. package/docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md +111 -0
  262. package/docs/reference/decisions/0061-a-memory-kill-ends-the-agents-processes-not-the-harness.md +83 -0
  263. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +212 -0
  264. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +83 -0
  265. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +79 -0
  266. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +65 -0
  267. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +550 -0
  268. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +430 -0
  269. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +464 -0
  270. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +180 -0
  271. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +323 -0
  272. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +183 -0
  273. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +144 -0
  274. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +122 -0
  275. package/docs/reference/decisions/0074-an-unrouted-mechanism-event-is-refused-at-load-and-faults-the-run.md +76 -0
  276. package/docs/reference/decisions.md +83 -0
  277. package/docs/reference/glossary.md +648 -0
  278. package/docs/reference/http-api.md +1348 -0
  279. package/docs/reference/machines.md +190 -0
  280. package/docs/reference/mechanism-events.md +72 -0
  281. package/docs/why.md +206 -0
  282. package/package.json +6 -4
  283. package/src/actor.ts +976 -61
  284. package/src/agent.ts +114 -38
  285. package/src/ambient.ts +4 -1
  286. package/src/assembly-checks.ts +347 -0
  287. package/src/assembly-tar.ts +494 -0
  288. package/src/assembly.ts +721 -0
  289. package/src/cause.ts +234 -0
  290. package/src/chart.ts +2182 -0
  291. package/src/charts.ts +748 -0
  292. package/src/config.ts +520 -162
  293. package/src/content-hash.ts +105 -0
  294. package/src/custodian.lua +6 -5
  295. package/src/custodian.ts +143 -39
  296. package/src/customize.ts +388 -55
  297. package/src/dispatch-effect.ts +945 -0
  298. package/src/door-compat.ts +217 -0
  299. package/src/durability.ts +11 -4
  300. package/src/effect-checks.ts +557 -0
  301. package/src/effect.ts +1202 -0
  302. package/src/effector-wire.ts +674 -0
  303. package/src/fail.ts +209 -0
  304. package/src/fingerprint.ts +4 -3
  305. package/src/gate.ts +8 -0
  306. package/src/git-clone.ts +65 -0
  307. package/src/git-push.ts +336 -0
  308. package/src/harness-client.ts +764 -48
  309. package/src/held-secrets.ts +82 -26
  310. package/src/holds.ts +156 -0
  311. package/src/http-effect.ts +1423 -0
  312. package/src/http.ts +916 -113
  313. package/src/images.ts +47 -21
  314. package/src/index.ts +62 -43
  315. package/src/instance.ts +505 -59
  316. package/src/internal.ts +84 -0
  317. package/src/kube-client.ts +333 -0
  318. package/src/machine-doc.ts +106 -6
  319. package/src/mechanism-routes.ts +168 -0
  320. package/src/model-keys.generated.ts +53 -0
  321. package/src/model-keys.ts +18 -58
  322. package/src/mount-digest.ts +53 -0
  323. package/src/names.ts +66 -8
  324. package/src/notices.ts +141 -0
  325. package/src/open-body.ts +1580 -0
  326. package/src/open-effect.ts +217 -0
  327. package/src/open.ts +59 -6
  328. package/src/parts.ts +709 -41
  329. package/src/party.ts +534 -0
  330. package/src/pick.ts +761 -0
  331. package/src/pool.ts +248 -37
  332. package/src/registration.ts +382 -19
  333. package/src/repo-fetch.ts +60 -85
  334. package/src/repos.ts +65 -75
  335. package/src/rest.ts +133 -0
  336. package/src/run-host.ts +2383 -115
  337. package/src/run-output.ts +38 -0
  338. package/src/sandbox-effect.ts +1126 -0
  339. package/src/sandbox-effector.ts +111 -0
  340. package/src/sandbox-kube.ts +1721 -0
  341. package/src/sandbox-watch.ts +260 -0
  342. package/src/secret-placement.ts +529 -0
  343. package/src/server.ts +284 -84
  344. package/src/setup.ts +819 -117
  345. package/src/size.ts +454 -0
  346. package/src/snapshot-store.ts +115 -20
  347. package/src/stub-harness.ts +49 -16
  348. package/src/sync-client.ts +54 -0
  349. package/src/timers.ts +191 -0
  350. package/src/tokens.ts +35 -4
  351. package/src/vocabulary.ts +135 -15
  352. package/src/wire.ts +238 -10
  353. package/src/workspace.ts +1195 -338
  354. package/src/sandbox-kubectl.ts +0 -1108
package/src/size.ts ADDED
@@ -0,0 +1,454 @@
1
+ // A Workspace's SIZE (ADR-0060): the ceiling it states for its whole Sandbox, cpu and memory and
2
+ // nothing else, which jr2 reserves in full. The shape is Agent Substrate's `ActorTemplate.resources`
3
+ // — a Kubernetes `ResourceRequirements` of which only `limits.cpu` and `limits.memory` are
4
+ // accepted — so a Workspace moves there unchanged.
5
+ //
6
+ // This module is the arithmetic and the refusals, with no Kubernetes client and no Machine in it:
7
+ //
8
+ // - `assertSize` refuses anything but the two limits WHERE IT IS READ (`workspace()`,
9
+ // `customize()`, `jr2.config.ts`), naming the field — a request below the limit, a missing
10
+ // memory limit, or a key Substrate refuses would make the Machine depend on something the move
11
+ // drops.
12
+ // - `resolveSize` walks the chain, most specific first, one field at a time: the Machine's Size
13
+ // (a `customize()` layered over the `workspace()`), then the Instance's `sandbox.resources` — a
14
+ // default for a Workspace that states none, never an override of one it states — then the kit.
15
+ // - `splitSize` divides the Size inside the pod: the Custodian's fixed slice, the User
16
+ // Container's split if the Machine states one, and the Harness gets the rest as its own limit,
17
+ // so an OOM stays per container where it can. It refuses a split that leaves the Harness below
18
+ // its floor, naming where each number came from, because the fix is an edit of one of them.
19
+ //
20
+ // Both the provision (sandbox-kube.ts) and `jr2 up` call the same two functions, so a Size the
21
+ // converge admits is a Size the provision composes.
22
+
23
+ /** A Kubernetes quantity: `"2Gi"`, `"1500m"`, `"2"`, or a plain number (`2` cpus, bytes of memory). */
24
+ export type Quantity = string | number;
25
+
26
+ // A Size (ADR-0060, GLOSSARY.md "Size"): `limits.memory` and `limits.cpu`, nothing else. A field
27
+ // left out resolves down the chain (`resolveSize`) — the Instance's `sandbox.resources`, then the
28
+ // kit default (`KIT_SIZE`). The same shape is the User Container's split (`userSeatOf`,
29
+ // `splitSize`), where a field left out means the seat has no limit of its own for it and shares
30
+ // the pod's budget.
31
+ /**
32
+ * The Size of a Workspace: the most memory and cpu that its whole Sandbox can use. jr2 reserves
33
+ * all of it on the node. Thus a Sandbox never gets less than its Size and never uses more.
34
+ *
35
+ * Write it as `{ limits: { memory, cpu } }`. You can write it in three places: the `resources`
36
+ * option of `workspace()`, `customize(machine, { resources })`, and `sandbox.resources` in
37
+ * `jr2.config.ts`. The Size is a fact of the Machine. The Instance's `sandbox.resources` applies
38
+ * only to a Workspace that states no Size. A field that neither states comes from the kit default:
39
+ * memory `2Gi`, cpu `1`.
40
+ *
41
+ * The same shape is the User Container's part of the Size, in `user: { image, resources }`. There,
42
+ * a field you leave out gives the User Container no limit of its own: it shares the pod's budget.
43
+ *
44
+ * It refuses any key but `limits.memory` and `limits.cpu`, where you write it. The message names
45
+ * the field: `<where>` is where you wrote the Size, for example `workspace(): resources`.
46
+ *
47
+ * - `<where>.requests is not accepted — jr2 reserves the whole Size, so every request equals its
48
+ * limit — a Size is { limits: { memory?, cpu? } } and nothing else — Agent Substrate's template
49
+ * size (ADR-0060)`
50
+ * - `<where>.<key> is not accepted — a Size is { limits: { memory?, cpu? } } and nothing else —
51
+ * Agent Substrate's template size (ADR-0060)`
52
+ * - `<where>.limits.<key> is not accepted — a Size is { limits: { memory?, cpu? } } and nothing
53
+ * else — Agent Substrate's template size (ADR-0060)`
54
+ * - `<where>.limits.memory: <value> is not a positive quantity (e.g. "2Gi", "512Mi")`, and for
55
+ * cpu, `<where>.limits.cpu: <value> is not a positive quantity (e.g. "2", "1500m")`
56
+ *
57
+ * `jr2 up` also refuses a Size that leaves the Harness container less than its floor. The message
58
+ * starts with `the Size leaves the Harness container <memory> memory, below its 256Mi floor:` or
59
+ * with `the Size leaves the Harness container no cpu:`. The text that follows names each number
60
+ * and where it came from.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * import { customize, type Size } from "@jr2/orchestrator";
65
+ * import { task } from "@jr2/machines";
66
+ *
67
+ * const size: Size = { limits: { memory: "4Gi", cpu: "2" } };
68
+ *
69
+ * export const machine = customize(task, {
70
+ * repos: { app: { url: "https://github.com/you/app.git" } },
71
+ * agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
72
+ * resources: size,
73
+ * });
74
+ * ```
75
+ *
76
+ * @see docs/guides/size-a-workspace.md
77
+ * @see docs/concepts/workspaces.md
78
+ * @see docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md
79
+ */
80
+ export type Size = {
81
+ /** The two limits. Write `limits: {}` to state no field: each field then comes from the next
82
+ * source. */
83
+ limits: {
84
+ /** The memory limit, as a Kubernetes quantity: `"4Gi"`, `"512Mi"`, or a number of bytes.
85
+ * It must be more than zero. When you leave it out, it comes from the next source, as above. */
86
+ memory?: Quantity;
87
+ /** The cpu limit, as a Kubernetes quantity: `"2"`, `"1500m"`, or a number of cpus. It must be
88
+ * more than zero. jr2 enforces it. When you leave it out, it comes from the next source, as
89
+ * above. */
90
+ cpu?: Quantity;
91
+ };
92
+ };
93
+
94
+ /** The kit's default Size (ADR-0060). Node gives each process a V8 heap of about 55% of its
95
+ * container limit, and `tsc` on a mid-size repo aborted below about 1.1Gi, so 1Gi fails a common
96
+ * task; 2Gi covers `tsc` + vitest, `cargo build` and Playwright at 2 workers. */
97
+ export const KIT_SIZE = { memory: "2Gi", cpu: "1" } as const;
98
+
99
+ /** The Custodian's fixed slice of every Sandbox's Size (ADR-0060). */
100
+ export const CUSTODIAN_SLICE = { memory: "64Mi", cpu: "50m" } as const;
101
+
102
+ /** The Harness container's floor: a split that leaves it less memory than this is refused. */
103
+ export const HARNESS_FLOOR = "256Mi";
104
+
105
+ /** `/dev/shm` is 25% of the Harness container's share, never below this (ADR-0060). */
106
+ export const SHM_FLOOR = "64Mi";
107
+
108
+ /** The `/work` disk: an ephemeral-storage REQUEST on the Harness container, with no limit and no
109
+ * `sizeLimit` — passing either evicts the pod and loses `/work` (ADR-0060). Kit-fixed. */
110
+ export const WORK_DISK_REQUEST = "10Gi";
111
+
112
+ /** The preflight init step's resources (ADR-0037): small and fixed, and inside the pod's budget —
113
+ * an init container runs before the others start, so it borrows the pod's Size, it adds nothing. */
114
+ export const PREFLIGHT_RESOURCES = {
115
+ requests: { cpu: "10m", memory: "32Mi" },
116
+ limits: { memory: "128Mi" },
117
+ } as const;
118
+
119
+ /** A container's or a pod's resources, in the Kubernetes (corev1.ResourceRequirements) shape. */
120
+ export type ResourceRequirements = {
121
+ requests?: Record<string, string>;
122
+ limits?: Record<string, string>;
123
+ };
124
+
125
+ // --- quantities --------------------------------------------------------------------------------
126
+
127
+ const QUANTITY = /^(\d+(?:\.\d+)?|\.\d+)(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$/;
128
+ const SCALE: Record<string, number> = {
129
+ "": 1,
130
+ m: 1e-3,
131
+ k: 1e3,
132
+ M: 1e6,
133
+ G: 1e9,
134
+ T: 1e12,
135
+ P: 1e15,
136
+ E: 1e18,
137
+ Ki: 2 ** 10,
138
+ Mi: 2 ** 20,
139
+ Gi: 2 ** 30,
140
+ Ti: 2 ** 40,
141
+ Pi: 2 ** 50,
142
+ Ei: 2 ** 60,
143
+ };
144
+
145
+ /** The quantity as a plain number of base units, or undefined when it is not one Kubernetes reads.
146
+ * Zero and negatives are not a Size: a zero limit is a container that cannot start. */
147
+ function baseUnits(q: unknown): number | undefined {
148
+ if (typeof q === "number") return Number.isFinite(q) && q > 0 ? q : undefined;
149
+ if (typeof q !== "string") return undefined;
150
+ const m = QUANTITY.exec(q.trim());
151
+ if (!m) return undefined;
152
+ const value = Number(m[1]) * SCALE[m[2] ?? ""]!;
153
+ return value > 0 ? value : undefined;
154
+ }
155
+
156
+ /** Memory in bytes, rounded up as Kubernetes rounds it. */
157
+ export function memoryBytes(q: Quantity): number {
158
+ const v = baseUnits(q);
159
+ if (v === undefined) throw new Error(`${JSON.stringify(q)} is not a memory quantity (e.g. "2Gi", "512Mi")`);
160
+ return Math.ceil(v - 1e-9);
161
+ }
162
+
163
+ /** CPU in millicores, rounded up to 1m as Kubernetes rounds it. */
164
+ export function cpuMillis(q: Quantity): number {
165
+ const v = baseUnits(q);
166
+ if (v === undefined) throw new Error(`${JSON.stringify(q)} is not a cpu quantity (e.g. "2", "1500m")`);
167
+ return Math.ceil(v * 1000 - 1e-9);
168
+ }
169
+
170
+ /** Bytes as the quantity a human would write: `Gi`, `Mi` or `Ki` when exact, else plain bytes. */
171
+ export function formatMemory(bytes: number): string {
172
+ for (const [unit, scale] of [
173
+ ["Gi", 2 ** 30],
174
+ ["Mi", 2 ** 20],
175
+ ["Ki", 2 ** 10],
176
+ ] as const) {
177
+ if (bytes % scale === 0) return `${bytes / scale}${unit}`;
178
+ }
179
+ return String(bytes);
180
+ }
181
+
182
+ /** Millicores as the quantity a human would write: whole cpus when exact, else `m`. */
183
+ export function formatCpu(millis: number): string {
184
+ return millis % 1000 === 0 ? String(millis / 1000) : `${millis}m`;
185
+ }
186
+
187
+ // --- the refusal where a Size is read ----------------------------------------------------------
188
+
189
+ /**
190
+ * Refuse anything but a Size, BY FIELD (ADR-0060): `requests`, any key beside `limits`, any limit
191
+ * beside `memory` and `cpu`, or a value Kubernetes would not read. `where` names the reader and the
192
+ * field's own path (`workspace(): resources`, `jr2.config.ts at sandbox.resources`), so the message
193
+ * points at the text to edit. An empty `limits` is legal and states nothing: every field resolves
194
+ * down the chain.
195
+ */
196
+ export function assertSize(where: string, value: unknown): asserts value is Size {
197
+ const hint = "a Size is { limits: { memory?, cpu? } } and nothing else — Agent Substrate's template size (ADR-0060)";
198
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
199
+ throw new Error(`${where}: expected an object (got ${JSON.stringify(value)}) — ${hint}`);
200
+ }
201
+ for (const key of Object.keys(value)) {
202
+ if (key === "limits") continue;
203
+ const why =
204
+ key === "requests"
205
+ ? "is not accepted — jr2 reserves the whole Size, so every request equals its limit"
206
+ : "is not accepted";
207
+ throw new Error(`${where}.${key} ${why} — ${hint}`);
208
+ }
209
+ const limits = (value as { limits?: unknown }).limits;
210
+ if (typeof limits !== "object" || limits === null || Array.isArray(limits)) {
211
+ throw new Error(`${where}.limits: expected an object (got ${JSON.stringify(limits)}) — ${hint}`);
212
+ }
213
+ for (const [key, q] of Object.entries(limits)) {
214
+ if (key !== "memory" && key !== "cpu") {
215
+ throw new Error(`${where}.limits.${key} is not accepted — ${hint}`);
216
+ }
217
+ if (q === undefined) continue;
218
+ if (baseUnits(q) === undefined) {
219
+ const eg = key === "memory" ? '"2Gi", "512Mi"' : '"2", "1500m"';
220
+ throw new Error(`${where}.limits.${key}: ${JSON.stringify(q)} is not a positive quantity (e.g. ${eg})`);
221
+ }
222
+ }
223
+ }
224
+
225
+ /** Layer one Size over another, field by field — `customize(machine, { resources })` over the
226
+ * `workspace()`'s own, as an Agent override layers over its stock definition. */
227
+ export function layerSize(base: Size | undefined, over: Size | undefined): Size | undefined {
228
+ if (over === undefined) return base;
229
+ if (base === undefined) return over;
230
+ return { limits: { ...base.limits, ...over.limits } };
231
+ }
232
+
233
+ // --- resolution --------------------------------------------------------------------------------
234
+
235
+ /** Where a resolved number came from — what a refusal names, because the fix is an edit THERE. */
236
+ export type SizeSource = "machine" | "instance" | "kit";
237
+
238
+ /** A Size resolved to numbers, each with its source. */
239
+ export type ResolvedSize = {
240
+ memory: { bytes: number; from: SizeSource };
241
+ cpu: { millis: number; from: SizeSource };
242
+ };
243
+
244
+ /**
245
+ * The chain (ADR-0060): the Machine's Size (its `workspace()`, with any `customize()` already
246
+ * layered over it), else the Instance's `sandbox.resources`, then the kit for a field neither
247
+ * states. The Instance default applies only to a Workspace that states NO Size — never field by
248
+ * field under a stated one — so a Machine's Size resolves the same on every Instance, and each
249
+ * fact has one override path, `customize()`.
250
+ */
251
+ export function resolveSize(machine: Size | undefined, instance: Size | undefined): ResolvedSize {
252
+ const stated = (size: Size | undefined) =>
253
+ size !== undefined && (size.limits.memory !== undefined || size.limits.cpu !== undefined);
254
+ const [chosen, source]: [Size | undefined, SizeSource] = stated(machine)
255
+ ? [machine, "machine"]
256
+ : [instance, "instance"];
257
+ const pick = (field: "memory" | "cpu"): { q: Quantity; from: SizeSource } => {
258
+ const q = chosen?.limits[field];
259
+ return q !== undefined ? { q, from: source } : { q: KIT_SIZE[field], from: "kit" };
260
+ };
261
+ const memory = pick("memory");
262
+ const cpu = pick("cpu");
263
+ return {
264
+ memory: { bytes: memoryBytes(memory.q), from: memory.from },
265
+ cpu: { millis: cpuMillis(cpu.q), from: cpu.from },
266
+ };
267
+ }
268
+
269
+ // --- the split inside the pod ------------------------------------------------------------------
270
+
271
+ /**
272
+ * One Sandbox pod's resources, as the CR carries them (ADR-0060). Every request equals its limit —
273
+ * the pod is Guaranteed, and the CPU limit is enforced — except the Harness container's
274
+ * `ephemeral-storage`, a request with no limit, and the preflight's small fixed step.
275
+ */
276
+ export type SizeSplit = {
277
+ /** The whole Size, pod-level (KEP-2837): the ceiling the pod never passes. */
278
+ pod: ResourceRequirements;
279
+ /** The rest of the Size, after the Custodian and the User Container. */
280
+ harness: ResourceRequirements;
281
+ custodian: ResourceRequirements;
282
+ /** Only when the Machine states a split for the User Container; otherwise it has no limit of its
283
+ * own and shares the pod's budget. */
284
+ user?: ResourceRequirements;
285
+ preflight: ResourceRequirements;
286
+ /** The `/dev/shm` emptyDir's `sizeLimit`: 25% of the Harness share, minimum 64Mi. */
287
+ shmSizeLimit: string;
288
+ /** The Harness container's memory limit in bytes — what layer 4 of ADR-0061 guards. */
289
+ harnessMemoryBytes: number;
290
+ };
291
+
292
+ const SOURCE_TEXT: Record<SizeSource, (field: "memory" | "cpu") => string> = {
293
+ machine: (f) => `the Machine's Size — its workspace()'s resources.limits.${f}, or a customize() of it`,
294
+ instance: (f) =>
295
+ `sandbox.resources.limits.${f} in jr2.config.ts, the Instance's default for a Workspace that states none`,
296
+ kit: () => "the kit default — the Workspace states no Size",
297
+ };
298
+
299
+ /**
300
+ * Split a resolved Size inside the pod (ADR-0060), or refuse. `user` is the User Container's split
301
+ * as the Machine stated it. The refusal names each number and where it came from, because the fix
302
+ * is one of them: a larger Size, or a smaller split.
303
+ */
304
+ export function splitSize(size: ResolvedSize, user?: Size): SizeSplit {
305
+ const custodian = { memory: memoryBytes(CUSTODIAN_SLICE.memory), cpu: cpuMillis(CUSTODIAN_SLICE.cpu) };
306
+ const userMemory = user?.limits.memory !== undefined ? memoryBytes(user.limits.memory) : undefined;
307
+ const userCpu = user?.limits.cpu !== undefined ? cpuMillis(user.limits.cpu) : undefined;
308
+ const harnessMemory = size.memory.bytes - custodian.memory - (userMemory ?? 0);
309
+ const harnessCpu = size.cpu.millis - custodian.cpu - (userCpu ?? 0);
310
+
311
+ const floor = memoryBytes(HARNESS_FLOOR);
312
+ if (harnessMemory < floor) {
313
+ throw new Error(
314
+ `the Size leaves the Harness container ${harnessMemory > 0 ? formatMemory(harnessMemory) : "no"} memory, below its ` +
315
+ `${HARNESS_FLOOR} floor: memory ${formatMemory(size.memory.bytes)} (${SOURCE_TEXT[size.memory.from]("memory")}) ` +
316
+ `− the Custodian's ${CUSTODIAN_SLICE.memory}` +
317
+ (userMemory !== undefined
318
+ ? ` − the User Container's ${formatMemory(userMemory)} (its user.resources.limits.memory)`
319
+ : "") +
320
+ " — raise the Size's limits.memory, or shrink the User Container's split (ADR-0060)",
321
+ );
322
+ }
323
+ if (harnessCpu <= 0) {
324
+ throw new Error(
325
+ `the Size leaves the Harness container no cpu: cpu ${formatCpu(size.cpu.millis)} ` +
326
+ `(${SOURCE_TEXT[size.cpu.from]("cpu")}) − the Custodian's ${CUSTODIAN_SLICE.cpu}` +
327
+ (userCpu !== undefined ? ` − the User Container's ${formatCpu(userCpu)} (its user.resources.limits.cpu)` : "") +
328
+ " — raise the Size's limits.cpu, or shrink the User Container's split (ADR-0060)",
329
+ );
330
+ }
331
+
332
+ const exact = (memory: number, cpu: number): ResourceRequirements => {
333
+ const limits = { cpu: formatCpu(cpu), memory: formatMemory(memory) };
334
+ return { requests: { ...limits }, limits };
335
+ };
336
+ const harness = exact(harnessMemory, harnessCpu);
337
+ harness.requests!["ephemeral-storage"] = WORK_DISK_REQUEST;
338
+
339
+ let userResources: ResourceRequirements | undefined;
340
+ if (userMemory !== undefined || userCpu !== undefined) {
341
+ const limits: Record<string, string> = {
342
+ ...(userCpu !== undefined ? { cpu: formatCpu(userCpu) } : {}),
343
+ ...(userMemory !== undefined ? { memory: formatMemory(userMemory) } : {}),
344
+ };
345
+ userResources = { requests: { ...limits }, limits };
346
+ }
347
+
348
+ return {
349
+ pod: exact(size.memory.bytes, size.cpu.millis),
350
+ harness,
351
+ custodian: exact(custodian.memory, custodian.cpu),
352
+ ...(userResources ? { user: userResources } : {}),
353
+ preflight: {
354
+ requests: { ...PREFLIGHT_RESOURCES.requests },
355
+ limits: { ...PREFLIGHT_RESOURCES.limits },
356
+ },
357
+ shmSizeLimit: formatMemory(Math.max(memoryBytes(SHM_FLOOR), Math.floor(harnessMemory / 4))),
358
+ harnessMemoryBytes: harnessMemory,
359
+ };
360
+ }
361
+
362
+ // --- the User Container seat -------------------------------------------------------------------
363
+
364
+ // The User Container as a Machine states it (ADR-0005, ADR-0060): an image in ADR-0037's two
365
+ // shapes, and optionally its split of the Size. The object form exists for the split alone.
366
+ /**
367
+ * The User Container in its object form: its image, and its part of the Workspace's Size. The User
368
+ * Container is an optional container of your own in the Sandbox pod. It runs its own entrypoint
369
+ * with `/work` mounted, and jr2 never builds into it, probes it or sends it commands. Use it for a
370
+ * service that must run without an Agent, such as an sshd.
371
+ *
372
+ * Write it in the `user` option of `workspace()` or of `customize()`. That option also takes the
373
+ * image as a string; use this object form only to give the User Container a part of the Size. A
374
+ * `customize()` that names `user` replaces the whole User Container, its part of the Size
375
+ * included.
376
+ *
377
+ * It refuses, where you write it. `<where>` is `workspace()` or `customize()`:
378
+ *
379
+ * - an image that is not a non-empty string. The message starts with
380
+ * `` <where>: `user` must be a non-empty string (an image), or { image, resources? } ``.
381
+ * - `<where>: user.<key> is not accepted — the User Container is { image, resources? } (ADR-0005,
382
+ * ADR-0060)`
383
+ *
384
+ * `resources` is checked as a {@link Size}, with the same refusals.
385
+ *
386
+ * `jr2 up` refuses a part that leaves the Harness container less than its floor. The message starts
387
+ * with `the Size leaves the Harness container <memory> memory, below its 256Mi floor:` or with
388
+ * `the Size leaves the Harness container no cpu:`. The text that follows names each number and
389
+ * where it came from.
390
+ *
391
+ * @example
392
+ * ```ts
393
+ * import { customize, type UserContainer } from "@jr2/orchestrator";
394
+ * import { task } from "@jr2/machines";
395
+ *
396
+ * const sshd: UserContainer = {
397
+ * image: "registry.example.com/sshd:1.0",
398
+ * resources: { limits: { memory: "256Mi", cpu: "250m" } },
399
+ * };
400
+ *
401
+ * export const machine = customize(task, {
402
+ * repos: { app: { url: "https://github.com/you/app.git" } },
403
+ * agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
404
+ * user: sshd,
405
+ * });
406
+ * ```
407
+ *
408
+ * @see docs/concepts/workspaces.md
409
+ * @see docs/guides/size-a-workspace.md
410
+ * @see docs/reference/decisions/0005-sandbox-pod-composition.md
411
+ * @see docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md
412
+ */
413
+ export type UserContainer = {
414
+ /** The image: a `file:` URL to a docker context that your module ships
415
+ * (`import.meta.resolve("./sshd")`), which `jr2 up` builds, or a registry ref. It must not be
416
+ * empty. */
417
+ image: string;
418
+ /** This container's part of the Workspace's Size, as a Size. jr2 takes it from the Harness
419
+ * container's part. A field you leave out gives this container no limit of its own for it: it
420
+ * shares the pod's budget. Default: no part. */
421
+ resources?: Size;
422
+ };
423
+
424
+ /** The `user` option in either shape, read as its two halves. */
425
+ export function userSeatOf(user: string | UserContainer | undefined): { image?: string; resources?: Size } {
426
+ if (user === undefined) return {};
427
+ if (typeof user === "string") return { image: user };
428
+ return { image: user.image, ...(user.resources !== undefined ? { resources: user.resources } : {}) };
429
+ }
430
+
431
+ /**
432
+ * Refuse a malformed `user` where it is written — a non-empty image string, or `{ image,
433
+ * resources? }` whose split is a Size. `where` names the caller (`workspace()`, `customize()`).
434
+ */
435
+ export function assertUserSeat(where: string, value: unknown): asserts value is string | UserContainer {
436
+ const image = typeof value === "object" && value !== null ? (value as { image?: unknown }).image : value;
437
+ if (typeof image !== "string" || !image) {
438
+ throw new Error(
439
+ `${where}: \`user\` must be a non-empty string (an image), or { image, resources? } (got ${JSON.stringify(value)}) — ` +
440
+ 'either a `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a ' +
441
+ "registry ref (ADR-0037, ADR-0060).",
442
+ );
443
+ }
444
+ if (typeof value !== "object" || value === null) return;
445
+ for (const key of Object.keys(value)) {
446
+ if (key !== "image" && key !== "resources") {
447
+ throw new Error(
448
+ `${where}: user.${key} is not accepted — the User Container is { image, resources? } (ADR-0005, ADR-0060)`,
449
+ );
450
+ }
451
+ }
452
+ const resources = (value as { resources?: unknown }).resources;
453
+ if (resources !== undefined) assertSize(`${where} user.resources`, resources);
454
+ }
@@ -1,7 +1,11 @@
1
- // Persistence for durable Machine snapshots (ADR-0007). A `SnapshotStore` is keyed by `runId`;
2
- // `SqliteSnapshotStore` is the default backing for a deployed Orchestrator, with `:memory:` for
3
- // tests. `markLost` records a run whose live state could not be re-hydrated (e.g. its flue
4
- // handle is gone) without deleting its history.
1
+ // Persistence for durable Machine snapshots (ADR-0007). A `SnapshotStore` is keyed by `runId`, one
2
+ // blob per run: the snapshot is a tree xstate restores from its root, and one row keeps it atomic
3
+ // (ADR-0065). `SqliteSnapshotStore` on the Instance's volume is the one implementation — no other
4
+ // store is owed — with `:memory:` for tests. A persist survives a process crash, not a power loss
5
+ // (WAL, `synchronous=NORMAL`), and the write stays synchronous so the row is on disk before the
6
+ // Machine's next side effect runs. `markLost` records a run whose live state could not be
7
+ // re-hydrated (e.g. its flue handle is gone), keeping any snapshot the row had; a non-live row
8
+ // lives a week, then `sweep` deletes it.
5
9
 
6
10
  import { DatabaseSync } from "node:sqlite";
7
11
  import type { StoredSnapshot } from "./durability.ts";
@@ -9,8 +13,24 @@ import type { StoredSnapshot } from "./durability.ts";
9
13
  export interface SnapshotStore {
10
14
  init(): Promise<void>;
11
15
  load(runId: string): Promise<StoredSnapshot | null>;
12
- /** Every persisted run, in insertion order. The host filters by `status` on restore. */
13
- list(): Promise<StoredSnapshot[]>;
16
+ /** The `live` runs, in insertion order — what `restore()` reads (ADR-0065). Terminal rows are
17
+ * filtered by the store, not the host, so a boot never parses a week of finished runs. */
18
+ live(): Promise<StoredSnapshot[]>;
19
+ /**
20
+ * The `live` runs by the Assembly hash their blob names (ADR-0069), each list in insertion order:
21
+ * what holds an Assembly. A hold is the ROW, not the process: a run this process failed to
22
+ * restore is still live here, so it still holds its Assembly. Read without parsing a snapshot,
23
+ * because `GET /assembly` and every collection ask. A live row that names no Assembly is not
24
+ * listed.
25
+ */
26
+ liveAssemblies(): Promise<Map<string, string[]>>;
27
+ /**
28
+ * Delete every non-live row last written before `olderThan`, and answer how many (ADR-0065). A
29
+ * `live` row is never swept, whatever its age: a run parked on a Gate for a month is restored.
30
+ * A swept id is gone as if it never ran — `read()` answers undefined, and its abbreviations are
31
+ * free again (ADR-0009).
32
+ */
33
+ sweep(olderThan: Date): Promise<number>;
14
34
  /**
15
35
  * Persisted run ids sharing a prefix, sorted, capped at `limit` — the store half of abbreviated
16
36
  * run ids (ADR-0009). Includes `lost` rows: the ambiguity set is the ids that EXIST, not the ones
@@ -18,16 +38,31 @@ export interface SnapshotStore {
18
38
  * silently to the live one, which makes resolution unsound.
19
39
  */
20
40
  findIdsByPrefix(prefix: string, limit: number): Promise<string[]>;
41
+ /**
42
+ * The run whose blob carries the idempotency key `effect` (`dispatchedBy.effect`, ADR-0071), in
43
+ * any status, or undefined: what answers a re-sent dispatch with the run it already started. A
44
+ * row `markLost` wrote with no save before it has no snapshot, and so no key; a swept row is gone
45
+ * with its key.
46
+ */
47
+ findByDispatch(effect: string): Promise<string | undefined>;
21
48
  save(runId: string, snapshot: unknown, status?: string): Promise<void>;
49
+ /**
50
+ * Record a run whose live state could not be re-hydrated. An upsert: a row that was saved KEEPS
51
+ * its snapshot and its idempotency key, because a lost dispatched run must keep answering its
52
+ * key — a re-send from its caller would otherwise start a second run (ADR-0071). Only a run with
53
+ * no row before gets a row with no snapshot.
54
+ */
22
55
  markLost(runId: string, reason: string): Promise<void>;
23
56
  /**
24
- * Record a run whose Machine changed shape underneath it (ADR-0030) — refused, not resumed.
57
+ * Record a run this Orchestrator can no longer read (ADR-0030, ADR-0069) — refused, not resumed:
58
+ * the Assembly its snapshot names is not on the volume, was published for another Kit version or
59
+ * does not load, or its Machine's shape does not match the snapshot's stamp.
25
60
  *
26
- * Unlike {@link markLost} this KEEPS the snapshot. A lost run has nothing left to look at, so
27
- * nulling it costs nothing; a drifted run is intact and merely unreadable by the Machine now
28
- * loaded, and it is the one a human most needs to inspect. Nulling it would also hide the run
29
- * entirely: `read()` returns undefined for a null blob, so `jr2 status <id>` would answer
30
- * `no run` — a refusal indistinguishable from a run that never existed.
61
+ * An UPDATE of a row that is there, and it KEEPS the snapshot: a drifted run is intact and only
62
+ * unreadable by this Orchestrator, and it is the one a human most needs to inspect. Nulling it
63
+ * would also hide the run entirely: `read()` returns undefined for a null blob, so
64
+ * `jr2 status <id>` would answer `no run` — a refusal indistinguishable from a run that never
65
+ * existed. It keeps the row's idempotency key too (ADR-0071).
31
66
  */
32
67
  markDrifted(runId: string, reason: string): Promise<void>;
33
68
  close(): Promise<void>;
@@ -41,6 +76,20 @@ type Row = {
41
76
  updated_at: string;
42
77
  };
43
78
 
79
+ /** The lookup `findByDispatch` runs, exported so a test can ask the planner about THIS statement,
80
+ * not a copy of it: the column is indexed, so the plan is a seek. */
81
+ export const FIND_BY_DISPATCH_SQL = `SELECT run_id FROM machine_snapshots
82
+ WHERE dispatch_effect = ? ORDER BY rowid LIMIT 1`;
83
+
84
+ /** The idempotency key a blob carries at `dispatchedBy.effect` (ADR-0071), or null. */
85
+ function dispatchEffectOf(snapshot: unknown): string | null {
86
+ if (typeof snapshot !== "object" || snapshot === null) return null;
87
+ const by = (snapshot as { dispatchedBy?: unknown }).dispatchedBy;
88
+ if (typeof by !== "object" || by === null) return null;
89
+ const effect = (by as { effect?: unknown }).effect;
90
+ return typeof effect === "string" ? effect : null;
91
+ }
92
+
44
93
  export class SqliteSnapshotStore implements SnapshotStore {
45
94
  private readonly db: DatabaseSync;
46
95
 
@@ -49,6 +98,10 @@ export class SqliteSnapshotStore implements SnapshotStore {
49
98
  }
50
99
 
51
100
  async init(): Promise<void> {
101
+ // A persist survives a process crash, not a power loss (ADR-0065): a write is durable once the
102
+ // OS holds it, and sqlite fsyncs only at a checkpoint. FULL cost 4.5 ms a persist, all fsync.
103
+ this.db.exec(`PRAGMA journal_mode = WAL`);
104
+ this.db.exec(`PRAGMA synchronous = NORMAL`);
52
105
  this.db.exec(`
53
106
  CREATE TABLE IF NOT EXISTS machine_snapshots (
54
107
  run_id TEXT PRIMARY KEY,
@@ -58,6 +111,16 @@ export class SqliteSnapshotStore implements SnapshotStore {
58
111
  updated_at TEXT
59
112
  )
60
113
  `);
114
+ // The idempotency key of a dispatched run (ADR-0071) is copied out of its blob into a column of
115
+ // its own when `save` writes the row, and the column is indexed. Not an expression index over
116
+ // `json_extract(snapshot, …)`: that runs on every row and THROWS on a blob SQLite's JSON parser
117
+ // refuses (nested about a thousand deep), so one such row would fail its own save and every
118
+ // later `init()` over the volume. A column also costs no JSON parse a save.
119
+ const columns = this.db.prepare(`PRAGMA table_info(machine_snapshots)`).all() as Array<{ name: string }>;
120
+ if (!columns.some((c) => c.name === "dispatch_effect")) {
121
+ this.db.exec(`ALTER TABLE machine_snapshots ADD COLUMN dispatch_effect TEXT`);
122
+ }
123
+ this.db.exec(`CREATE INDEX IF NOT EXISTS machine_snapshots_dispatch_effect ON machine_snapshots(dispatch_effect)`);
61
124
  }
62
125
 
63
126
  async load(runId: string): Promise<StoredSnapshot | null> {
@@ -75,9 +138,12 @@ export class SqliteSnapshotStore implements SnapshotStore {
75
138
  return stored;
76
139
  }
77
140
 
78
- async list(): Promise<StoredSnapshot[]> {
141
+ async live(): Promise<StoredSnapshot[]> {
79
142
  const rows = this.db
80
- .prepare(`SELECT run_id, status, snapshot, reason, updated_at FROM machine_snapshots ORDER BY rowid`)
143
+ .prepare(
144
+ `SELECT run_id, status, snapshot, reason, updated_at FROM machine_snapshots
145
+ WHERE status = 'live' ORDER BY rowid`,
146
+ )
81
147
  .all() as Row[];
82
148
  return rows.map((row) => {
83
149
  const stored: StoredSnapshot = {
@@ -90,13 +156,27 @@ export class SqliteSnapshotStore implements SnapshotStore {
90
156
  });
91
157
  }
92
158
 
159
+ async liveAssemblies(): Promise<Map<string, string[]>> {
160
+ const rows = this.db
161
+ .prepare(
162
+ `SELECT run_id, json_extract(snapshot, '$.assembly') AS assembly FROM machine_snapshots
163
+ WHERE status = 'live' AND json_valid(snapshot) ORDER BY rowid`,
164
+ )
165
+ .all() as Array<{ run_id: string; assembly: unknown }>;
166
+ const held = new Map<string, string[]>();
167
+ for (const { run_id, assembly } of rows) {
168
+ if (typeof assembly !== "string") continue;
169
+ held.set(assembly, [...(held.get(assembly) ?? []), run_id]);
170
+ }
171
+ return held;
172
+ }
173
+
93
174
  /**
94
175
  * A range scan rather than `LIKE`/`GLOB`. `run_id` is the BINARY-collated PRIMARY KEY, but
95
176
  * SQLite's `LIKE` is ASCII-case-insensitive by default, so the planner declines the index range
96
177
  * and falls back to a full scan. `GLOB` would seek correctly but obliges every caller to escape
97
178
  * `*?[]` first — a store shouldn't have to trust that. The `CHAR(0x10FFFF)` sentinel as the upper
98
- * bound also avoids incrementing the prefix's last character, which has edge cases. `>=`/`<`
99
- * carries to Postgres unchanged, where `LIKE 'x%'` would need `text_pattern_ops` to use an index.
179
+ * bound also avoids incrementing the prefix's last character, which has edge cases.
100
180
  */
101
181
  async findIdsByPrefix(prefix: string, limit: number): Promise<string[]> {
102
182
  const rows = this.db
@@ -109,18 +189,24 @@ export class SqliteSnapshotStore implements SnapshotStore {
109
189
  return rows.map((row) => row.run_id);
110
190
  }
111
191
 
192
+ async findByDispatch(effect: string): Promise<string | undefined> {
193
+ const row = this.db.prepare(FIND_BY_DISPATCH_SQL).get(effect) as { run_id: string } | undefined;
194
+ return row?.run_id;
195
+ }
196
+
112
197
  async save(runId: string, snapshot: unknown, status = "live"): Promise<void> {
113
198
  this.db
114
199
  .prepare(
115
- `INSERT INTO machine_snapshots (run_id, status, snapshot, reason, updated_at)
116
- VALUES (?, ?, ?, NULL, ?)
200
+ `INSERT INTO machine_snapshots (run_id, status, snapshot, reason, updated_at, dispatch_effect)
201
+ VALUES (?, ?, ?, NULL, ?, ?)
117
202
  ON CONFLICT(run_id) DO UPDATE SET
118
203
  status = excluded.status,
119
204
  snapshot = excluded.snapshot,
120
205
  reason = NULL,
121
- updated_at = excluded.updated_at`,
206
+ updated_at = excluded.updated_at,
207
+ dispatch_effect = excluded.dispatch_effect`,
122
208
  )
123
- .run(runId, status, JSON.stringify(snapshot), new Date().toISOString());
209
+ .run(runId, status, JSON.stringify(snapshot), new Date().toISOString(), dispatchEffectOf(snapshot));
124
210
  }
125
211
 
126
212
  async markLost(runId: string, reason: string): Promise<void> {
@@ -144,6 +230,15 @@ export class SqliteSnapshotStore implements SnapshotStore {
144
230
  .run(reason, new Date().toISOString(), runId);
145
231
  }
146
232
 
233
+ /** `updated_at` is an ISO-8601 UTC string, so a string comparison is a time comparison. A
234
+ * terminal row's `updated_at` is its terminal write — the age the week counts from. */
235
+ async sweep(olderThan: Date): Promise<number> {
236
+ const result = this.db
237
+ .prepare(`DELETE FROM machine_snapshots WHERE status != 'live' AND updated_at < ?`)
238
+ .run(olderThan.toISOString());
239
+ return Number(result.changes);
240
+ }
241
+
147
242
  async close(): Promise<void> {
148
243
  this.db.close();
149
244
  }