@jr2/orchestrator 0.4.1 → 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 (327) hide show
  1. package/README.md +20 -2
  2. package/console/components/app.ts +2 -0
  3. package/console/components/fleet.ts +14 -1
  4. package/console/components/machine-pane.ts +41 -2
  5. package/console/main.ts +13 -1
  6. package/console/store.ts +102 -6
  7. package/console/style.css +21 -0
  8. package/docs/agents-block.md +5 -0
  9. package/docs/concepts/agents-and-turns.md +442 -0
  10. package/docs/concepts/architecture.md +230 -0
  11. package/docs/concepts/charts.md +143 -0
  12. package/docs/concepts/composition.md +357 -0
  13. package/docs/concepts/converge.md +254 -0
  14. package/docs/concepts/durable-runs.md +189 -0
  15. package/docs/concepts/effects.md +603 -0
  16. package/docs/concepts/gates.md +208 -0
  17. package/docs/concepts/machines-and-workflows.md +289 -0
  18. package/docs/concepts/pools-and-sources.md +230 -0
  19. package/docs/concepts/secrets-and-trust.md +222 -0
  20. package/docs/concepts/the-menu.md +378 -0
  21. package/docs/concepts/workspaces.md +678 -0
  22. package/docs/get-started/a-model-decides.md +563 -0
  23. package/docs/get-started/an-agent-in-a-sandbox.md +381 -0
  24. package/docs/get-started/connect-a-model.md +248 -0
  25. package/docs/get-started/create-an-instance.md +194 -0
  26. package/docs/get-started/have-your-assistant-write-a-machine.md +922 -0
  27. package/docs/get-started/install.md +126 -0
  28. package/docs/guides/add-tools-to-the-sandbox.md +295 -0
  29. package/docs/guides/answer-a-gate.md +304 -0
  30. package/docs/guides/bind-open-parts.md +223 -0
  31. package/docs/guides/cancel-a-run.md +146 -0
  32. package/docs/guides/clean-up.md +206 -0
  33. package/docs/guides/fix-a-failed-converge.md +361 -0
  34. package/docs/guides/give-the-cluster-git-credentials.md +222 -0
  35. package/docs/guides/iterate-on-a-machine-on-kind.md +190 -0
  36. package/docs/guides/let-an-agent-write-a-chart.md +226 -0
  37. package/docs/guides/look-inside-a-sandbox.md +174 -0
  38. package/docs/guides/mirror-the-kit-images.md +176 -0
  39. package/docs/guides/move-from-kind-to-a-shared-cluster.md +302 -0
  40. package/docs/guides/open-the-console.md +91 -0
  41. package/docs/guides/publish-a-machine-as-an-npm-package.md +350 -0
  42. package/docs/guides/push-and-sign-a-branch.md +486 -0
  43. package/docs/guides/run-a-chart-by-name.md +270 -0
  44. package/docs/guides/run-a-command-or-call-a-url.md +544 -0
  45. package/docs/guides/run-one-worker-per-item.md +321 -0
  46. package/docs/guides/set-an-agents-access-and-dials.md +375 -0
  47. package/docs/guides/size-a-workspace.md +204 -0
  48. package/docs/guides/start-a-run-from-a-run.md +237 -0
  49. package/docs/guides/start-a-run.md +207 -0
  50. package/docs/guides/upgrade-the-kit-version.md +240 -0
  51. package/docs/guides/use-a-hosted-provider.md +208 -0
  52. package/docs/guides/use-a-local-model.md +313 -0
  53. package/docs/guides/watch-a-run.md +326 -0
  54. package/docs/guides/write-a-workflow.md +318 -0
  55. package/docs/index.md +44 -0
  56. package/docs/llms.txt +216 -0
  57. package/docs/reference/authoring-surface/agent.md +128 -0
  58. package/docs/reference/authoring-surface/answer.md +140 -0
  59. package/docs/reference/authoring-surface/arg.md +60 -0
  60. package/docs/reference/authoring-surface/basic.md +62 -0
  61. package/docs/reference/authoring-surface/bearer.md +53 -0
  62. package/docs/reference/authoring-surface/chartschema.md +23 -0
  63. package/docs/reference/authoring-surface/chartschemafor.md +48 -0
  64. package/docs/reference/authoring-surface/command.md +191 -0
  65. package/docs/reference/authoring-surface/customize.md +64 -0
  66. package/docs/reference/authoring-surface/defineconfig.md +41 -0
  67. package/docs/reference/authoring-surface/defineevent.md +114 -0
  68. package/docs/reference/authoring-surface/dispatch.md +126 -0
  69. package/docs/reference/authoring-surface/fail.md +83 -0
  70. package/docs/reference/authoring-surface/file.md +122 -0
  71. package/docs/reference/authoring-surface/frompick.md +277 -0
  72. package/docs/reference/authoring-surface/http.md +159 -0
  73. package/docs/reference/authoring-surface/isfailure.md +58 -0
  74. package/docs/reference/authoring-surface/jr2setup.md +237 -0
  75. package/docs/reference/authoring-surface/open.md +74 -0
  76. package/docs/reference/authoring-surface/openbody.md +86 -0
  77. package/docs/reference/authoring-surface/paramsof.md +125 -0
  78. package/docs/reference/authoring-surface/pool.md +186 -0
  79. package/docs/reference/authoring-surface/push.md +112 -0
  80. package/docs/reference/authoring-surface/secret.md +118 -0
  81. package/docs/reference/authoring-surface/sentby.md +83 -0
  82. package/docs/reference/authoring-surface/source.md +51 -0
  83. package/docs/reference/authoring-surface/text.md +62 -0
  84. package/docs/reference/authoring-surface/type-agentdeclaration.md +27 -0
  85. package/docs/reference/authoring-surface/type-agentdefinition.md +80 -0
  86. package/docs/reference/authoring-surface/type-agentlogic.md +15 -0
  87. package/docs/reference/authoring-surface/type-agentslots.md +12 -0
  88. package/docs/reference/authoring-surface/type-answeraction.md +12 -0
  89. package/docs/reference/authoring-surface/type-arg.md +19 -0
  90. package/docs/reference/authoring-surface/type-argnames.md +12 -0
  91. package/docs/reference/authoring-surface/type-binding.md +62 -0
  92. package/docs/reference/authoring-surface/type-bindingof.md +12 -0
  93. package/docs/reference/authoring-surface/type-bodyacceptsdoor.md +15 -0
  94. package/docs/reference/authoring-surface/type-bodyacceptsslots.md +17 -0
  95. package/docs/reference/authoring-surface/type-ceilings.md +41 -0
  96. package/docs/reference/authoring-surface/type-chainlink.md +19 -0
  97. package/docs/reference/authoring-surface/type-chartaction.md +46 -0
  98. package/docs/reference/authoring-surface/type-chartagent.md +28 -0
  99. package/docs/reference/authoring-surface/type-chartevent.md +20 -0
  100. package/docs/reference/authoring-surface/type-chartguard.md +20 -0
  101. package/docs/reference/authoring-surface/type-chartinvoke.md +28 -0
  102. package/docs/reference/authoring-surface/type-chartjson.md +14 -0
  103. package/docs/reference/authoring-surface/type-chartstate.md +46 -0
  104. package/docs/reference/authoring-surface/type-charttext.md +40 -0
  105. package/docs/reference/authoring-surface/type-charttransition.md +22 -0
  106. package/docs/reference/authoring-surface/type-charttransitions.md +14 -0
  107. package/docs/reference/authoring-surface/type-chartvalue.md +15 -0
  108. package/docs/reference/authoring-surface/type-childat.md +12 -0
  109. package/docs/reference/authoring-surface/type-childslots.md +12 -0
  110. package/docs/reference/authoring-surface/type-commandenv.md +15 -0
  111. package/docs/reference/authoring-surface/type-commandinput.md +19 -0
  112. package/docs/reference/authoring-surface/type-commandoptions.md +78 -0
  113. package/docs/reference/authoring-surface/type-commandoutput.md +104 -0
  114. package/docs/reference/authoring-surface/type-customize.md +109 -0
  115. package/docs/reference/authoring-surface/type-dispatchdefinition.md +39 -0
  116. package/docs/reference/authoring-surface/type-dispatchinput.md +14 -0
  117. package/docs/reference/authoring-surface/type-dispatchopenparts.md +14 -0
  118. package/docs/reference/authoring-surface/type-dispatchoutput.md +65 -0
  119. package/docs/reference/authoring-surface/type-duration.md +15 -0
  120. package/docs/reference/authoring-surface/type-effectbinding.md +93 -0
  121. package/docs/reference/authoring-surface/type-effectlogic.md +12 -0
  122. package/docs/reference/authoring-surface/type-envopenparts.md +14 -0
  123. package/docs/reference/authoring-surface/type-eventaudience.md +27 -0
  124. package/docs/reference/authoring-surface/type-eventdef.md +45 -0
  125. package/docs/reference/authoring-surface/type-eventfrom.md +16 -0
  126. package/docs/reference/authoring-surface/type-eventsemantics.md +26 -0
  127. package/docs/reference/authoring-surface/type-execoutcome.md +34 -0
  128. package/docs/reference/authoring-surface/type-failslot.md +14 -0
  129. package/docs/reference/authoring-surface/type-failure.md +19 -0
  130. package/docs/reference/authoring-surface/type-failureinput.md +20 -0
  131. package/docs/reference/authoring-surface/type-filebody.md +41 -0
  132. package/docs/reference/authoring-surface/type-frominput.md +14 -0
  133. package/docs/reference/authoring-surface/type-hostinjectedinput.md +58 -0
  134. package/docs/reference/authoring-surface/type-httpdefinition.md +101 -0
  135. package/docs/reference/authoring-surface/type-httpinput.md +12 -0
  136. package/docs/reference/authoring-surface/type-httpmethod.md +14 -0
  137. package/docs/reference/authoring-surface/type-httpopenparts.md +12 -0
  138. package/docs/reference/authoring-surface/type-httpoutput.md +89 -0
  139. package/docs/reference/authoring-surface/type-httprequestparts.md +33 -0
  140. package/docs/reference/authoring-surface/type-jr2config.md +126 -0
  141. package/docs/reference/authoring-surface/type-lends.md +38 -0
  142. package/docs/reference/authoring-surface/type-logicat.md +12 -0
  143. package/docs/reference/authoring-surface/type-markedif.md +12 -0
  144. package/docs/reference/authoring-surface/type-mergedactors.md +12 -0
  145. package/docs/reference/authoring-surface/type-namedsecret.md +25 -0
  146. package/docs/reference/authoring-surface/type-noslotspec.md +26 -0
  147. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +32 -0
  148. package/docs/reference/authoring-surface/type-openbodydeclaration.md +40 -0
  149. package/docs/reference/authoring-surface/type-openbodylogic.md +14 -0
  150. package/docs/reference/authoring-surface/type-openbodyoutput.md +42 -0
  151. package/docs/reference/authoring-surface/type-openbodyslot.md +18 -0
  152. package/docs/reference/authoring-surface/type-opencommandinput.md +17 -0
  153. package/docs/reference/authoring-surface/type-openeffect.md +12 -0
  154. package/docs/reference/authoring-surface/type-openeffectslots.md +12 -0
  155. package/docs/reference/authoring-surface/type-openof.md +12 -0
  156. package/docs/reference/authoring-surface/type-opensecret.md +23 -0
  157. package/docs/reference/authoring-surface/type-opensecretmark.md +12 -0
  158. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +31 -0
  159. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +32 -0
  160. package/docs/reference/authoring-surface/type-poolmachine.md +28 -0
  161. package/docs/reference/authoring-surface/type-poolspec.md +60 -0
  162. package/docs/reference/authoring-surface/type-pushinput.md +15 -0
  163. package/docs/reference/authoring-surface/type-pushoptions.md +79 -0
  164. package/docs/reference/authoring-surface/type-pushoutput.md +124 -0
  165. package/docs/reference/authoring-surface/type-pushwireoutput.md +12 -0
  166. package/docs/reference/authoring-surface/type-quantity.md +14 -0
  167. package/docs/reference/authoring-surface/type-reached.md +12 -0
  168. package/docs/reference/authoring-surface/type-reposlot.md +70 -0
  169. package/docs/reference/authoring-surface/type-reposlotsof.md +16 -0
  170. package/docs/reference/authoring-surface/type-sandboxoptions.md +77 -0
  171. package/docs/reference/authoring-surface/type-secret.md +25 -0
  172. package/docs/reference/authoring-surface/type-sender.md +19 -0
  173. package/docs/reference/authoring-surface/type-sentbyguards.md +12 -0
  174. package/docs/reference/authoring-surface/type-size.md +85 -0
  175. package/docs/reference/authoring-surface/type-slothandles.md +32 -0
  176. package/docs/reference/authoring-surface/type-sourcenextresult.md +17 -0
  177. package/docs/reference/authoring-surface/type-sourcespec.md +44 -0
  178. package/docs/reference/authoring-surface/type-syncstate.md +12 -0
  179. package/docs/reference/authoring-surface/type-text.md +19 -0
  180. package/docs/reference/authoring-surface/type-textpart.md +24 -0
  181. package/docs/reference/authoring-surface/type-thinkinglevel.md +39 -0
  182. package/docs/reference/authoring-surface/type-toparameterizedobject.md +14 -0
  183. package/docs/reference/authoring-surface/type-toprovidedactor.md +14 -0
  184. package/docs/reference/authoring-surface/type-unknown.md +21 -0
  185. package/docs/reference/authoring-surface/type-usercontainer.md +77 -0
  186. package/docs/reference/authoring-surface/type-withoutemptyindex.md +20 -0
  187. package/docs/reference/authoring-surface/type-workflowevent.md +46 -0
  188. package/docs/reference/authoring-surface/type-workspaceaccess.md +16 -0
  189. package/docs/reference/authoring-surface/type-workspaced.md +94 -0
  190. package/docs/reference/authoring-surface/type-workspacehandles.md +23 -0
  191. package/docs/reference/authoring-surface/type-workspacemachine.md +29 -0
  192. package/docs/reference/authoring-surface/type-workspaceof.md +13 -0
  193. package/docs/reference/authoring-surface/type-workspaceoptions.md +39 -0
  194. package/docs/reference/authoring-surface/type-workspacespec.md +37 -0
  195. package/docs/reference/authoring-surface/workspace.md +259 -0
  196. package/docs/reference/authoring-surface.md +197 -0
  197. package/docs/reference/cli.md +433 -0
  198. package/docs/reference/config.md +756 -0
  199. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +113 -0
  200. package/docs/reference/decisions/0002-duplex-streaming-actor.md +110 -0
  201. package/docs/reference/decisions/0004-worktree-provisioning-model.md +124 -0
  202. package/docs/reference/decisions/0005-sandbox-pod-composition.md +230 -0
  203. package/docs/reference/decisions/0006-agent-control-surface.md +47 -0
  204. package/docs/reference/decisions/0007-durable-machine-state.md +109 -0
  205. package/docs/reference/decisions/0008-orchestrator-as-deployed-app.md +60 -0
  206. package/docs/reference/decisions/0009-cli-and-instance-interface.md +275 -0
  207. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +160 -0
  208. package/docs/reference/decisions/0011-workflow-defined-events.md +141 -0
  209. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +103 -0
  210. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +157 -0
  211. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +95 -0
  212. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +265 -0
  213. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +124 -0
  214. package/docs/reference/decisions/0017-pool-over-a-source-port.md +69 -0
  215. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +111 -0
  216. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +275 -0
  217. package/docs/reference/decisions/0020-private-ca-trust-is-a-named-concept.md +60 -0
  218. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +100 -0
  219. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +103 -0
  220. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +176 -0
  221. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +439 -0
  222. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +65 -0
  223. package/docs/reference/decisions/0026-a-turn-that-is-over-is-never-prompted.md +62 -0
  224. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +237 -0
  225. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +89 -0
  226. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +154 -0
  227. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +100 -0
  228. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +190 -0
  229. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +61 -0
  230. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +127 -0
  231. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +57 -0
  232. package/docs/reference/decisions/0035-a-runaway-turn-is-ended-rerolled-once-then-a-fault.md +92 -0
  233. package/docs/reference/decisions/0036-a-turn-compacts-its-own-context-mid-flight.md +138 -0
  234. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +196 -0
  235. package/docs/reference/decisions/0038-jr2-up-builds-every-image-it-deploys.md +170 -0
  236. package/docs/reference/decisions/0039-image-garbage-collects-by-reachability.md +115 -0
  237. package/docs/reference/decisions/0041-a-build-the-host-already-holds-is-not-spent-again.md +64 -0
  238. package/docs/reference/decisions/0042-ready-is-not-routable-an-admission-retries-its-connection.md +142 -0
  239. package/docs/reference/decisions/0043-the-kit-is-tested-as-installed-a-local-registry-stands-in-for-npm.md +170 -0
  240. package/docs/reference/decisions/0044-kit-images-live-at-a-canonical-home-a-self-host-mirrors-it.md +85 -0
  241. package/docs/reference/decisions/0045-the-platform-joins-the-image-address-and-the-cluster-chooses-it.md +82 -0
  242. package/docs/reference/decisions/0046-a-converge-failure-carries-its-diagnosis.md +34 -0
  243. package/docs/reference/decisions/0047-the-git-ssh-key-source-is-the-users-choice.md +47 -0
  244. package/docs/reference/decisions/0048-the-orchestrator-boots-without-its-repos.md +36 -0
  245. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +126 -0
  246. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +73 -0
  247. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +227 -0
  248. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +81 -0
  249. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +111 -0
  250. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +126 -0
  251. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +107 -0
  252. package/docs/reference/decisions/0056-a-global-jr2-hands-off-to-the-instances-own-and-an-instance-has-one-kit-version.md +62 -0
  253. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +152 -0
  254. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +106 -0
  255. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +384 -0
  256. package/docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md +111 -0
  257. package/docs/reference/decisions/0061-a-memory-kill-ends-the-agents-processes-not-the-harness.md +83 -0
  258. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +212 -0
  259. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +83 -0
  260. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +79 -0
  261. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +65 -0
  262. 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
  263. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +430 -0
  264. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +464 -0
  265. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +180 -0
  266. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +323 -0
  267. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +183 -0
  268. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +144 -0
  269. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +122 -0
  270. package/docs/reference/decisions/0074-an-unrouted-mechanism-event-is-refused-at-load-and-faults-the-run.md +76 -0
  271. package/docs/reference/decisions.md +83 -0
  272. package/docs/reference/glossary.md +648 -0
  273. package/docs/reference/http-api.md +1348 -0
  274. package/docs/reference/machines.md +190 -0
  275. package/docs/reference/mechanism-events.md +72 -0
  276. package/docs/why.md +206 -0
  277. package/package.json +5 -3
  278. package/src/actor.ts +541 -79
  279. package/src/agent.ts +114 -38
  280. package/src/ambient.ts +2 -1
  281. package/src/assembly-checks.ts +189 -7
  282. package/src/cause.ts +234 -0
  283. package/src/chart.ts +169 -37
  284. package/src/charts.ts +34 -6
  285. package/src/config.ts +430 -178
  286. package/src/custodian.ts +74 -22
  287. package/src/customize.ts +188 -46
  288. package/src/dispatch-effect.ts +945 -0
  289. package/src/door-compat.ts +217 -0
  290. package/src/durability.ts +11 -4
  291. package/src/effect.ts +341 -44
  292. package/src/effector-wire.ts +160 -11
  293. package/src/fail.ts +209 -0
  294. package/src/harness-client.ts +541 -47
  295. package/src/http-effect.ts +495 -53
  296. package/src/http.ts +427 -16
  297. package/src/index.ts +60 -82
  298. package/src/instance.ts +68 -8
  299. package/src/internal.ts +84 -0
  300. package/src/machine-doc.ts +21 -3
  301. package/src/mechanism-routes.ts +168 -0
  302. package/src/model-keys.generated.ts +53 -0
  303. package/src/model-keys.ts +18 -58
  304. package/src/notices.ts +66 -13
  305. package/src/open-body.ts +159 -23
  306. package/src/open-effect.ts +81 -8
  307. package/src/open.ts +59 -6
  308. package/src/parts.ts +184 -13
  309. package/src/party.ts +68 -2
  310. package/src/pick.ts +463 -11
  311. package/src/pool.ts +247 -36
  312. package/src/registration.ts +83 -4
  313. package/src/repos.ts +2 -1
  314. package/src/run-host.ts +565 -31
  315. package/src/run-output.ts +38 -0
  316. package/src/sandbox-effect.ts +273 -38
  317. package/src/sandbox-kube.ts +3 -1
  318. package/src/secret-placement.ts +52 -17
  319. package/src/server.ts +3 -0
  320. package/src/setup.ts +239 -56
  321. package/src/size.ts +121 -9
  322. package/src/snapshot-store.ts +54 -10
  323. package/src/stub-harness.ts +44 -16
  324. package/src/timers.ts +14 -1
  325. package/src/vocabulary.ts +69 -14
  326. package/src/wire.ts +90 -16
  327. package/src/workspace.ts +697 -175
package/src/workspace.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  // `workspace(body, { input, image, user, repos, resources, spec })` (ADR-0012, ADR-0049, ADR-0051,
2
2
  // ADR-0060): the jr2-owned wrapper Machine that owns ONLY Sandbox lifecycle — provision the Sandbox
3
- // (out of the STATIC `image`/`user`/`repos`/`resources` options it carries) + attach one worktree per Repo Slot, run the
4
- // author's body Machine inside it as the named slot `body`, with `{ workspace: { repos, branch } }`
5
- // appended to its input (the mechanism-facing endpoint/sandbox are published ambiently
6
- // — ADR-0016, ambient.ts), and destroy the Sandbox when the body reaches a final state. Teardown lives INSIDE the
3
+ // (out of the STATIC `image`/`user`/`repos`/`resources` options it carries) + attach one worktree
4
+ // per Repo Slot, run the author's body Machine inside it as the named slot `body`, with
5
+ // `{ workspace: { repos, branch } }` appended to its input (the mechanism-facing endpoint/sandbox
6
+ // are published ambiently — ADR-0016, ambient.ts), and destroy the Sandbox when the body reaches a
7
+ // final state or fails (ADR-0073). Teardown lives INSIDE the
7
8
  // wrapper's own states because an xstate stop is synchronous — multi-step async cleanup must be
8
9
  // states the machine transitions through itself, which forces the thing that provisions to also
9
10
  // observe the body's completion (the ADR's load-bearing argument). There is no retain policy: a
@@ -18,7 +19,9 @@
18
19
  //
19
20
  // The lifecycle (ADR-0012, ADR-0064): `placing` writes the Sandbox and waits, with no deadline,
20
21
  // for its pod to be scheduled; `provisioning` waits for Ready under a budget that starts there;
21
- // `attaching` runs the Harness's attach; `running` runs the body; `teardown` destroys.
22
+ // `attaching` runs the Harness's attach; `running` runs the body; `teardown` destroys. A Failure from
23
+ // the body (ADR-0073) goes to `failing`, which destroys as `teardown` does and then sends the same
24
+ // Failure up; a defect has no `onError` here, so it goes up at once and its Sandbox stays as evidence.
22
25
  //
23
26
  // Restore-reconcile (ADR-0012, ADR-0021): every state from `placing` to `running` co-invokes the
24
27
  // lease, and in `running` the lease judges Continuity from the Orchestrator's watch for its
@@ -41,16 +44,20 @@ import {
41
44
  } from "xstate";
42
45
  import type { z } from "zod";
43
46
  import { registerAmbientHandles, type AmbientHandles } from "./ambient.ts";
47
+ import { failLogic, failurePayload, isFailure, type Failure, type FailureInput } from "./fail.ts";
48
+ import { refuseUnroutedLost } from "./mechanism-routes.ts";
44
49
  import {
45
50
  actorRoute,
46
51
  actorSlotPath,
47
52
  assertRepoSlot,
48
53
  attachSandboxParts,
49
54
  attachWrapperBody,
55
+ composesSandbox,
50
56
  customizeLine,
51
57
  open,
52
58
  repoSlotState,
53
59
  sandboxPartsOf,
60
+ wrapperBodyOf,
54
61
  type Binding,
55
62
  type JR2Repos,
56
63
  type JR2Wrapper,
@@ -61,7 +68,7 @@ import {
61
68
  } from "./parts.ts";
62
69
  import { actorPath, runBindingOf, type AnyActorSystem } from "./registration.ts";
63
70
  import { assertSize, assertUserSeat, userSeatOf, type Size, type UserContainer } from "./size.ts";
64
- import { attachInputSchema, inputSchemaOf, invokingMachine, type HostInjectedInput } from "./vocabulary.ts";
71
+ import { attachInputSchema, declaresRunInput, invokingMachine, type HostInjectedInput } from "./vocabulary.ts";
65
72
 
66
73
  /** Lease cadence when the backend names none. Well inside the 30m default idle timeout, so a
67
74
  * few missed renewals in a row are survivable. It bounds only how long an orphan waits to be
@@ -83,75 +90,201 @@ export function leaseDelay(interval: number, which: "first" | "next", random: nu
83
90
  return interval * (1 - LEASE_JITTER + 2 * LEASE_JITTER * random);
84
91
  }
85
92
 
86
- /** What to attach, in workspace vocabulary only (ADR-0012 boundary): the one branch the body
87
- * works on, the pod's work group, and the review sha. Derived PER RUN from the wrapper's input,
88
- * which is what keeps it out here rather than in the options — and which is exactly why the two
89
- * IMAGES and the REPOS are NOT here (ADR-0049, ADR-0051): `jr2 up` must find them by walking the
90
- * Machine, and no walk can evaluate a function of run input. They are static `workspace()`
91
- * options instead; a Repo that IS a function of run input is a per-run slot, a mapper the walk
92
- * can see the shape of even though it cannot see the url. */
93
+ // What to attach, in workspace vocabulary only (ADR-0012 boundary): the one branch the body works
94
+ // on, the pod's work group, and the review sha. Derived PER RUN from the wrapper's input, which is
95
+ // what keeps it out here rather than in the options — and which is exactly why the two IMAGES and
96
+ // the REPOS are NOT here (ADR-0049, ADR-0051): `jr2 up` must find them by walking the Machine, and
97
+ // no walk can evaluate a function of run input. They are static `workspace()` options instead; a
98
+ // Repo that IS a function of run input is a per-run slot, a mapper the walk can see the shape of
99
+ // even though it cannot see the url.
100
+ /** What the `spec` of a `workspace()` returns: the branch that the Workspace works on, and two
101
+ * optional fields. jr2 calls `spec` with the run input when the run enters the Workspace. */
93
102
  export type WorkspaceSpec = {
103
+ /** The branch that the Workspace works on: each Repo Slot gets a Worktree for it, at
104
+ * `/work/<slot>/<branch>`, with each `/` in the branch written as `-`. Required, and not
105
+ * `default`, which is the directory of the clone. Your Machine decides its name. */
94
106
  branch: string;
95
- /** The pod's work group (ADR-0005): `fsGroup`, default 2000. The two writing seats may run
96
- * different uids — each image's own `USER` decides — and POSIX would then make the other seat's
97
- * files read-only; fsGroup (group ownership) plus the default ACL the attach stamps on each
98
- * repo root (group writability, umask-proof) closes that, both inert when the uids already
99
- * match. The override exists for the image whose sessions already hold a gid of their own —
100
- * a root sshd's logins rebuild groups from `/etc/group`, so pointing the work group at one
101
- * they have costs no rebuild. Never a config key — pod composition is the spec's business. */
107
+ // The pod's work group (ADR-0005): `fsGroup`. The two writing seats may run different uids —
108
+ // each image's own `USER` decides — and POSIX would then make the other seat's files read-only;
109
+ // fsGroup (group ownership) plus the default ACL the attach stamps on each repo root (group
110
+ // writability, umask-proof) closes that, both inert when the uids already match. The override
111
+ // exists for the image whose sessions already hold a gid of their own — a root sshd's logins
112
+ // rebuild groups from `/etc/group`, so pointing the work group at one they have costs no
113
+ // rebuild. Never a config key — pod composition is the spec's business.
114
+ /** The group id that owns the files in `/work`. With it, the Sandbox Image and the User
115
+ * Container can both write those files when they run as different users. Default: 2000. Set it
116
+ * to a group id that the User Container's sessions already have, for example when a root sshd
117
+ * builds their groups from `/etc/group`. A non-negative integer. */
102
118
  workGroup?: number;
103
- /** Attach the detached review worktree at this sha (ADR-0028): `<branchDir>-review`, a sibling
104
- * of the branch worktree, forced to exactly this sha on every attach. Creation-time seat only;
105
- * the per-round refresh verb (the sha moves between review rounds) is a later, workflow-driven
106
- * change. */
119
+ // `<branchDir>-review`, a sibling of the branch worktree, forced to exactly this sha on every
120
+ // attach (ADR-0028). Creation-time seat only; the per-round refresh verb (the sha moves between
121
+ // review rounds) is a later, workflow-driven change.
122
+ /** A commit to check out in a second, detached Worktree beside each branch Worktree, for a
123
+ * reviewer: `/work/<slot>/<branch>-review`. The body gets its paths as `workspace.review`. */
107
124
  reviewSha?: string;
108
125
  };
109
126
 
127
+ // A Workspace with no Repo Slot (ADR-0051) attaches nothing, so a branch and a review sha name
128
+ // nothing: `never` refuses them where the spec is written, and `assertSpec` refuses them at run
129
+ // time for the caller the types do not see.
130
+ /** What the `spec` of a `workspace()` with no Repo Slot returns: only `workGroup`. There is no
131
+ * branch and no review Worktree, because the Workspace has no Repo Slot. */
132
+ export type NoSlotSpec = Pick<WorkspaceSpec, "workGroup"> & { branch?: never; reviewSha?: never };
133
+
134
+ // What the workspace hands the BODY (ADR-0012, ADR-0016): worktree geography only, keyed by Repo
135
+ // Slot (ADR-0051) — so a path a prompt names is right in every Instance that consumes the Machine.
136
+ // `endpoint` and `sandbox` are mechanism-internal — the Agent actor resolves them ambiently from
137
+ // the enclosing wrapper (ambient.ts), so a workflow can no longer forget to thread them (the
138
+ // baba71f incident: `sandbox` omitted, every tool call 403'd, fail-closed but silent).
139
+ //
140
+ // `TSlots` has NO default on purpose (ADR-0050): a body names the slots it reads, and
141
+ // `workspace()` holds those names to the ones the wrapper declares. A default of `string` would
142
+ // type `repos` as `Record<string, string>`, under which a misspelled slot reads as a path — the
143
+ // silent widening the slot key exists to refuse. `string` WRITTEN is a different statement: the
144
+ // body names no slot at all — it enumerates them, in the order the composer wrote — which is the
145
+ // body under a wrapper that declares its map Open (`repos: open`, ADR-0051), where the slots are
146
+ // the composer's words and no body could name them.
110
147
  /**
111
- * What the workspace hands the BODY (ADR-0012, ADR-0016): worktree geography only, keyed by Repo
112
- * Slot (ADR-0051) — so a path a prompt names is right in every Instance that consumes the Machine.
113
- * `endpoint` and `sandbox` are mechanism-internal — the Agent actor resolves them ambiently from
114
- * the enclosing wrapper (ambient.ts), so a workflow can no longer forget to thread them (the
115
- * baba71f incident: `sandbox` omitted, every tool call 403'd, fail-closed but silent).
148
+ * What the body of a `workspace()` gets as `input.workspace`: the path of each Worktree, and the
149
+ * branch. A path is correct in each Instance that registers the Machine.
116
150
  *
117
- * `TSlots` has NO default on purpose (ADR-0050): a body names the slots it reads, and
118
- * `workspace()` holds those names to the ones the wrapper declares. A default of `string` would
119
- * type `repos` as `Record<string, string>`, under which a misspelled slot reads as a path — the
120
- * silent widening the slot key exists to refuse. `string` WRITTEN is a different statement: the
121
- * body names no slot at all — it enumerates them, in the order the composer wrote — which is the body
122
- * under a wrapper that declares its map Open (`repos: open`, ADR-0051), where the slots are the
123
- * composer's words and no body could name them.
151
+ * `TSlots` is the names of the Repo Slots that the body reads. `workspace()` refuses a body that
152
+ * names a slot that the Workspace does not have. Write `string` for a body that names no slot and
153
+ * reads the Worktrees in the order of `repos`, as under `repos: open`. Write `never` for a
154
+ * Workspace with no Repo Slot: its handles are `{ repos: {} }`, with no branch, and its Agents work
155
+ * in `/work`.
124
156
  */
125
- export type WorkspaceHandles<TSlots extends string> = {
126
- /** Every slot's branch-worktree path: `/work/<slot>/<branch>`, keyed in DECLARATION ORDER. The
127
- * kit gives no slot a privileged meaning — there is no `workdir` — because which tree an Agent
128
- * works in is a fact about that Agent's Turn, not about the Workspace: a body that names its
129
- * slots frames each Agent with its own, and a body that names none may give the order a
130
- * meaning of its own (`task`: the first is the one the coder edits). The order is the kit's
131
- * promise; the meaning is the Machine's (ADR-0051). */
157
+ // `[TSlots] extends [never]`, not `TSlots extends never`: a bare `never` would distribute to `never`.
158
+ export type WorkspaceHandles<TSlots extends string> = [TSlots] extends [never]
159
+ ? { repos: Record<never, string> }
160
+ : SlotHandles<TSlots>;
161
+
162
+ /** The handles of a Workspace that declares Repo Slots. @internal */
163
+ type SlotHandles<TSlots extends string> = {
164
+ // The kit gives no slot a privileged meaning — there is no `workdir` — because which tree an
165
+ // Agent works in is a fact about that Agent's Turn, not about the Workspace: a body that names
166
+ // its slots frames each Agent with its own, and a body that names none may give the order a
167
+ // meaning of its own (`task`: the first is the one the coder edits). The order is the kit's
168
+ // promise; the meaning is the Machine's (ADR-0051).
169
+ /** The absolute path of each Repo Slot's Worktree, `/work/<slot>/<branch>`, in the order that
170
+ * `repos` declares the slots. Give one to an Agent's Turn as its `cwd`. */
132
171
  repos: Record<TSlots, string>;
172
+ /** The branch that the Workspace works on: the `branch` of the spec. */
133
173
  branch: string;
134
- /** Detached review-worktree paths by slot (ADR-0028) — present only when the spec carried
135
- * `reviewSha`. The reviewer's seat: hand one of these as its cwd/prompt frame. */
174
+ // The review seat (ADR-0028).
175
+ /** The path of each Repo Slot's review Worktree. Present only when the spec has `reviewSha`.
176
+ * Give one to a reviewer's Turn as its `cwd`. */
136
177
  review?: Partial<Record<TSlots, string>>;
137
178
  };
138
179
 
180
+ // The composition is the whole reason the door is declared on the wrapper and not on the body
181
+ // (ADR-0033): `Workspaced<RunInput, Slot>` is what the body receives, `RunInput` is what a caller
182
+ // may send, and no caller can send `workspace` (the handles do not exist until a Sandbox is
183
+ // provisioned and attached). Naming it here keeps the body from hand-copying
184
+ // {@link WorkspaceHandles}, which drifts. The slot argument is required, never defaulted, so
185
+ // `workspace.repos.<slot>` is typed by the same keys the wrapper declares (ADR-0050, ADR-0051).
186
+ //
187
+ // The wrapper passes its input through UNTOUCHED, so a ROOT-placed wrapper's body also receives
188
+ // what the host injected beside the door — `HostInjectedInput` today (the run's `instanceId`).
189
+ // That is outside this type on purpose: it depends on where the wrapper sits, and `Workspaced` is
190
+ // the composition the WRAPPER makes.
139
191
  /**
140
- * A body's input under a Workspace: the run input the wrapper passes through, PLUS the handles it
141
- * injects. The composition is the whole reason the door is declared on the wrapper and not on the
142
- * body (ADR-0033) — `Workspaced<RunInput, Slot>` is what the body receives, `RunInput` is what a caller
143
- * may send, and no caller can send `workspace` (the handles do not exist until a Sandbox is
144
- * provisioned and attached). Naming it here keeps the body from hand-copying
145
- * {@link WorkspaceHandles}, which drifts. The second argument is the body's word for each Repo
146
- * Slot it reads (`Workspaced<RunInput, "target">`) — required, never defaulted, so
147
- * `workspace.repos.<slot>` is typed by the same keys the wrapper declares (ADR-0050, ADR-0051).
192
+ * The input of a `workspace()` body: the run input, plus `workspace`, the handles that the
193
+ * Workspace adds (`workspace.repos`, `workspace.branch`, `workspace.review`). Use it as the type of
194
+ * the body's input.
195
+ *
196
+ * The first argument is the run input: the `input` schema of the `workspace()`. The second is the
197
+ * Repo Slots that the body reads, by name (`"app"` or `"app" | "docs"`). Under `repos: open`, write
198
+ * `string`: the body cannot know the names, so it reads the slots in order. For a `workspace()`
199
+ * with no `repos`, write `never`. The second argument has no default.
200
+ *
201
+ * A body under a root `workspace()` also gets the fields of `HostInjectedInput`. Add them when the
202
+ * body reads them: `Workspaced<HostInjectedInput & RunInput, "app">`.
203
+ *
204
+ * Its fields are the fields of the run input, and `workspace`. The run input can have no fields:
205
+ * `Workspaced<z.infer<typeof door>, "app">` for the door `z.object({})` holds only `workspace`.
206
+ *
207
+ * @example
208
+ * ```ts
209
+ * import { z } from "zod";
210
+ * import { agent, defineEvent, fail, jr2Setup, workspace, type Workspaced } from "@jr2/orchestrator";
211
+ *
212
+ * const done = defineEvent({
213
+ * name: "done",
214
+ * description: "Call this when REVIEW.md is written.",
215
+ * input: z.object({}),
216
+ * });
217
+ * const runInput = z.object({ branch: z.string(), sha: z.string() });
218
+ * type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
219
+ *
220
+ * const body = jr2Setup({
221
+ * types: {} as { input: BodyInput; context: BodyInput },
222
+ * events: [done],
223
+ * actors: {
224
+ * reviewer: agent({
225
+ * model: "anthropic/claude-haiku-5-5",
226
+ * instructions: "You review one commit and write REVIEW.md.",
227
+ * workspace: "read",
228
+ * }),
229
+ * fail: fail(),
230
+ * },
231
+ * }).createMachine({
232
+ * id: "review",
233
+ * context: ({ input }) => input,
234
+ * on: { "workspace.lost": ".lost", "agent.fault": ".failed" },
235
+ * initial: "reviewing",
236
+ * states: {
237
+ * reviewing: {
238
+ * invoke: {
239
+ * src: "reviewer",
240
+ * input: ({ context }) => ({
241
+ * prompt: `Review commit ${context.sha} on ${context.workspace.branch}.`,
242
+ * cwd: context.workspace.review?.app ?? context.workspace.repos.app,
243
+ * }),
244
+ * },
245
+ * on: { done: "finished" },
246
+ * },
247
+ * finished: { type: "final" },
248
+ * failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
249
+ * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
250
+ * },
251
+ * });
252
+ *
253
+ * export const machine = workspace(body, {
254
+ * input: runInput,
255
+ * repos: { app: "https://github.com/you/app.git" },
256
+ * spec: ({ input }) => ({ branch: input.branch, reviewSha: input.sha }),
257
+ * });
258
+ * ```
148
259
  *
149
- * The wrapper passes its input through UNTOUCHED, so a ROOT-placed wrapper's body also receives
150
- * what the host injected beside the door — `HostInjectedInput` today (the run's `instanceId`).
151
- * That is outside this type on purpose: it depends on where the wrapper sits, and `Workspaced` is
152
- * the composition the WRAPPER makes.
260
+ * @see docs/concepts/workspaces.md
261
+ * @see docs/guides/write-a-workflow.md
262
+ * @see docs/reference/decisions/0012-workspace-wrapper-machine.md
263
+ * @see docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md
153
264
  */
154
- export type Workspaced<TInput, TSlots extends string> = TInput & { workspace: WorkspaceHandles<TSlots> };
265
+ export type Workspaced<TInput, TSlots extends string> = WithoutEmptyIndex<TInput> & {
266
+ workspace: WorkspaceHandles<TSlots>;
267
+ };
268
+
269
+ // `z.infer` of a door with no fields, `z.object({})`, is `Record<string, never>`: an index signature
270
+ // that says each key holds `never`. Intersected with the handles, it makes each assign of a body
271
+ // context fail (`n: number` is not `never`) with a diagnostic that does not name the cause. This
272
+ // removes that index signature, and only one whose value is `never`, so the named fields stay
273
+ // (`HostInjectedInput & Record<string, never>` keeps `instanceId: string`) and an input that is a
274
+ // real map (`Record<string, string>`) is not changed. The alternative, a separate type for the empty
275
+ // door, makes the author choose between two names for one statement.
276
+ // It is not `@internal`: the Reference then shows this TSDoc, so a reader of `Workspaced` sees that
277
+ // these are the fields of the run input. As plumbing, its page showed only the declaration.
278
+ /**
279
+ * The fields of the run input `T`. A string index signature whose value is `never` is not one of
280
+ * them: `z.infer` gives `Record<string, never>` for `z.object({})`, and this type makes it `{}`. The
281
+ * named fields of `T` stay. A different `T` does not change.
282
+ */
283
+ type WithoutEmptyIndex<T> = string extends keyof T
284
+ ? [T[string & keyof T]] extends [never]
285
+ ? { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }
286
+ : T
287
+ : T;
155
288
 
156
289
  /**
157
290
  * What the watch says about a workspace (ADR-0021, ADR-0063): gone, or present — and, once the
@@ -277,8 +410,10 @@ export interface SandboxPort {
277
410
  * `OOMKilled` at or after `since`, the kill — its fault reason, starting with the fixed prefix
278
411
  * `memory limit`, and the data the next Turn's notice is made of (ADR-0062) — else undefined.
279
412
  * Asked when a Turn faults on a lost conversation; it may wait a moment for the operator's word,
280
- * which can trail the Harness's restart. A backend that cannot see the container answers
281
- * undefined, and every lost conversation stays lost — never an optional method (ADR-0021).
413
+ * which can trail the Harness's restart. Asked too before an admission into the Workspace whose
414
+ * watch shows a last end `OOMKilled`, with `since` that end: a kill while no Turn ran (ADR-0062).
415
+ * A backend that cannot see the container answers undefined, and every lost conversation stays
416
+ * lost — never an optional method (ADR-0021).
282
417
  */
283
418
  memoryFault(name: string, since: Date): Promise<MemoryKill | undefined>;
284
419
  /** Delete the Sandbox CR. Absent is success. */
@@ -353,7 +488,7 @@ type WsContext = {
353
488
  /** This invocation's stable identity (the actor id the parent invoked/spawned us under). */
354
489
  wsId: string;
355
490
  /** The resolved spec — computed once from input and persisted, like any other context data. */
356
- spec: WorkspaceSpec;
491
+ spec: AnySpec;
357
492
  /** Every slot's resolved Binding, by slot, in declaration order — assigned at provision. */
358
493
  bindings?: Record<string, ResolvedBinding>;
359
494
  endpoint?: string;
@@ -363,6 +498,9 @@ type WsContext = {
363
498
  /** Persisted in context so the registrar can re-publish them on restore. */
364
499
  handles?: MechanismHandles;
365
500
  output?: unknown;
501
+ /** The body's Failure (ADR-0073), as plain data: kept while `failing` destroys, so a restore
502
+ * there sends the same Failure up. */
503
+ failure?: FailureInput;
366
504
  };
367
505
 
368
506
  /**
@@ -383,12 +521,14 @@ type WsContext = {
383
521
  * Repo Slots it carries, because a default would widen exactly where the phantom exists to refuse
384
522
  * — `JR2Repos<string>` offers `customize()` every key, and `AnyStateMachine` as the body offers no
385
523
  * Agent at all. `PoolMachine` names its worker the same way.
524
+ *
525
+ * @internal
386
526
  */
387
527
  export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSlots extends string> = StateMachine<
388
528
  any,
389
529
  any,
390
530
  any,
391
- WrapperActors<"body", TBody, "place" | "provision" | "attach" | "registrar" | "lease" | "destroy">,
531
+ WrapperActors<"body", TBody, "place" | "provision" | "attach" | "registrar" | "lease" | "destroy" | "fail">,
392
532
  any,
393
533
  any,
394
534
  any,
@@ -435,8 +575,15 @@ export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSl
435
575
  */
436
576
  // The handles are keyed by the wrapper's DECLARED slots (ADR-0051), so a body demanding a slot the
437
577
  // wrapper never declared is refused here too; demanding fewer is fine, as with any other field.
578
+ //
579
+ // The provided side holds the raw door AND its `Workspaced` form. `Workspaced` removes the
580
+ // `Record<string, never>` that `z.infer` gives the empty door, so `Workspaced` alone no longer
581
+ // satisfies a body that writes that type itself (`z.infer<typeof door>`, alone or with the
582
+ // handles). The raw door keeps those bodies legal, and the stripped form keeps a body that
583
+ // declares `Workspaced<…>` legal. A key that the door does not carry is still refused.
584
+ /** @internal */
438
585
  type BodyAcceptsDoor<TBody extends AnyStateMachine, TDoor, TSlots extends string> =
439
- Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
586
+ TDoor & Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
440
587
  ? BodyNamesNoSlotUnderOpenMap<TBody, TSlots>
441
588
  : { "the body's declared input must accept the door plus the injected handles": Workspaced<TDoor, TSlots> };
442
589
 
@@ -467,6 +614,8 @@ type BodyNamesNoSlotUnderOpenMap<TBody extends AnyStateMachine, TSlots extends s
467
614
  * is the same one-direction assignability, on the `workspace` field alone: the handles the wrapper
468
615
  * will inject must satisfy what the body declares for them, so a body that names fewer slots, or
469
616
  * no handles at all, is fed a superset and passes, as ever.
617
+ *
618
+ * @internal
470
619
  */
471
620
  type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
472
621
  InputFrom<TBody> extends { workspace: infer THandles }
@@ -475,132 +624,426 @@ type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
475
624
  : { "the body's declared handles must accept the Repo Slots the wrapper declares": WorkspaceHandles<TSlots> }
476
625
  : unknown;
477
626
 
478
- /**
479
- * What the Sandbox is MADE OF (ADR-0037, ADR-0005) and which Repos it attaches (ADR-0051), as
480
- * STATIC options on the wrapper rather than fields of the per-run spec (ADR-0049). Static is the
481
- * whole point: `jr2 up` walks the registered Machines to find every `file:` context and build it,
482
- * every bound Repo and warm it, every open slot and refuse it (parts.ts) — and a spec is a function
483
- * of run input that no walk can evaluate. They are also never persisted — the provisioning state
484
- * re-reads them off the Machine it was invoked as, so a restore, a `provide()` and a `customize()`
485
- * all get the image and the slots the Machine carries NOW.
486
- *
487
- * Each image is one string in ADR-0037's two shapes: a `file:` URL to a docker context the
488
- * Machine's module ships (`import.meta.resolve("./image")`), or a registry ref its owner baked and
489
- * hosts.
490
- *
491
- * `TSlots` has no default, here and on the two option types below (ADR-0050): a value annotated
492
- * with a `string` slot set types `repos` as `Record<string, RepoSlot>`, under which a body naming
493
- * any slot passes the wrapper's check — the widening {@link WorkspaceHandles} refuses for the
494
- * same reason. The `workspace()` overloads infer it; an annotation names it.
495
- */
627
+ // What the Sandbox is MADE OF (ADR-0037, ADR-0005) and which Repos it attaches (ADR-0051), as
628
+ // STATIC options on the wrapper rather than fields of the per-run spec (ADR-0049). Static is the
629
+ // whole point: `jr2 up` walks the registered Machines to find every `file:` context and build it,
630
+ // every bound Repo and warm it, every open slot and refuse it (parts.ts) — and a spec is a function
631
+ // of run input that no walk can evaluate. They are also never persisted — the provisioning state
632
+ // re-reads them off the Machine it was invoked as, so a restore, a `provide()` and a `customize()`
633
+ // all get the image and the slots the Machine carries NOW.
634
+ //
635
+ // Each image is one string in ADR-0037's two shapes: a `file:` URL to a docker context the
636
+ // Machine's module ships (`import.meta.resolve("./image")`), or a registry ref its owner baked and
637
+ // hosts.
638
+ //
639
+ // `TSlots` has no default, here and on the two option types below (ADR-0050): a value annotated
640
+ // with a `string` slot set types `repos` as `Record<string, RepoSlot>`, under which a body naming
641
+ // any slot passes the wrapper's check — the widening WorkspaceHandles refuses for the
642
+ // same reason. The `workspace()` overloads infer it; an annotation names it.
643
+ /** The options of a `workspace()` that say what its Sandbox is made of and which Repos it
644
+ * clones. They are the same for each run. */
496
645
  export type SandboxOptions<TSlots extends string, TInput = unknown> = {
497
- /** The Sandbox Image. Absent → the Instance's `images/default`, then the stock Harness. */
646
+ /** The Sandbox Image: the image of the container where the Agent's Working tools and
647
+ * `command` Effects run. A `file:` URL to a docker context that your module ships
648
+ * (`import.meta.resolve("./image")`), which `jr2 up` builds, or a registry ref. Its floor is
649
+ * glibc and git; jr2 adds its own runtime at `/opt/jr2`. Default: the Instance's
650
+ * `images/default`, then jr2's Harness image. */
498
651
  image?: string;
499
- /** The User Container (ADR-0005): its image, or `{ image, resources }` when the Machine states
500
- * the seat's split of the Size (ADR-0060) — the widening ADR-0005 foresaw. Absent → the pod has
501
- * no third container: there is no default, because the seat's whole identity is "what jr2 does
502
- * not own" and jr2 has nothing to put there. Env and ports are deliberately not forwarded. With
503
- * no split stated, the seat has no limit of its own and shares the pod's budget. */
652
+ // The widening ADR-0005 foresaw: `{ image, resources }` states the seat's split of the Size
653
+ // (ADR-0060). There is no default, because the seat's whole identity is "what jr2 does not own"
654
+ // and jr2 has nothing to put there. Env and ports are deliberately not forwarded.
655
+ /** The User Container: a third container in the Sandbox, for a service that must run beside the
656
+ * Agent, such as an sshd. Its image, in the same two forms as `image`, or `{ image, resources }`
657
+ * to give it a part of the Size. It runs its own entrypoint, with `/work` mounted. jr2 adds
658
+ * nothing to it. Default: none. Without `resources`, it has no limit of its own and shares the
659
+ * Size of the Sandbox. */
504
660
  user?: string | UserContainer;
661
+ // Agent Substrate's template size (ADR-0060), so the Workspace moves there unchanged. A fact of
662
+ // the Machine, like its image: the author knows the toolchain, the runner, the worker count.
505
663
  /**
506
- * The Size (ADR-0060): the ceiling for the whole Sandbox, `limits.memory` and `limits.cpu` and
507
- * nothing else — Agent Substrate's template size, so the Workspace moves there unchanged. A fact
508
- * of the Machine, like its image: the author knows the toolchain, the runner, the worker count.
509
- * jr2 reserves all of it (every request equals its limit) and enforces the CPU limit. Absent, or
510
- * a field left out → the Instance's `sandbox.resources`, then the kit default (2Gi, 1 cpu). A
511
- * composer retunes it with `customize(machine, { resources })`.
664
+ * The Size: the memory and cpu limit of the whole Sandbox, `{ limits: { memory, cpu } }`, such as
665
+ * `{ limits: { memory: "4Gi", cpu: "2" } }`. jr2 reserves all of it on the node: each request
666
+ * equals its limit. jr2 also enforces the cpu limit. Only `limits`,
667
+ * and in it only `memory` and `cpu`, are accepted. Default: the Instance's `sandbox.resources`,
668
+ * then 2Gi and 1 cpu. Under a Size that you give, a field you leave out takes the kit's value,
669
+ * not the Instance's. The Instance that registers the Machine can change it with
670
+ * `customize(machine, { resources })`.
512
671
  */
513
672
  resources?: Size;
673
+ // The handles keep this map's order, and the kit reads nothing into it (ADR-0051). Required
674
+ // here, at least one; a Workspace with no Repo Slot leaves it out, which is the
675
+ // `NoSlotWorkspaceOptions` overload, not an empty map. `repos: open` is the shape a packaged
676
+ // Machine takes when its body enumerates its checkouts rather than naming them
677
+ // (`@jr2/machines`'s `task`, whose first slot is the one the coder edits).
514
678
  /**
515
- * The Repo Slots (ADR-0051), keyed by the Machine's own word for each — the key of the body's
516
- * `workspace.repos` handles and the directory under `/work`. The handles keep this map's order,
517
- * and the kit reads nothing into it. Required, at least one: a Workspace exists to work on a repository. Each slot is bound (a url,
518
- * or `{ url, ref? }` — the package's own), open (`open` — the consumer binds it with
519
- * `customize`), or per-run (a mapper over the door: `({ input }) => input.repo`).
679
+ * The Repo Slots: the repositories that the Sandbox clones, each under a name of your Machine's
680
+ * choice. The name is the key of `workspace.repos` in the body and the directory under `/work`.
681
+ * At least one slot. Leave `repos` out for a Workspace with no Repo Slot (see
682
+ * {@link NoSlotWorkspaceOptions}). Each value is one of:
683
+ * - a url, or `{ url, ref }`, where `ref` is the branch or commit that the Worktree starts from
684
+ * (default: the Repo's default branch);
685
+ * - `open`, for the Instance that registers the Machine to bind with `customize()`;
686
+ * - a function of the run input, `({ input }) => url` or `{ url, ref }`, when each run chooses.
520
687
  *
521
- * Or the whole map Open (`repos: open`): the Machine names no slot, the composer names every
522
- * one with `customize`, in an order the Machine may give a meaning to. The shape a packaged
523
- * Machine takes when its body enumerates its checkouts rather than naming them (`@jr2/machines`'s
524
- * `task`, whose first slot is the one the coder edits). Under it `TSlots` is `string`, and the body's handles must say so.
688
+ * Or `repos: open` for the whole map: the Machine names no slot, and the Instance that registers
689
+ * it names each one with `customize()`. The body's input is then `Workspaced<RunInput, string>`.
525
690
  */
526
691
  repos: Record<TSlots, RepoSlot<TInput>> | typeof open;
692
+ // Naming is placement (ADR-0059): a pod gets the Held secrets something in it names — this
693
+ // list, and the model keys of the Agents whose Turns run here, which their providers claim
694
+ // without being listed.
527
695
  /**
528
- * The Held secrets this Workspace's pod gets (ADR-0059): names of `jr2.config.ts`'s `secrets`
529
- * list. Its Custodian holds each one, and the Harness container sees its Stand-in. Naming is
530
- * placement: a pod gets the Held secrets something in it names — this list, and the model keys
531
- * of the Agents whose Turns run here, which their providers claim without being listed.
696
+ * The Held secrets that this Sandbox can use: names from the `secrets` list in `jr2.config.ts`.
697
+ * The Sandbox's Custodian holds each value and puts it into requests to the hosts of that Held
698
+ * secret. The Agent sees only a Stand-in. You need not list the model key of an Agent that runs
699
+ * here: its provider claims it. Each name once.
532
700
  */
533
701
  secrets?: readonly string[];
702
+ // The container exists only when an Effect below this `workspace()` is placed there (ADR-0068,
703
+ // ADR-0005). It runs as its own `USER`.
534
704
  /**
535
- * The Sandbox Effector's image (ADR-0068, ADR-0005): the container that runs this Workspace's
536
- * `push`, file-body `http` and credentialed `command` Effects, with the secrets they name. Absent → the
537
- * Sandbox Image, whose floor already holds `git` (ADR-0037). Name one for the tools and the
538
- * credentials kept out of the Agent's image (`terraform`, `aws`). A `file:` context or a
539
- * registry ref, resolved like the User Container's image; it runs as its own `USER`. The
540
- * container exists only when an Effect below this `workspace()` is placed there.
705
+ * The image of the Sandbox Effector. This container runs three kinds of Effect for this
706
+ * Workspace: a `push`, an `http` Effect with a Worktree file as its body, and a `command` with an
707
+ * Effect secret in its `env`. It gets the Effect secrets that those Effects name. Give an image
708
+ * when those Effects need tools or credentials that you keep out of the Sandbox Image, such as
709
+ * `terraform`. A `file:` URL or a registry ref, as for `image`. Default: the Sandbox Image,
710
+ * which already holds `git`.
541
711
  */
712
+ // The default is the Sandbox Image, whose floor already holds `git` (ADR-0037).
542
713
  effector?: { image: string };
543
714
  };
544
715
 
545
- /**
546
- * How a Workspace with a declared door is configured (ADR-0012, ADR-0033) — the wrapper's own
547
- * run-input schema, what the pod is made of, and the mapping from what comes through the door to
548
- * workspace vocabulary.
549
- */
716
+ // How a Workspace with a declared door is configured (ADR-0012, ADR-0033) — the wrapper's own
717
+ // run-input schema, what the pod is made of, and the mapping from what comes through the door to
718
+ // workspace vocabulary.
719
+ /** The options of a `workspace()` that declares the input that starts a run. */
550
720
  export type WorkspaceOptions<TSchema extends z.ZodObject, TSlots extends string> = SandboxOptions<
551
721
  TSlots,
552
722
  z.infer<TSchema>
553
723
  > & {
554
- /** The wrapper's OWN declared run input (ADR-0033) — what a caller sends to start a run of it,
555
- * what types `spec`'s `input`, and what the body is checked against. Deliberately NOT the body's
556
- * schema: the body is fed the run input PLUS the injected `workspace` handles
557
- * ({@link Workspaced}), which no caller can send, so the body's contract is the door plus
558
- * something that does not exist yet. Symmetric with `PoolSpec.input`. */
724
+ // Deliberately NOT the body's schema (ADR-0033): the body is fed the run input PLUS the injected
725
+ // `workspace` handles ({@link Workspaced}), which no caller can send, so the body's contract is
726
+ // the door plus something that does not exist yet. Symmetric with `PoolSpec.input`.
727
+ /** The input that starts a run, as a `z.object`. jr2 checks each start against it. It also types
728
+ * the `input` of `spec` and of a per-run Repo Slot, and the body must accept it plus the
729
+ * `workspace` handles. Write it here, not on the body. */
559
730
  input: TSchema;
560
- /** Map what came through the door to the workspace-domain spec: what to attach, on what ref,
561
- * on which branch (ADR-0012's boundary — workflow configuration never enters it). */
562
- spec: (args: { input: z.infer<TSchema> }) => WorkspaceSpec;
731
+ // ADR-0012's boundary: workflow configuration never enters the spec.
732
+ /**
733
+ * A function from the run input to the Workspace's {@link WorkspaceSpec}: the `branch` it works
734
+ * on, and optionally `reviewSha` and `workGroup`. jr2 calls it when the run enters the
735
+ * Workspace.
736
+ *
737
+ * The input of `spec` also holds `instanceId`, the seed Instance ID of the run (see
738
+ * {@link HostInjectedInput}). Use it to give each run a branch of its own:
739
+ * ``spec: ({ input }) => ({ branch: `fix/${input.instanceId}` })``. Only a root `workspace()` gets
740
+ * it from jr2. A `workspace()` that a different Machine invokes gets only the input that its
741
+ * parent gives it, so that parent must give `instanceId` when `spec` reads it.
742
+ */
743
+ // The type says `instanceId` is present also below the root, where only the parent can supply it:
744
+ // the same permissive answer the body guard gives (BodyAcceptsDoor), because no type can see where
745
+ // the wrapper sits. The alternative, `Partial<HostInjectedInput>`, makes each root `spec` that reads
746
+ // it write a fallback for a value that is always there.
747
+ spec: (args: { input: WithoutEmptyIndex<z.infer<TSchema>> & HostInjectedInput }) => WorkspaceSpec;
563
748
  };
564
749
 
750
+ // How a Workspace with NO declared door is configured: absence is permissive (ADR-0033), so jr2
751
+ // has nothing to infer from and says `unknown` rather than `any` — an honest "jr2 does not know",
752
+ // which the mapper must narrow before it reads a field.
565
753
  /**
566
- * How a Workspace with NO declared door is configured: absence is permissive (ADR-0033), so jr2
567
- * has nothing to infer from and says `unknown` rather than `any` — an honest "jr2 does not know",
568
- * which the mapper must narrow before it reads a field. A wrapper that is fed by something other
569
- * than a caller — a pool worker, a nested invoke — may state what it is fed by annotating the
570
- * parameter (`spec: ({ input }: { input: Item }) => …`), which types the wrapper's input too. For
571
- * anything a caller starts, the honest fix is to declare `input`.
754
+ * The options of a `workspace()` with no `input`: it starts with any input. The `input` of `spec`
755
+ * is then `unknown`, and `spec` must check it before it reads a field.
756
+ *
757
+ * Use it for a Workspace that a Pool or another Machine starts. Give the parameter of `spec` a
758
+ * type to say what that input is: `spec: ({ input }: { input: Item }) => …`. That also types the
759
+ * input of the Workspace. For a Workspace that a caller starts, declare `input`.
572
760
  */
573
761
  export type PermissiveWorkspaceOptions<TSlots extends string, TInput = unknown> = SandboxOptions<TSlots, TInput> & {
574
- /** Never present on this path. Spelled out so a declared schema can never fall through to the
575
- * permissive overload, where the body's door would go unchecked. */
762
+ // Spelled out so a declared schema can never fall through to the permissive overload, where the
763
+ // body's door would go unchecked.
764
+ /** Not present: a `workspace()` without `input` starts with any input. */
576
765
  input?: never;
766
+ /** A function from the input to the Workspace's {@link WorkspaceSpec}. Its `input` is
767
+ * `unknown` unless you give the parameter a type: `spec: ({ input }: { input: Item }) => …`. */
577
768
  spec: (args: { input: TInput }) => WorkspaceSpec;
578
769
  };
579
770
 
771
+ // A Workspace with no Repo Slot (ADR-0051): a Sandbox for one unit of work whose Agents and
772
+ // Effects share `/work`, and no Worktree is in it — the steps of one job. Its own option types, not a
773
+ // conditional on `TSlots`, because the slot overloads infer `TSlots` from `repos`, and no
774
+ // inference reaches through a conditional type.
775
+ /** The options of a `workspace()` with no Repo Slot that declares the input that starts a run.
776
+ * `repos` is left out, and `spec` is optional. */
777
+ export type NoSlotWorkspaceOptions<TSchema extends z.ZodObject> = Omit<SandboxOptions<never>, "repos"> & {
778
+ /** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
779
+ repos?: never;
780
+ /** The input that starts a run, as a `z.object`, as in {@link WorkspaceOptions}. */
781
+ input: TSchema;
782
+ /** A function from the run input to the Workspace's {@link NoSlotSpec}: only `workGroup`.
783
+ * Default: no `workGroup`. */
784
+ spec?: (args: { input: WithoutEmptyIndex<z.infer<TSchema>> & HostInjectedInput }) => NoSlotSpec;
785
+ };
786
+
787
+ /** The options of a `workspace()` with no Repo Slot and no `input`: it starts with any input. */
788
+ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOptions<never>, "repos"> & {
789
+ /** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
790
+ repos?: never;
791
+ /** Not present: a `workspace()` without `input` starts with any input. */
792
+ input?: never;
793
+ /** A function from the input to the Workspace's {@link NoSlotSpec}: only `workGroup`. Default:
794
+ * no `workGroup`. */
795
+ spec?: (args: { input: TInput }) => NoSlotSpec;
796
+ };
797
+
580
798
  /**
581
- * Wrap a body Machine in Sandbox lifecycle (ADR-0012). `spec` maps the wrapper's input to the
582
- * workspace-domain spec; the body receives the wrapper's input plus `workspace` (the handles) —
583
- * `Workspaced<TInput, TSlots>`, which is also what the declared door checks the body against. The
584
- * wrapper's output is the body's output. A body ERROR is deliberately unhandled: it faults the run
585
- * loudly (RunStatus.fault) and leaves the Sandbox to the operator's idle-timeout GC — the trail
586
- * stays inspectable, and silent cleanup would destroy the evidence.
799
+ * Run a body Machine in a Sandbox: a pod of its own, with a Worktree of each Repo Slot on one
800
+ * branch. The result is a Machine. Register it as a Workflow, or invoke it from another Machine as
801
+ * an actor slot.
802
+ *
803
+ * When a run enters it, the Workspace makes the Sandbox and waits for room on a node, with no
804
+ * deadline. It clones each Repo Slot and makes its Worktree on the `branch` that `spec` gives. Then
805
+ * it runs the body with the run input plus `workspace`, the handles (see {@link Workspaced}). The
806
+ * Agents with Working tools and the `command` Effects in the body run in that Sandbox. An Agent
807
+ * with `workspace: "none"` runs on the Instance Harness. When the body reaches a final state, the
808
+ * Workspace deletes the Sandbox, and its output is the body's output. Push the branch before
809
+ * then: jr2 deletes the Sandbox's commits with it.
810
+ *
811
+ * Leave out `repos` for a Workspace with no Repo Slot: its Agents and `command` Effects share
812
+ * `/work`, as the steps of one job share a folder, and `spec` is optional and gives only
813
+ * `workGroup`. The body's input is then `Workspaced<RunInput, never>`. An `http` Effect sends a
814
+ * file of `/work` with `file(path)`. A `push`, and a `command` with an Effect secret in its `env`,
815
+ * need a Repo Slot, so jr2 refuses them there. jr2 deletes `/work` with the Sandbox: send what
816
+ * must stay before the body ends.
817
+ *
818
+ * Give each run a different branch, so that two runs do not push to one branch. Make it from a
819
+ * field of the run input, or from `instanceId` when the run input has no field that is different
820
+ * for each run: ``spec: ({ input }) => ({ branch: `fix/${input.instanceId}` })``. The input of
821
+ * `spec` holds `instanceId` with no cast, also for the door `z.object({})`. Only a root
822
+ * `workspace()` gets `instanceId` from jr2. A `workspace()` that a different Machine invokes gets
823
+ * it only when its parent gives it.
824
+ *
825
+ * The Sandbox is Lost when its pod is evicted or its node goes. Then the body gets the event
826
+ * `workspace.lost`, and the body decides what to do. jr2 does not make the Lost Sandbox again.
827
+ *
828
+ * When the body fails (it invokes a `fail()` slot), the Workspace deletes the Sandbox, and then the
829
+ * same Failure goes up to the parent. With no parent to catch it, the run ends `failed`. When the
830
+ * body has a defect (an error that is not a Failure), the run faults: it ends `error`, and the
831
+ * Sandbox stays for you to inspect, until jr2's operator removes it after an idle timeout.
832
+ *
833
+ * Give the run input to `input`, not to the body: a body with an `input` schema of its own is
834
+ * refused. The types refuse a body whose input does not accept the run input plus the handles,
835
+ * with the message `the body's declared input must accept the door plus the injected handles`.
836
+ *
837
+ * It refuses, when you call it:
838
+ * - a body with a state that is not final, when that state and each state above it have no
839
+ * `workspace.lost` route: ``workspace(): the body "<id>" has a state "<state>" that is not final,
840
+ * and no route for "workspace.lost" is on it or on a state above — jr2 sends workspace.lost to the
841
+ * body when its Sandbox is gone, and with no route the run faults: route it on the body's root,
842
+ * as on: { "workspace.lost": "<the state that handles it>" } (ADR-0074)``
843
+ * - a body with its own `input`: ``workspace(): the body "<id>" declares its own run input, which
844
+ * nothing will ever serve or enforce — the wrapper feeds the body the run input PLUS the injected
845
+ * `workspace` handles, so the body's schema is not the door. Move it to the wrapper:
846
+ * workspace(body, { input, spec }) (ADR-0033).``
847
+ * - an empty `image`: ``workspace(): `image` must be a non-empty string (got <value>) — either a
848
+ * `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a
849
+ * registry ref (ADR-0037).``
850
+ * - an empty `repos`: ``workspace(): `repos: {}` names no Repo Slot — leave `repos` out for a
851
+ * Workspace with no Repo Slot, or name a slot: `repos: { app: "https://…" }` (ADR-0051).``
852
+ * - a `repos` that is not a map or `open`: ``workspace(): `repos` must be a map of Repo Slots —
853
+ * `repos: { app: "https://…" }` — or `open` whole, for a map the consumer names; leave it out
854
+ * for a Workspace with no Repo Slot (ADR-0051).``
855
+ * - a bad Repo Slot name or value (see {@link RepoSlot})
856
+ * - a `spec` that is not a function, or no `spec` on a Workspace with Repo Slots: ``workspace():
857
+ * `spec` must be a function from the run input to the spec — spec: ({ input }) => ({ branch }) —
858
+ * and only a Workspace with no Repo Slot may leave it out (ADR-0051).``
859
+ * - a bad `secrets` list: ``workspace(): `secrets` must be a list of distinct names from
860
+ * jr2.config.ts's `secrets` list (got <value>) — secrets: ["JIRA_TOKEN"] (ADR-0059).``
861
+ * - a bad `effector`: ``workspace(): `effector` is { image } — a `file:` URL to a docker context
862
+ * or a registry ref (got <value>) — effector: { image: import.meta.resolve("./effector") }
863
+ * (ADR-0068).``
864
+ * - a `user` that is not an image or `{ image, resources? }`: ``workspace(): `user` must be a
865
+ * non-empty string (an image), or { image, resources? } (got <value>) — either a `file:` URL to a
866
+ * docker context this module ships (`import.meta.resolve("./image")`) or a registry ref
867
+ * (ADR-0037, ADR-0060).``, or ``workspace(): user.<key> is not accepted — the User Container is {
868
+ * image, resources? } (ADR-0005, ADR-0060)`` (see {@link UserContainer})
869
+ * - a `resources` key other than `limits` or `requests`: ``workspace(): resources.<key> is not accepted — a
870
+ * Size is { limits: { memory?, cpu? } } and nothing else — Agent Substrate's template size
871
+ * (ADR-0060)``, or the same text for `resources.limits.<key>`. The other texts are in
872
+ * {@link Size}.
873
+ *
874
+ * And when a run enters it, a spec without a `branch` (or with `branch: "default"`), or with a bad
875
+ * `reviewSha` or `workGroup`: ``workspace spec invalid: <fields> — the spec derives from run input;
876
+ * does `jr2 run --input` carry every field this workflow's workspace() mapping reads?``. With no
877
+ * Repo Slot, a spec with a `branch` or a `reviewSha`: ``workspace spec invalid: <field> (the
878
+ * Workspace has no Repo Slot, so it has no branch and no review Worktree; its spec gives only
879
+ * workGroup) …``
880
+ *
881
+ * @param body - The Machine to run in the Sandbox. Its input is `Workspaced<RunInput, Slots>`.
882
+ * @param options - What the Sandbox is made of (`image`, `user`, `resources`, `effector`), the
883
+ * Repo Slots it clones (`repos`), the Held secrets it can use (`secrets`), the run input
884
+ * (`input`), and the function from the run input to the branch (`spec`).
885
+ * @returns A Machine whose input is the run input and whose output is the body's output.
886
+ *
887
+ * @example
888
+ * ```ts
889
+ * import { z } from "zod";
890
+ * import {
891
+ * command,
892
+ * fail,
893
+ * jr2Setup,
894
+ * workspace,
895
+ * type CommandOutput,
896
+ * type Workspaced,
897
+ * } from "@jr2/orchestrator";
898
+ *
899
+ * const runInput = z.object({ ticket: z.string(), repo: z.string() });
900
+ * type BodyInput = Workspaced<z.infer<typeof runInput>, "app" | "docs">;
901
+ *
902
+ * const passed = (output: CommandOutput): boolean =>
903
+ * output.outcome === "exited" && output.code === 0;
904
+ *
905
+ * const body = jr2Setup({
906
+ * types: {} as { input: BodyInput },
907
+ * actors: {
908
+ * build: command(["npm", "run", "build"], { slot: "app" }),
909
+ * fail: fail(),
910
+ * },
911
+ * }).createMachine({
912
+ * id: "build",
913
+ * on: { "workspace.lost": ".lost" },
914
+ * initial: "building",
915
+ * states: {
916
+ * building: {
917
+ * invoke: {
918
+ * src: "build",
919
+ * onDone: [
920
+ * { guard: ({ event }) => passed(event.output), target: "built" },
921
+ * { target: "broken" },
922
+ * ],
923
+ * },
924
+ * },
925
+ * built: { type: "final" },
926
+ * broken: { invoke: { src: "fail", input: { reason: "npm run build did not pass" } } },
927
+ * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
928
+ * },
929
+ * });
930
+ *
931
+ * export const machine = workspace(body, {
932
+ * input: runInput,
933
+ * image: "docker.io/library/node:24",
934
+ * resources: { limits: { memory: "4Gi", cpu: "2" } },
935
+ * repos: {
936
+ * app: ({ input }) => input.repo,
937
+ * docs: { url: "https://github.com/you/docs.git", ref: "main" },
938
+ * },
939
+ * spec: ({ input }) => ({ branch: `fix/${input.ticket}` }),
940
+ * });
941
+ * ```
942
+ *
943
+ * @example
944
+ * ```ts
945
+ * import { z } from "zod";
946
+ * import {
947
+ * command,
948
+ * fail,
949
+ * jr2Setup,
950
+ * workspace,
951
+ * type CommandOutput,
952
+ * type Workspaced,
953
+ * } from "@jr2/orchestrator";
954
+ *
955
+ * const passed = (output: CommandOutput): boolean =>
956
+ * output.outcome === "exited" && output.code === 0;
957
+ *
958
+ * // The run input has no field that is different for each run, so the branch comes from
959
+ * // `instanceId`.
960
+ * const runInput = z.object({});
961
+ *
962
+ * const body = jr2Setup({
963
+ * types: {} as { input: Workspaced<z.infer<typeof runInput>, "app"> },
964
+ * actors: { test: command(["npm", "test"], { slot: "app" }), fail: fail() },
965
+ * }).createMachine({
966
+ * id: "nightly",
967
+ * on: { "workspace.lost": ".lost" },
968
+ * initial: "testing",
969
+ * states: {
970
+ * testing: {
971
+ * invoke: {
972
+ * src: "test",
973
+ * onDone: [
974
+ * { guard: ({ event }) => passed(event.output), target: "tested" },
975
+ * { target: "red" },
976
+ * ],
977
+ * },
978
+ * },
979
+ * tested: { type: "final" },
980
+ * red: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
981
+ * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
982
+ * },
983
+ * });
984
+ *
985
+ * export const machine = workspace(body, {
986
+ * input: runInput,
987
+ * image: "docker.io/library/node:24",
988
+ * repos: { app: "https://github.com/you/app.git" },
989
+ * spec: ({ input }) => ({ branch: `fix/${input.instanceId}` }),
990
+ * });
991
+ * ```
992
+ *
993
+ * @see docs/concepts/workspaces.md
994
+ * @see docs/guides/write-a-workflow.md
995
+ * @see docs/guides/size-a-workspace.md
996
+ * @see docs/guides/add-tools-to-the-sandbox.md
997
+ * @see docs/reference/decisions/0012-workspace-wrapper-machine.md
998
+ * @see docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md
587
999
  */
588
- export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMachine, TSlots extends string>(
1000
+ // `TSlots` defaults to `never`: with `repos` left out there is nothing to infer it from, and the
1001
+ // Workspace has no Repo Slot (ADR-0051). The no-slot options are a union member, not overloads of
1002
+ // their own, so a call with slots reports its error where it always did.
1003
+ export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMachine, TSlots extends string = never>(
589
1004
  body: TBody & BodyAcceptsDoor<TBody, z.infer<TSchema>, TSlots>,
590
- options: WorkspaceOptions<TSchema, TSlots>,
591
- ): WorkspaceMachine<z.infer<TSchema>, OutputFrom<TBody>, TBody, TSlots>;
592
- export function workspace<TBody extends AnyStateMachine, TSlots extends string, TInput = unknown>(
1005
+ options: WorkspaceOptions<TSchema, TSlots> | NoSlotWorkspaceOptions<TSchema>,
1006
+ // The input is the door without the empty door's `Record<string, never>` (`{}` for `z.object({})`), so
1007
+ // a parent can give `instanceId` to a nested `spec` that reads it (ADR-0033).
1008
+ ): WorkspaceMachine<WithoutEmptyIndex<z.infer<TSchema>>, OutputFrom<TBody>, TBody, TSlots>;
1009
+ /**
1010
+ * Run a body Machine in a Sandbox, with no `input` schema: the Workspace starts with any input. Use
1011
+ * it for a Workspace that a Pool or another Machine starts. {@link workspace} tells the rest, and
1012
+ * what it refuses.
1013
+ *
1014
+ * @param body - The Machine to run in the Sandbox. Its input is the input of the Workspace plus
1015
+ * `workspace`, the handles.
1016
+ * @param options - The options of the Sandbox, and `spec`. There is no `input`.
1017
+ * @returns A Machine whose input is the run input and whose output is the body's output.
1018
+ *
1019
+ * @see docs/concepts/workspaces.md
1020
+ * @see docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md
1021
+ */
1022
+ export function workspace<TBody extends AnyStateMachine, TSlots extends string = never, TInput = unknown>(
593
1023
  body: TBody & BodyAcceptsSlots<TBody, TSlots>,
594
- options: PermissiveWorkspaceOptions<TSlots, TInput>,
1024
+ options: PermissiveWorkspaceOptions<TSlots, TInput> | PermissiveNoSlotWorkspaceOptions<TInput>,
595
1025
  ): WorkspaceMachine<TInput, OutputFrom<TBody>, TBody, TSlots>;
596
1026
  export function workspace(
597
1027
  body: AnyStateMachine,
598
- options: SandboxOptions<string, any> & { input?: z.ZodObject; spec: (args: { input: any }) => WorkspaceSpec },
1028
+ options: Omit<SandboxOptions<string, any>, "repos"> & {
1029
+ repos?: SandboxOptions<string, any>["repos"];
1030
+ input?: z.ZodObject;
1031
+ spec?: (args: { input: any }) => WorkspaceSpec | NoSlotSpec;
1032
+ },
599
1033
  ): AnyStateMachine {
1034
+ // `spec` maps the wrapper's input to the workspace-domain spec (ADR-0012); the body receives
1035
+ // the wrapper's input plus the handles — `Workspaced<TInput, TSlots>`, which is also what the
1036
+ // declared door checks the body against. A Failure of the body is caught: the wrapper destroys
1037
+ // the Sandbox and fails again with the same payload (ADR-0073). Every other body error (a defect)
1038
+ // is not caught: it faults the run (RunStatus.fault) and leaves the Sandbox to the operator's
1039
+ // idle-timeout GC, so the trail stays as evidence.
1040
+ //
600
1041
  // A body that still declares its own run input is a dead declaration under the door design: the
601
1042
  // wrapper never serves it, never validates against it, and feeds the body something it does not
602
1043
  // describe. Silence would leave an author believing a contract that nothing enforces (ADR-0033).
603
- if (inputSchemaOf(body)) {
1044
+ // A body that `setup()` made keeps its zod `input` in the config with no door attached, so the
1045
+ // check reads the config too: else the author loses the door with no message.
1046
+ if (declaresRunInput(body)) {
604
1047
  throw new Error(
605
1048
  `workspace(): the body "${body.id}" declares its own run input, which nothing will ever ` +
606
1049
  "serve or enforce — the wrapper feeds the body the run input PLUS the injected `workspace` " +
@@ -608,6 +1051,11 @@ export function workspace(
608
1051
  "workspace(body, { input, spec }) (ADR-0033).",
609
1052
  );
610
1053
  }
1054
+ // A body state that cannot route `workspace.lost` (ADR-0074): with no route, the body would show
1055
+ // healthy on the feed with no Sandbox. Here, because `workspace()` is the one place that knows
1056
+ // that a Machine is a body. A body that is the kit's own wrapper (a `workspace()`, a `pool()`) is
1057
+ // not judged: its states are jr2's, and the walk refuses a Workspace below a Workspace (ADR-0059).
1058
+ if (!composesSandbox(body) && wrapperBodyOf(body) === undefined) refuseUnroutedLost(body);
611
1059
  // Static, so checkable NOW rather than at the first provision — an empty or non-string image is
612
1060
  // the same derives-from-a-typo bug `assertSpec` catches for the spec, one build earlier.
613
1061
  const image = options.image;
@@ -623,16 +1071,22 @@ export function workspace(
623
1071
  // any key Substrate would refuse, is a dependency the move to it would drop.
624
1072
  if (options.resources !== undefined) assertSize("workspace(): resources", options.resources);
625
1073
  // The slots, checked NOW for the same reason (ADR-0051): every value is one of the three forms,
626
- // every key is a directory name, and there is at least one — a Workspace exists to work on a
627
- // repository, and a wrapper with no slot would attach nothing and hand the body no checkout.
628
- // An Open map defers all of that to the `customize` that fills it, which runs the same checks.
629
- const repos = options.repos;
1074
+ // and every key is a directory name. Left out, the Workspace has no Repo Slot and its steps share
1075
+ // `/work`; written empty, it is refused, so "no slot" has one spelling and an empty map never
1076
+ // reads as a map someone forgot to fill. An Open map defers all of that to the `customize` that
1077
+ // fills it, which still asks for at least one: an Open map says the composer names the slots.
1078
+ const repos = options.repos ?? {};
630
1079
  if (repos !== open) {
631
- if (typeof repos !== "object" || repos === null || Array.isArray(repos) || Object.keys(repos).length === 0) {
1080
+ if (typeof repos !== "object" || repos === null || Array.isArray(repos)) {
632
1081
  throw new Error(
633
- 'workspace(): `repos` must name at least one Repo Slot — `repos: { app: "https://…" }`, or ' +
634
- "`open` for a slot the consumer binds, or a mapper over the door for one the run chooses — or be " +
635
- "`open` whole, for a map the consumer names (ADR-0051).",
1082
+ 'workspace(): `repos` must be a map of Repo Slots — `repos: { app: "https://…" }` — or `open` whole, ' +
1083
+ "for a map the consumer names; leave it out for a Workspace with no Repo Slot (ADR-0051).",
1084
+ );
1085
+ }
1086
+ if (options.repos !== undefined && Object.keys(repos).length === 0) {
1087
+ throw new Error(
1088
+ "workspace(): `repos: {}` names no Repo Slot — leave `repos` out for a Workspace with no Repo Slot, " +
1089
+ 'or name a slot: `repos: { app: "https://…" }` (ADR-0051).',
636
1090
  );
637
1091
  }
638
1092
  for (const [slot, value] of Object.entries(repos)) assertRepoSlot("workspace()", slot, value);
@@ -664,7 +1118,15 @@ export function workspace(
664
1118
  `${JSON.stringify(effector)}) — effector: { image: import.meta.resolve("./effector") } (ADR-0068).`,
665
1119
  );
666
1120
  }
667
- const wrapper = buildWorkspaceMachine(body, options.spec);
1121
+ // A Workspace with slots cuts a branch, so it needs `spec`; one with none may leave it out. The
1122
+ // types say so; this says so to a caller the types do not see, by name, before any run.
1123
+ if (options.spec !== undefined ? typeof options.spec !== "function" : repos === open || Object.keys(repos).length) {
1124
+ throw new Error(
1125
+ "workspace(): `spec` must be a function from the run input to the spec — spec: ({ input }) => ({ branch }) — " +
1126
+ "and only a Workspace with no Repo Slot may leave it out (ADR-0051).",
1127
+ );
1128
+ }
1129
+ const wrapper = buildWorkspaceMachine(body, options.spec ?? (() => ({})));
668
1130
  // The wrapper is TRANSPARENT to its body (ADR-0049): `customize(machine, { agents })` on a
669
1131
  // Workspace means the Machine inside, so the composer never spells `body` and never has to know
670
1132
  // that jr2 wrapped anything.
@@ -701,16 +1163,29 @@ export function workspace(
701
1163
  * unchecked, it survives until the attach script's string ops and dies as "Cannot read properties
702
1164
  * of undefined", with a Sandbox already provisioned and nothing pointing back at the input.
703
1165
  */
704
- function assertSpec(spec: WorkspaceSpec): void {
1166
+ function assertSpec(spec: AnySpec, noSlot: boolean): void {
1167
+ if (typeof spec !== "object" || spec === null) {
1168
+ throw new Error(
1169
+ `workspace spec invalid: the spec is ${JSON.stringify(spec)}, not an object — \`spec\` returns one, ` +
1170
+ "such as { branch } (ADR-0051)",
1171
+ );
1172
+ }
705
1173
  const bad: string[] = [];
706
- if (typeof spec?.branch !== "string" || !spec.branch) bad.push(`branch (got ${JSON.stringify(spec?.branch)})`);
1174
+ // No Repo Slot, nothing attached (ADR-0051): a branch or a review sha would name nothing, and a
1175
+ // value jr2 silently ignores is the failure the refusals exist for.
1176
+ const nothing =
1177
+ "(the Workspace has no Repo Slot, so it has no branch and no review Worktree; its spec gives only workGroup)";
1178
+ if (noSlot) {
1179
+ if (spec?.branch !== undefined) bad.push(`branch ${nothing}`);
1180
+ if (spec?.reviewSha !== undefined) bad.push(`reviewSha ${nothing}`);
1181
+ } else if (typeof spec?.branch !== "string" || !spec.branch) bad.push(`branch (got ${JSON.stringify(spec?.branch)})`);
707
1182
  // The branch Worktree is a SIBLING of the pod-local clone at `<slot>/default/` (ADR-0004), so
708
1183
  // the one branch name that is not a worktree directory is `default`.
709
1184
  else if (spec.branch === "default")
710
1185
  bad.push(
711
1186
  'branch "default" (the pod-local clone\'s own directory — a branch Worktree sits beside `default/`, ADR-0004)',
712
1187
  );
713
- if (spec?.reviewSha !== undefined && (typeof spec.reviewSha !== "string" || !spec.reviewSha))
1188
+ if (!noSlot && spec?.reviewSha !== undefined && (typeof spec.reviewSha !== "string" || !spec.reviewSha))
714
1189
  bad.push(`reviewSha (got ${JSON.stringify(spec?.reviewSha)})`);
715
1190
  // A gid, so an integer — a float or a negative becomes a pod the API server rejects at
716
1191
  // admission, which surfaces as "never reached Ready" with nothing pointing back at the spec.
@@ -779,6 +1254,15 @@ function resolveBindings(
779
1254
  return bindings;
780
1255
  }
781
1256
 
1257
+ /** A spec as the wrapper holds it: a Workspace's with Repo Slots, or one with none (ADR-0051). */
1258
+ type AnySpec = WorkspaceSpec | NoSlotSpec;
1259
+
1260
+ /** Does this Workspace declare no Repo Slot (ADR-0051)? An Open map is not "none": its slots are
1261
+ * the composer's, and `customize()` names at least one. */
1262
+ function noSlot(slots: SandboxParts["repos"]): boolean {
1263
+ return slots !== open && Object.keys(slots).length === 0;
1264
+ }
1265
+
782
1266
  /** The port-facing view of the persisted bindings, in declaration order. */
783
1267
  function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot: string; url: string; ref?: string }> {
784
1268
  return Object.entries(bindings).map(([slot, b]) => ({
@@ -788,7 +1272,7 @@ function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot:
788
1272
  }));
789
1273
  }
790
1274
 
791
- function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any }) => WorkspaceSpec): AnyStateMachine {
1275
+ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any }) => AnySpec): AnyStateMachine {
792
1276
  /**
793
1277
  * `placing` (ADR-0064): write the Sandbox and wait, with no deadline, for its pod to be
794
1278
  * scheduled. jr2 cannot tell "full now" from "never fits", so a bound is the Machine's own
@@ -807,13 +1291,15 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
807
1291
  * delete rides the abort, after any write in flight has settled. A wait that fails on its own —
808
1292
  * the Sandbox Lost before its node, a write that half landed — is the same end: the delete comes
809
1293
  * before the fault, and the fault carries the reason. The host stopping the run for a restart is
810
- * not an end: the restored run re-enters `placing` and keeps its place.
1294
+ * not an end: the restored run re-enters `placing` and keeps its place. Nor is a name that a live
1295
+ * Workspace of the run holds when the delete would run: a parent that re-enters the state, or
1296
+ * catches a Failure and invokes the same Machine again, gives its new Workspace the same invoke id,
1297
+ * and so the same name, which that Workspace has placed again by then (ADR-0073).
811
1298
  */
812
1299
  const place = fromPromise<
813
1300
  { bindings: Record<string, ResolvedBinding> },
814
- { wsId: string; spec: WorkspaceSpec; runInput: unknown }
1301
+ { wsId: string; spec: AnySpec; runInput: unknown }
815
1302
  >(async ({ input, self, system, signal }) => {
816
- assertSpec(input.spec); // before the port: a bad spec must never cost a pod
817
1303
  const binding = runBindingOf(system);
818
1304
  // The images and the slots come off the WRAPPER, at invoke time, not out of context and not
819
1305
  // out of a build-time closure (ADR-0049, ADR-0051). This state re-runs on every restore, so
@@ -822,6 +1308,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
822
1308
  // content-addressed tag, which would outlive the image it names. The RESOLVED bindings are
823
1309
  // persisted, because a per-run mapper's answer is this run's fact.
824
1310
  const parts = sandboxPartsOf(invokingMachine(self));
1311
+ assertSpec(input.spec, noSlot(parts.repos)); // before the port: a bad spec must never cost a pod
825
1312
  const user = userSeatOf(parts.user);
826
1313
  const path = actorSlotPath(self._parent);
827
1314
  // Read before the port: a Workspace no walk can see placed nothing, and is refused with the slot form.
@@ -879,7 +1366,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
879
1366
  if (binding.hostStopping) return;
880
1367
  void placed
881
1368
  .catch(() => {})
882
- .then(() => port.destroy(name))
1369
+ .then(() => (binding.heldSandboxes?.().has(name) ? undefined : port.destroy(name)))
883
1370
  .catch(() => {}); // a delete that fails leaves the CR to the idle GC, as ever
884
1371
  },
885
1372
  { once: true },
@@ -910,8 +1397,11 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
910
1397
 
911
1398
  const attach = fromPromise<
912
1399
  { repos: Record<string, string>; review?: Record<string, string>; stale?: Record<string, string> },
913
- { wsId: string; spec: WorkspaceSpec; bindings: Record<string, ResolvedBinding> }
1400
+ { wsId: string; spec: AnySpec; bindings: Record<string, ResolvedBinding> }
914
1401
  >(async ({ input, system, signal }) => {
1402
+ // No Repo Slot, nothing to clone (ADR-0051): the Harness's attach would refuse an empty list,
1403
+ // and `/work` needs nothing from it — the pod's `fsGroup` already makes it the work group's.
1404
+ if (Object.keys(input.bindings).length === 0) return { repos: {} };
915
1405
  const port = sandboxOf(system);
916
1406
  const name = workspaceName(runBindingOf(system).runId, input.wsId);
917
1407
  // The attach is loss-aware (ADR-0021): a Sandbox gone or Lost under it fails the attach at
@@ -930,7 +1420,9 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
930
1420
  signal.addEventListener("abort", () => unsubscribe(), { once: true });
931
1421
  let out: Awaited<ReturnType<typeof port.attach>>;
932
1422
  try {
933
- out = await Promise.race([port.attach({ name, spec: input.spec, repos: attachedRepos(input.bindings) }), loss]);
1423
+ // A Workspace with slots holds a `WorkspaceSpec`: `place` refused one without a branch.
1424
+ const spec = input.spec as WorkspaceSpec;
1425
+ out = await Promise.race([port.attach({ name, spec, repos: attachedRepos(input.bindings) }), loss]);
934
1426
  } finally {
935
1427
  unsubscribe();
936
1428
  }
@@ -1049,11 +1541,12 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1049
1541
 
1050
1542
  // Every actor this wrapper runs is a NAMED SLOT (ADR-0049), the body first among them: a Machine
1051
1543
  // composes by invoking a declared `src`, and `body` is what `provide()`, `customize()`, Stately,
1052
- // the Console's join key and the `jr2 up` parts walk all reach it by. The mechanism's own six —
1053
- // place, provision, attach, registrar, lease, destroy — are named for the same price, and the
1054
- // Console shows what each state is doing instead of "inline".
1544
+ // the Console's join key and the `jr2 up` parts walk all reach it by. The mechanism's own seven —
1545
+ // place, provision, attach, registrar, lease, destroy, fail — are named for the same price, and the
1546
+ // Console shows what each state is doing instead of "inline". `fail` is the unbranded logic: it
1547
+ // sends the body's Failure up again, and the walk does not list it as a state that fails the run.
1055
1548
  return setup({
1056
- actors: { body, place, provision, attach, registrar, lease, destroy },
1549
+ actors: { body, place, provision, attach, registrar, lease, destroy, fail: failLogic },
1057
1550
  }).createMachine({
1058
1551
  id: "workspace",
1059
1552
  context: ({ input, self }: { input: unknown; self: { id: string } }): WsContext => ({
@@ -1141,7 +1634,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1141
1634
  ...(ctx.bindings
1142
1635
  ? { bindings: Object.fromEntries(Object.entries(ctx.bindings).map(([s, b]) => [s, b.url])) }
1143
1636
  : {}),
1144
- branch: ctx.spec.branch,
1637
+ // No branch with no Repo Slot (ADR-0051): nothing was cut.
1638
+ ...(ctx.spec.branch !== undefined ? { branch: ctx.spec.branch } : {}),
1145
1639
  ...(out.review ? { review: out.review } : {}),
1146
1640
  };
1147
1641
  },
@@ -1167,16 +1661,25 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1167
1661
  input: ({ context }: { context: WsContext }) => {
1168
1662
  const ctx = context;
1169
1663
  const { repos, branch, review } = ctx.handles!;
1170
- // Body-facing subset only (ADR-0016): endpoint/sandbox are mechanism-internal.
1171
- return {
1172
- ...ctx.runInput,
1173
- workspace: { repos, branch, ...(review ? { review } : {}) } satisfies WorkspaceHandles<string>,
1174
- };
1664
+ // Body-facing subset only (ADR-0016): endpoint/sandbox are mechanism-internal. With
1665
+ // no Repo Slot the handles are `{ repos: {} }` (WorkspaceHandles<never>).
1666
+ const handles: WorkspaceHandles<string> | WorkspaceHandles<never> =
1667
+ branch === undefined ? { repos: {} } : { repos, branch, ...(review ? { review } : {}) };
1668
+ return { ...ctx.runInput, workspace: handles };
1175
1669
  },
1176
1670
  onDone: {
1177
1671
  target: "teardown",
1178
1672
  actions: assign({ output: ({ event }) => (event as unknown as { output: unknown }).output }),
1179
1673
  },
1674
+ // A Failure deletes the Sandbox before it goes up (ADR-0073). Guarded, so a defect
1675
+ // takes no transition and goes up at once, as before: its Sandbox stays as evidence.
1676
+ onError: {
1677
+ guard: ({ event }: { event: { error?: unknown } }) => isFailure(event.error),
1678
+ target: "failing",
1679
+ actions: assign({
1680
+ failure: ({ event }) => failurePayload((event as unknown as { error: Failure }).error),
1681
+ }),
1682
+ },
1180
1683
  },
1181
1684
  leaseInvoke(true),
1182
1685
  ],
@@ -1194,6 +1697,25 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1194
1697
  },
1195
1698
  },
1196
1699
  done: { type: "final" },
1700
+ // The body failed (ADR-0073): the same delete as `teardown`, then the same Failure goes up to
1701
+ // the parent unchanged. No lease here: nothing judges a Sandbox that is being deleted.
1702
+ failing: {
1703
+ initial: "destroying",
1704
+ states: {
1705
+ destroying: {
1706
+ invoke: {
1707
+ src: "destroy",
1708
+ input: ({ context }) => ({ wsId: (context as unknown as WsContext).wsId }),
1709
+ onDone: "raising",
1710
+ // A failed delete is the operator GC's problem, and the run host's (ADR-0012 backstop).
1711
+ onError: "raising",
1712
+ },
1713
+ },
1714
+ raising: {
1715
+ invoke: { src: "fail", input: ({ context }) => (context as unknown as WsContext).failure! },
1716
+ },
1717
+ },
1718
+ },
1197
1719
  },
1198
1720
  // Machine output must be declared at the ROOT in xstate v5 (a final state's own `output`
1199
1721
  // only rides the done event); the workspace's output is the body's, verbatim (ADR-0012).