@jr2/orchestrator 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) 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 +478 -0
  10. package/docs/concepts/architecture.md +243 -0
  11. package/docs/concepts/charts.md +146 -0
  12. package/docs/concepts/composition.md +357 -0
  13. package/docs/concepts/converge.md +257 -0
  14. package/docs/concepts/durable-runs.md +189 -0
  15. package/docs/concepts/effects.md +658 -0
  16. package/docs/concepts/gates.md +208 -0
  17. package/docs/concepts/machines-and-workflows.md +298 -0
  18. package/docs/concepts/pools-and-sources.md +230 -0
  19. package/docs/concepts/secrets-and-trust.md +306 -0
  20. package/docs/concepts/the-menu.md +381 -0
  21. package/docs/concepts/workspaces.md +756 -0
  22. package/docs/get-started/a-model-decides.md +563 -0
  23. package/docs/get-started/an-agent-in-a-sandbox.md +383 -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 +993 -0
  27. package/docs/get-started/install.md +130 -0
  28. package/docs/guides/add-tools-to-the-sandbox.md +308 -0
  29. package/docs/guides/answer-a-gate.md +304 -0
  30. package/docs/guides/bind-open-parts.md +224 -0
  31. package/docs/guides/cancel-a-run.md +146 -0
  32. package/docs/guides/clean-up.md +206 -0
  33. package/docs/guides/cut-a-worktree.md +560 -0
  34. package/docs/guides/fix-a-failed-converge.md +361 -0
  35. package/docs/guides/give-the-cluster-git-credentials.md +222 -0
  36. package/docs/guides/iterate-on-a-machine-on-kind.md +190 -0
  37. package/docs/guides/let-an-agent-write-a-chart.md +230 -0
  38. package/docs/guides/look-inside-a-sandbox.md +177 -0
  39. package/docs/guides/mirror-the-kit-images.md +176 -0
  40. package/docs/guides/move-from-kind-to-a-shared-cluster.md +318 -0
  41. package/docs/guides/open-the-console.md +91 -0
  42. package/docs/guides/publish-a-machine-as-an-npm-package.md +350 -0
  43. package/docs/guides/push-and-sign-a-branch.md +532 -0
  44. package/docs/guides/run-a-chart-by-name.md +270 -0
  45. package/docs/guides/run-a-command-or-call-a-url.md +587 -0
  46. package/docs/guides/run-one-worker-per-item.md +322 -0
  47. package/docs/guides/set-an-agents-access-and-dials.md +420 -0
  48. package/docs/guides/size-a-workspace.md +217 -0
  49. package/docs/guides/start-a-run-from-a-run.md +237 -0
  50. package/docs/guides/start-a-run.md +207 -0
  51. package/docs/guides/upgrade-the-kit-version.md +240 -0
  52. package/docs/guides/use-a-hosted-provider.md +214 -0
  53. package/docs/guides/use-a-local-model.md +313 -0
  54. package/docs/guides/watch-a-run.md +349 -0
  55. package/docs/guides/write-a-workflow.md +318 -0
  56. package/docs/index.md +44 -0
  57. package/docs/llms.txt +237 -0
  58. package/docs/reference/authoring-surface/agent.md +151 -0
  59. package/docs/reference/authoring-surface/answer.md +156 -0
  60. package/docs/reference/authoring-surface/arg.md +82 -0
  61. package/docs/reference/authoring-surface/basic.md +62 -0
  62. package/docs/reference/authoring-surface/bearer.md +53 -0
  63. package/docs/reference/authoring-surface/branchname.md +15 -0
  64. package/docs/reference/authoring-surface/chartschema.md +23 -0
  65. package/docs/reference/authoring-surface/chartschemafor.md +48 -0
  66. package/docs/reference/authoring-surface/command.md +206 -0
  67. package/docs/reference/authoring-surface/customize.md +64 -0
  68. package/docs/reference/authoring-surface/defineconfig.md +41 -0
  69. package/docs/reference/authoring-surface/defineevent.md +114 -0
  70. package/docs/reference/authoring-surface/dispatch.md +126 -0
  71. package/docs/reference/authoring-surface/fail.md +96 -0
  72. package/docs/reference/authoring-surface/file.md +142 -0
  73. package/docs/reference/authoring-surface/frompick.md +277 -0
  74. package/docs/reference/authoring-surface/http.md +259 -0
  75. package/docs/reference/authoring-surface/isfailure.md +58 -0
  76. package/docs/reference/authoring-surface/isready.md +14 -0
  77. package/docs/reference/authoring-surface/jr2setup.md +237 -0
  78. package/docs/reference/authoring-surface/open.md +105 -0
  79. package/docs/reference/authoring-surface/openbody.md +86 -0
  80. package/docs/reference/authoring-surface/paramsof.md +143 -0
  81. package/docs/reference/authoring-surface/pool.md +186 -0
  82. package/docs/reference/authoring-surface/push.md +130 -0
  83. package/docs/reference/authoring-surface/readyof.md +21 -0
  84. package/docs/reference/authoring-surface/secret.md +139 -0
  85. package/docs/reference/authoring-surface/sentby.md +83 -0
  86. package/docs/reference/authoring-surface/source.md +51 -0
  87. package/docs/reference/authoring-surface/text.md +62 -0
  88. package/docs/reference/authoring-surface/type-agentdeclaration.md +27 -0
  89. package/docs/reference/authoring-surface/type-agentdefinition.md +80 -0
  90. package/docs/reference/authoring-surface/type-agentlogic.md +15 -0
  91. package/docs/reference/authoring-surface/type-agentslots.md +15 -0
  92. package/docs/reference/authoring-surface/type-answeraction.md +12 -0
  93. package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
  94. package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
  95. package/docs/reference/authoring-surface/type-arg.md +19 -0
  96. package/docs/reference/authoring-surface/type-argnames.md +12 -0
  97. package/docs/reference/authoring-surface/type-bindactors.md +14 -0
  98. package/docs/reference/authoring-surface/type-binding.md +61 -0
  99. package/docs/reference/authoring-surface/type-bindingof.md +12 -0
  100. package/docs/reference/authoring-surface/type-bodyacceptsdoor.md +15 -0
  101. package/docs/reference/authoring-surface/type-bodyacceptsslots.md +17 -0
  102. package/docs/reference/authoring-surface/type-ceilings.md +41 -0
  103. package/docs/reference/authoring-surface/type-chainlink.md +19 -0
  104. package/docs/reference/authoring-surface/type-chartaction.md +46 -0
  105. package/docs/reference/authoring-surface/type-chartagent.md +28 -0
  106. package/docs/reference/authoring-surface/type-chartevent.md +20 -0
  107. package/docs/reference/authoring-surface/type-chartguard.md +20 -0
  108. package/docs/reference/authoring-surface/type-chartinvoke.md +28 -0
  109. package/docs/reference/authoring-surface/type-chartjson.md +14 -0
  110. package/docs/reference/authoring-surface/type-chartstate.md +46 -0
  111. package/docs/reference/authoring-surface/type-charttext.md +40 -0
  112. package/docs/reference/authoring-surface/type-charttransition.md +22 -0
  113. package/docs/reference/authoring-surface/type-charttransitions.md +14 -0
  114. package/docs/reference/authoring-surface/type-chartvalue.md +15 -0
  115. package/docs/reference/authoring-surface/type-childat.md +12 -0
  116. package/docs/reference/authoring-surface/type-childslots.md +12 -0
  117. package/docs/reference/authoring-surface/type-commandenv.md +15 -0
  118. package/docs/reference/authoring-surface/type-commandinput.md +20 -0
  119. package/docs/reference/authoring-surface/type-commandoptions.md +56 -0
  120. package/docs/reference/authoring-surface/type-commandoutput.md +122 -0
  121. package/docs/reference/authoring-surface/type-customize.md +111 -0
  122. package/docs/reference/authoring-surface/type-dispatchdefinition.md +39 -0
  123. package/docs/reference/authoring-surface/type-dispatchinput.md +14 -0
  124. package/docs/reference/authoring-surface/type-dispatchopenparts.md +14 -0
  125. package/docs/reference/authoring-surface/type-dispatchoutput.md +72 -0
  126. package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
  127. package/docs/reference/authoring-surface/type-duration.md +15 -0
  128. package/docs/reference/authoring-surface/type-effectbinding.md +93 -0
  129. package/docs/reference/authoring-surface/type-effectlogic.md +12 -0
  130. package/docs/reference/authoring-surface/type-envopenparts.md +14 -0
  131. package/docs/reference/authoring-surface/type-eventaudience.md +27 -0
  132. package/docs/reference/authoring-surface/type-eventdef.md +45 -0
  133. package/docs/reference/authoring-surface/type-eventfrom.md +16 -0
  134. package/docs/reference/authoring-surface/type-eventsemantics.md +26 -0
  135. package/docs/reference/authoring-surface/type-execoutcome.md +34 -0
  136. package/docs/reference/authoring-surface/type-failslot.md +14 -0
  137. package/docs/reference/authoring-surface/type-failure.md +19 -0
  138. package/docs/reference/authoring-surface/type-failureinput.md +20 -0
  139. package/docs/reference/authoring-surface/type-filebody.md +37 -0
  140. package/docs/reference/authoring-surface/type-framepath.md +14 -0
  141. package/docs/reference/authoring-surface/type-frominput.md +14 -0
  142. package/docs/reference/authoring-surface/type-hostinjectedinput.md +56 -0
  143. package/docs/reference/authoring-surface/type-httpdefinition.md +104 -0
  144. package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
  145. package/docs/reference/authoring-surface/type-httpinput.md +12 -0
  146. package/docs/reference/authoring-surface/type-httpmethod.md +14 -0
  147. package/docs/reference/authoring-surface/type-httpopenparts.md +12 -0
  148. package/docs/reference/authoring-surface/type-httpoutput.md +95 -0
  149. package/docs/reference/authoring-surface/type-httprequestparts.md +33 -0
  150. package/docs/reference/authoring-surface/type-jr2config.md +126 -0
  151. package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
  152. package/docs/reference/authoring-surface/type-lends.md +38 -0
  153. package/docs/reference/authoring-surface/type-logicat.md +12 -0
  154. package/docs/reference/authoring-surface/type-markedif.md +12 -0
  155. package/docs/reference/authoring-surface/type-mergedactors.md +12 -0
  156. package/docs/reference/authoring-surface/type-namedsecret.md +25 -0
  157. package/docs/reference/authoring-surface/type-namestart.md +77 -0
  158. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +27 -0
  159. package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
  160. package/docs/reference/authoring-surface/type-openbodydeclaration.md +40 -0
  161. package/docs/reference/authoring-surface/type-openbodylogic.md +14 -0
  162. package/docs/reference/authoring-surface/type-openbodyoutput.md +42 -0
  163. package/docs/reference/authoring-surface/type-openbodyslot.md +18 -0
  164. package/docs/reference/authoring-surface/type-opencommandinput.md +22 -0
  165. package/docs/reference/authoring-surface/type-openeffect.md +12 -0
  166. package/docs/reference/authoring-surface/type-openeffectslots.md +12 -0
  167. package/docs/reference/authoring-surface/type-openof.md +12 -0
  168. package/docs/reference/authoring-surface/type-opensecret.md +23 -0
  169. package/docs/reference/authoring-surface/type-opensecretmark.md +12 -0
  170. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +26 -0
  171. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +27 -0
  172. package/docs/reference/authoring-surface/type-poolmachine.md +28 -0
  173. package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
  174. package/docs/reference/authoring-surface/type-poolspec.md +60 -0
  175. package/docs/reference/authoring-surface/type-pushinput.md +34 -0
  176. package/docs/reference/authoring-surface/type-pushoptions.md +79 -0
  177. package/docs/reference/authoring-surface/type-pushoutput.md +149 -0
  178. package/docs/reference/authoring-surface/type-pushwireoutput.md +12 -0
  179. package/docs/reference/authoring-surface/type-quantity.md +14 -0
  180. package/docs/reference/authoring-surface/type-reached.md +12 -0
  181. package/docs/reference/authoring-surface/type-reposlot.md +70 -0
  182. package/docs/reference/authoring-surface/type-reposlotsof.md +16 -0
  183. package/docs/reference/authoring-surface/type-sandboxoptions.md +84 -0
  184. package/docs/reference/authoring-surface/type-secret.md +25 -0
  185. package/docs/reference/authoring-surface/type-sender.md +19 -0
  186. package/docs/reference/authoring-surface/type-sentbyguards.md +12 -0
  187. package/docs/reference/authoring-surface/type-size.md +85 -0
  188. package/docs/reference/authoring-surface/type-slothandles.md +24 -0
  189. package/docs/reference/authoring-surface/type-sourcenextresult.md +17 -0
  190. package/docs/reference/authoring-surface/type-sourcespec.md +44 -0
  191. package/docs/reference/authoring-surface/type-syncstate.md +12 -0
  192. package/docs/reference/authoring-surface/type-text.md +19 -0
  193. package/docs/reference/authoring-surface/type-textpart.md +24 -0
  194. package/docs/reference/authoring-surface/type-thinkinglevel.md +39 -0
  195. package/docs/reference/authoring-surface/type-toparameterizedobject.md +14 -0
  196. package/docs/reference/authoring-surface/type-toprovidedactor.md +14 -0
  197. package/docs/reference/authoring-surface/type-unknown.md +21 -0
  198. package/docs/reference/authoring-surface/type-usercontainer.md +77 -0
  199. package/docs/reference/authoring-surface/type-withoutemptyindex.md +20 -0
  200. package/docs/reference/authoring-surface/type-workflowevent.md +46 -0
  201. package/docs/reference/authoring-surface/type-workspaceaccess.md +16 -0
  202. package/docs/reference/authoring-surface/type-workspaced.md +115 -0
  203. package/docs/reference/authoring-surface/type-workspacehandles.md +22 -0
  204. package/docs/reference/authoring-surface/type-workspacemachine.md +29 -0
  205. package/docs/reference/authoring-surface/type-workspaceof.md +13 -0
  206. package/docs/reference/authoring-surface/type-workspaceoptions.md +27 -0
  207. package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
  208. package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
  209. package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
  210. package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
  211. package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
  212. package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
  213. package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
  214. package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
  215. package/docs/reference/authoring-surface/workspace.md +287 -0
  216. package/docs/reference/authoring-surface/worktree.md +71 -0
  217. package/docs/reference/authoring-surface.md +217 -0
  218. package/docs/reference/cli.md +433 -0
  219. package/docs/reference/config.md +765 -0
  220. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +114 -0
  221. package/docs/reference/decisions/0002-duplex-streaming-actor.md +117 -0
  222. package/docs/reference/decisions/0004-worktree-provisioning-model.md +130 -0
  223. package/docs/reference/decisions/0005-sandbox-pod-composition.md +240 -0
  224. package/docs/reference/decisions/0006-agent-control-surface.md +50 -0
  225. package/docs/reference/decisions/0007-durable-machine-state.md +109 -0
  226. package/docs/reference/decisions/0008-orchestrator-as-deployed-app.md +60 -0
  227. package/docs/reference/decisions/0009-cli-and-instance-interface.md +275 -0
  228. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +160 -0
  229. package/docs/reference/decisions/0011-workflow-defined-events.md +141 -0
  230. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +103 -0
  231. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +175 -0
  232. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +95 -0
  233. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +271 -0
  234. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +126 -0
  235. package/docs/reference/decisions/0017-pool-over-a-source-port.md +71 -0
  236. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +111 -0
  237. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +287 -0
  238. package/docs/reference/decisions/0020-private-ca-trust-is-a-named-concept.md +60 -0
  239. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +101 -0
  240. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +103 -0
  241. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +175 -0
  242. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +436 -0
  243. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +65 -0
  244. package/docs/reference/decisions/0026-a-turn-that-is-over-is-never-prompted.md +62 -0
  245. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +245 -0
  246. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +90 -0
  247. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +154 -0
  248. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +100 -0
  249. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +193 -0
  250. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +61 -0
  251. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +126 -0
  252. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +57 -0
  253. package/docs/reference/decisions/0035-a-runaway-turn-is-ended-rerolled-once-then-a-fault.md +92 -0
  254. package/docs/reference/decisions/0036-a-turn-compacts-its-own-context-mid-flight.md +138 -0
  255. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +196 -0
  256. package/docs/reference/decisions/0038-jr2-up-builds-every-image-it-deploys.md +170 -0
  257. package/docs/reference/decisions/0039-image-garbage-collects-by-reachability.md +115 -0
  258. package/docs/reference/decisions/0041-a-build-the-host-already-holds-is-not-spent-again.md +64 -0
  259. package/docs/reference/decisions/0042-ready-is-not-routable-an-admission-retries-its-connection.md +142 -0
  260. package/docs/reference/decisions/0043-the-kit-is-tested-as-installed-a-local-registry-stands-in-for-npm.md +170 -0
  261. package/docs/reference/decisions/0044-kit-images-live-at-a-canonical-home-a-self-host-mirrors-it.md +85 -0
  262. package/docs/reference/decisions/0045-the-platform-joins-the-image-address-and-the-cluster-chooses-it.md +82 -0
  263. package/docs/reference/decisions/0046-a-converge-failure-carries-its-diagnosis.md +34 -0
  264. package/docs/reference/decisions/0047-the-git-ssh-key-source-is-the-users-choice.md +47 -0
  265. package/docs/reference/decisions/0048-the-orchestrator-boots-without-its-repos.md +36 -0
  266. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +126 -0
  267. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +73 -0
  268. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +238 -0
  269. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +86 -0
  270. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +123 -0
  271. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +126 -0
  272. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +107 -0
  273. package/docs/reference/decisions/0056-a-global-jr2-hands-off-to-the-instances-own-and-an-instance-has-one-kit-version.md +62 -0
  274. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +152 -0
  275. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +108 -0
  276. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +427 -0
  277. package/docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md +111 -0
  278. package/docs/reference/decisions/0061-a-memory-kill-ends-the-agents-processes-not-the-harness.md +83 -0
  279. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +239 -0
  280. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +82 -0
  281. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +79 -0
  282. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +65 -0
  283. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +552 -0
  284. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +458 -0
  285. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +475 -0
  286. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +180 -0
  287. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +334 -0
  288. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +183 -0
  289. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +144 -0
  290. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +122 -0
  291. package/docs/reference/decisions/0074-an-unrouted-mechanism-event-is-refused-at-load-and-faults-the-run.md +76 -0
  292. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +295 -0
  293. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +320 -0
  294. package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
  295. package/docs/reference/decisions.md +86 -0
  296. package/docs/reference/glossary.md +687 -0
  297. package/docs/reference/http-api.md +1386 -0
  298. package/docs/reference/machines.md +189 -0
  299. package/docs/reference/mechanism-events.md +72 -0
  300. package/docs/why.md +207 -0
  301. package/package.json +5 -3
  302. package/src/actor.ts +694 -106
  303. package/src/agent.ts +114 -38
  304. package/src/ambient.ts +3 -4
  305. package/src/assembly-checks.ts +189 -7
  306. package/src/cause.ts +234 -0
  307. package/src/chart.ts +345 -46
  308. package/src/charts.ts +43 -8
  309. package/src/config.ts +440 -178
  310. package/src/custodian.lua +17 -5
  311. package/src/custodian.ts +325 -99
  312. package/src/customize.ts +199 -49
  313. package/src/dispatch-effect.ts +945 -0
  314. package/src/door-compat.ts +217 -0
  315. package/src/durability.ts +11 -4
  316. package/src/effect-checks.ts +50 -3
  317. package/src/effect.ts +641 -128
  318. package/src/effector-wire.ts +165 -13
  319. package/src/fail.ts +222 -0
  320. package/src/harness-client.ts +587 -50
  321. package/src/harness-env.ts +33 -0
  322. package/src/held-secrets.ts +125 -16
  323. package/src/holds.ts +3 -0
  324. package/src/http-effect.ts +556 -58
  325. package/src/http.ts +480 -30
  326. package/src/index.ts +74 -82
  327. package/src/instance.ts +68 -8
  328. package/src/internal.ts +85 -0
  329. package/src/kube-client.ts +6 -3
  330. package/src/machine-doc.ts +24 -7
  331. package/src/mechanism-routes.ts +168 -0
  332. package/src/model-keys.generated.ts +53 -0
  333. package/src/model-keys.ts +42 -58
  334. package/src/names.ts +19 -6
  335. package/src/notices.ts +483 -41
  336. package/src/open-body.ts +242 -40
  337. package/src/open-effect.ts +81 -8
  338. package/src/open.ts +90 -6
  339. package/src/parts.ts +213 -30
  340. package/src/party.ts +101 -2
  341. package/src/pick.ts +497 -11
  342. package/src/pool.ts +282 -50
  343. package/src/registration.ts +133 -12
  344. package/src/repo-identity.ts +7 -3
  345. package/src/repos.ts +22 -3
  346. package/src/run-host.ts +665 -45
  347. package/src/run-output.ts +38 -0
  348. package/src/sandbox-effect.ts +457 -71
  349. package/src/sandbox-kube.ts +66 -17
  350. package/src/secret-placement.ts +59 -17
  351. package/src/server.ts +3 -0
  352. package/src/setup.ts +325 -62
  353. package/src/size.ts +121 -9
  354. package/src/snapshot-store.ts +54 -10
  355. package/src/stub-harness.ts +44 -16
  356. package/src/timers.ts +14 -1
  357. package/src/tokens.ts +107 -32
  358. package/src/vocabulary.ts +88 -14
  359. package/src/wire.ts +100 -26
  360. package/src/workspace.ts +771 -238
  361. package/src/worktree.ts +249 -0
package/src/workspace.ts CHANGED
@@ -1,9 +1,10 @@
1
- // `workspace(body, { input, image, user, repos, resources, spec })` (ADR-0012, ADR-0049, ADR-0051,
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
1
+ // `workspace(body, { input, image, user, repos, resources, workGroup })` (ADR-0012, ADR-0049, ADR-0051,
2
+ // ADR-0060, ADR-0075): 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) + clone each Repo
4
+ // Slot, run the author's body Machine inside it as the named slot `body`, with
5
+ // `{ workspace: { repos } }` 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,22 @@ import {
41
44
  } from "xstate";
42
45
  import type { z } from "zod";
43
46
  import { registerAmbientHandles, type AmbientHandles } from "./ambient.ts";
47
+ import { isAgent } from "./agent.ts";
48
+ import { isEffect } from "./effect.ts";
49
+ import { failLogic, failurePayload, isFailure, type Failure, type FailureInput } from "./fail.ts";
50
+ import { refuseUnroutedLost } from "./mechanism-routes.ts";
44
51
  import {
45
52
  actorRoute,
46
53
  actorSlotPath,
47
54
  assertRepoSlot,
48
55
  attachSandboxParts,
49
56
  attachWrapperBody,
57
+ composesSandbox,
50
58
  customizeLine,
51
59
  open,
52
60
  repoSlotState,
53
61
  sandboxPartsOf,
62
+ wrapperBodyOf,
54
63
  type Binding,
55
64
  type JR2Repos,
56
65
  type JR2Wrapper,
@@ -61,7 +70,14 @@ import {
61
70
  } from "./parts.ts";
62
71
  import { actorPath, runBindingOf, type AnyActorSystem } from "./registration.ts";
63
72
  import { assertSize, assertUserSeat, userSeatOf, type Size, type UserContainer } from "./size.ts";
64
- import { attachInputSchema, inputSchemaOf, invokingMachine, type HostInjectedInput } from "./vocabulary.ts";
73
+ import {
74
+ attachInputSchema,
75
+ declaresRunInput,
76
+ inputSchemaOf,
77
+ parsedNestedDoor,
78
+ invokingMachine,
79
+ type HostInjectedInput,
80
+ } from "./vocabulary.ts";
65
81
 
66
82
  /** Lease cadence when the backend names none. Well inside the 30m default idle timeout, so a
67
83
  * few missed renewals in a row are survivable. It bounds only how long an orphan waits to be
@@ -83,75 +99,170 @@ export function leaseDelay(interval: number, which: "first" | "next", random: nu
83
99
  return interval * (1 - LEASE_JITTER + 2 * LEASE_JITTER * random);
84
100
  }
85
101
 
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
- export type WorkspaceSpec = {
94
- 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. */
102
- 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. */
107
- reviewSha?: string;
102
+ // What the workspace hands the BODY (ADR-0012, ADR-0016): worktree geography only, keyed by Repo
103
+ // Slot (ADR-0051) — so a path a prompt names is right in every Instance that consumes the Machine.
104
+ // `endpoint` and `sandbox` are mechanism-internal — the Agent actor resolves them ambiently from
105
+ // the enclosing wrapper (ambient.ts), so a workflow can no longer forget to thread them (the
106
+ // baba71f incident: `sandbox` omitted, every tool call 403'd, fail-closed but silent).
107
+ //
108
+ // `TSlots` has NO default on purpose (ADR-0050): a body names the slots it reads, and
109
+ // `workspace()` holds those names to the ones the wrapper declares. A default of `string` would
110
+ // type `repos` as `Record<string, string>`, under which a misspelled slot reads as a path — the
111
+ // silent widening the slot key exists to refuse. `string` WRITTEN is a different statement: the
112
+ // body names no slot at all — it enumerates them, in the order the composer wrote — which is the
113
+ // body under a wrapper that declares its map Open (`repos: open`, ADR-0051), where the slots are
114
+ // the composer's words and no body could name them.
115
+ /**
116
+ * What the body of a `workspace()` gets as `input.workspace`: the folder of each Repo Slot. A path
117
+ * is correct in each Instance that registers the Machine.
118
+ *
119
+ * `TSlots` is the names of the Repo Slots that the body reads. `workspace()` refuses a body that
120
+ * names a slot that the Workspace does not have. Write `string` for a body that names no slot and
121
+ * reads the folders in the order of `repos`, as under `repos: open`. Write `never` for a
122
+ * Workspace with no Repo Slot: its handles are `{ repos: {} }`, and its Agents work in `/work`.
123
+ */
124
+ // `[TSlots] extends [never]`, not `TSlots extends never`: a bare `never` would distribute to `never`.
125
+ export type WorkspaceHandles<TSlots extends string> = [TSlots] extends [never]
126
+ ? { repos: Record<never, string> }
127
+ : SlotHandles<TSlots>;
128
+
129
+ /** The handles of a Workspace that declares Repo Slots. @internal */
130
+ type SlotHandles<TSlots extends string> = {
131
+ /** The folder of each Repo Slot, `/work/<slot>`, in the order that `repos` declares the slots.
132
+ * `${handle}/default` is the clone, on the Binding's base. Any other Worktree is one the Machine
133
+ * cut with `worktree()`. */
134
+ repos: { [K in TSlots]: `/work/${K}` };
108
135
  };
109
136
 
137
+ // The composition is the whole reason the door is declared on the wrapper and not on the body
138
+ // (ADR-0033): `Workspaced<RunInput, Slot>` is what the body receives, `RunInput` is what a caller
139
+ // may send, and no caller can send `workspace` (the handles do not exist until a Sandbox is
140
+ // provisioned and attached). Naming it here keeps the body from hand-copying
141
+ // {@link WorkspaceHandles}, which drifts. The slot argument is required, never defaulted, so
142
+ // `workspace.repos.<slot>` is typed by the same keys the wrapper declares (ADR-0050, ADR-0051).
143
+ //
144
+ // The wrapper passes its input through UNTOUCHED, so a ROOT-placed wrapper's body also receives
145
+ // what the host injected beside the door — `HostInjectedInput` today (the run's `instanceId`).
146
+ // That is outside this type on purpose: it depends on where the wrapper sits, and `Workspaced` is
147
+ // the composition the WRAPPER makes.
110
148
  /**
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).
149
+ * The input of a `workspace()` body: the run input, plus `workspace`, the handles that the
150
+ * Workspace adds (`workspace.repos`, the folder of each Repo Slot). Use it as the type of
151
+ * the body's input.
152
+ *
153
+ * The first argument is the run input: the `input` schema of the `workspace()`. The second is the
154
+ * Repo Slots that the body reads, by name (`"app"` or `"app" | "docs"`). Under `repos: open`, write
155
+ * `string`: the body cannot know the names, so it reads the slots in order. For a `workspace()`
156
+ * with no `repos`, write `never`. The second argument has no default.
157
+ *
158
+ * A body under a root `workspace()` also gets the fields of `HostInjectedInput`. Add them when the
159
+ * body reads them: `Workspaced<HostInjectedInput & RunInput, "app">`.
160
+ *
161
+ * Its fields are the fields of the run input, and `workspace`. The run input can have no fields:
162
+ * `Workspaced<z.infer<typeof door>, "app">` for the door `z.object({})` holds only `workspace`.
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * import { z } from "zod";
167
+ * import {
168
+ * agent,
169
+ * defineEvent,
170
+ * fail,
171
+ * isReady,
172
+ * jr2Setup,
173
+ * workspace,
174
+ * worktree,
175
+ * type Workspaced,
176
+ * } from "@jr2/orchestrator";
116
177
  *
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.
178
+ * const done = defineEvent({
179
+ * name: "done",
180
+ * description: "Call this when REVIEW.md is written.",
181
+ * input: z.object({}),
182
+ * });
183
+ * const runInput = z.object({ sha: z.string() });
184
+ * type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
185
+ *
186
+ * // A handle is the slot's folder: `/work/app`. A helper returns `as const`, so that the compiler
187
+ * // sees the slot.
188
+ * const reviewTree = (context: BodyInput) => `${context.workspace.repos.app}/review` as const;
189
+ *
190
+ * const body = jr2Setup({
191
+ * types: {} as { input: BodyInput; context: BodyInput },
192
+ * events: [done],
193
+ * actors: {
194
+ * reviewer: agent({
195
+ * model: "anthropic/claude-haiku-5-5",
196
+ * instructions: "You review one commit and write REVIEW.md.",
197
+ * workspace: "read",
198
+ * }),
199
+ * cut: worktree(),
200
+ * fail: fail(),
201
+ * },
202
+ * }).createMachine({
203
+ * id: "review",
204
+ * context: ({ input }) => input,
205
+ * on: { "workspace.lost": ".lost", "agent.fault": ".failed" },
206
+ * initial: "cutting",
207
+ * states: {
208
+ * cutting: {
209
+ * invoke: {
210
+ * src: "cut",
211
+ * input: ({ context }) => ({ path: reviewTree(context), detach: context.sha }),
212
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "reviewing" }, { target: "notCut" }],
213
+ * },
214
+ * },
215
+ * reviewing: {
216
+ * invoke: {
217
+ * src: "reviewer",
218
+ * input: ({ context }) => ({
219
+ * prompt: `Review commit ${context.sha}.`,
220
+ * cwd: reviewTree(context),
221
+ * }),
222
+ * },
223
+ * on: { done: "finished" },
224
+ * },
225
+ * finished: { type: "final" },
226
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
227
+ * failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
228
+ * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
229
+ * },
230
+ * });
231
+ *
232
+ * export const machine = workspace(body, {
233
+ * input: runInput,
234
+ * repos: { app: "https://github.com/you/app.git" },
235
+ * });
236
+ * ```
237
+ *
238
+ * @see docs/concepts/workspaces.md
239
+ * @see docs/guides/write-a-workflow.md
240
+ * @see docs/reference/decisions/0012-workspace-wrapper-machine.md
241
+ * @see docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md
124
242
  */
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). */
132
- repos: Record<TSlots, string>;
133
- 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. */
136
- review?: Partial<Record<TSlots, string>>;
243
+ export type Workspaced<TInput, TSlots extends string> = WithoutEmptyIndex<TInput> & {
244
+ workspace: WorkspaceHandles<TSlots>;
137
245
  };
138
246
 
247
+ // `z.infer` of a door with no fields, `z.object({})`, is `Record<string, never>`: an index signature
248
+ // that says each key holds `never`. Intersected with the handles, it makes each assign of a body
249
+ // context fail (`n: number` is not `never`) with a diagnostic that does not name the cause. This
250
+ // removes that index signature, and only one whose value is `never`, so the named fields stay
251
+ // (`HostInjectedInput & Record<string, never>` keeps `instanceId: string`) and an input that is a
252
+ // real map (`Record<string, string>`) is not changed. The alternative, a separate type for the empty
253
+ // door, makes the author choose between two names for one statement.
254
+ // It is not `@internal`: the Reference then shows this TSDoc, so a reader of `Workspaced` sees that
255
+ // these are the fields of the run input. As plumbing, its page showed only the declaration.
139
256
  /**
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).
148
- *
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.
257
+ * The fields of the run input `T`. A string index signature whose value is `never` is not one of
258
+ * them: `z.infer` gives `Record<string, never>` for `z.object({})`, and this type makes it `{}`. The
259
+ * named fields of `T` stay. A different `T` does not change.
153
260
  */
154
- export type Workspaced<TInput, TSlots extends string> = TInput & { workspace: WorkspaceHandles<TSlots> };
261
+ type WithoutEmptyIndex<T> = string extends keyof T
262
+ ? [T[string & keyof T]] extends [never]
263
+ ? { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }
264
+ : T
265
+ : T;
155
266
 
156
267
  /**
157
268
  * What the watch says about a workspace (ADR-0021, ADR-0063): gone, or present — and, once the
@@ -190,6 +301,9 @@ export type PlaceRequest = {
190
301
  image?: string;
191
302
  user?: string;
192
303
  workGroup?: number;
304
+ /** The Held secrets the `workspace()`'s `secrets` list names (ADR-0059): the ones the Harness may
305
+ * put in a confined child's env, by Stand-in (ADR-0076). Names, never a value. Absent → none. */
306
+ secrets?: string[];
193
307
  repos: ProvisionedRepo[];
194
308
  resources?: Size;
195
309
  userResources?: Size;
@@ -237,17 +351,12 @@ export interface SandboxPort {
237
351
  */
238
352
  provision(name: string): Promise<{ endpoint: string }>;
239
353
  /** Post-Ready attach (ADR-0004): per slot, `git clone --shared` off the node's read-only
240
- * cache (the `default/` checkout), then a branch worktree sibling — and, with `spec.reviewSha`, the detached
241
- * review worktree (ADR-0028). Resolves with the worktree paths by slot; `stale` names the slots
242
- * whose cache could not be fetched before this attach, with git's own error (ADR-0051: freshness
243
- * degrades, absence does not). */
244
- attach(req: {
245
- name: string;
246
- spec: WorkspaceSpec;
247
- repos: Array<{ slot: string; url: string; ref?: string }>;
248
- }): Promise<{
354
+ * cache (the `default/` checkout). A Machine cuts each Worktree (ADR-0075). Resolves with the
355
+ * folder of each slot; `stale` names the slots whose cache could not be fetched before this
356
+ * attach, with git's own error (ADR-0051: freshness degrades, absence does not). */
357
+ attach(req: { name: string; repos: Array<{ slot: string; url: string; ref?: string }> }): Promise<{
249
358
  repos: Record<string, string>;
250
- review?: Record<string, string>;
359
+ bases?: Record<string, string>;
251
360
  stale?: Record<string, string>;
252
361
  }>;
253
362
  /**
@@ -277,8 +386,10 @@ export interface SandboxPort {
277
386
  * `OOMKilled` at or after `since`, the kill — its fault reason, starting with the fixed prefix
278
387
  * `memory limit`, and the data the next Turn's notice is made of (ADR-0062) — else undefined.
279
388
  * 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).
389
+ * which can trail the Harness's restart. Asked too before an admission into the Workspace whose
390
+ * watch shows a last end `OOMKilled`, with `since` that end: a kill while no Turn ran (ADR-0062).
391
+ * A backend that cannot see the container answers undefined, and every lost conversation stays
392
+ * lost — never an optional method (ADR-0021).
282
393
  */
283
394
  memoryFault(name: string, since: Date): Promise<MemoryKill | undefined>;
284
395
  /** Delete the Sandbox CR. Absent is success. */
@@ -348,12 +459,11 @@ export type WorkspaceWait = PlacingWait & { since: string };
348
459
  export const WORKSPACE_WAIT_KEY = "wait";
349
460
 
350
461
  type WsContext = {
351
- /** The wrapper's own input, passed through to the body untouched (plus `workspace`). */
462
+ /** The wrapper's input, which the body gets (plus `workspace`). Under a parent, `place` replaces
463
+ * it with the door's parsed value. */
352
464
  runInput: Record<string, unknown>;
353
465
  /** This invocation's stable identity (the actor id the parent invoked/spawned us under). */
354
466
  wsId: string;
355
- /** The resolved spec — computed once from input and persisted, like any other context data. */
356
- spec: WorkspaceSpec;
357
467
  /** Every slot's resolved Binding, by slot, in declaration order — assigned at provision. */
358
468
  bindings?: Record<string, ResolvedBinding>;
359
469
  endpoint?: string;
@@ -363,6 +473,9 @@ type WsContext = {
363
473
  /** Persisted in context so the registrar can re-publish them on restore. */
364
474
  handles?: MechanismHandles;
365
475
  output?: unknown;
476
+ /** The body's Failure (ADR-0073), as plain data: kept while `failing` destroys, so a restore
477
+ * there sends the same Failure up. */
478
+ failure?: FailureInput;
366
479
  };
367
480
 
368
481
  /**
@@ -383,12 +496,14 @@ type WsContext = {
383
496
  * Repo Slots it carries, because a default would widen exactly where the phantom exists to refuse
384
497
  * — `JR2Repos<string>` offers `customize()` every key, and `AnyStateMachine` as the body offers no
385
498
  * Agent at all. `PoolMachine` names its worker the same way.
499
+ *
500
+ * @internal
386
501
  */
387
502
  export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSlots extends string> = StateMachine<
388
503
  any,
389
504
  any,
390
505
  any,
391
- WrapperActors<"body", TBody, "place" | "provision" | "attach" | "registrar" | "lease" | "destroy">,
506
+ WrapperActors<"body", TBody, "place" | "provision" | "attach" | "registrar" | "lease" | "destroy" | "fail">,
392
507
  any,
393
508
  any,
394
509
  any,
@@ -435,8 +550,29 @@ export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSl
435
550
  */
436
551
  // The handles are keyed by the wrapper's DECLARED slots (ADR-0051), so a body demanding a slot the
437
552
  // wrapper never declared is refused here too; demanding fewer is fine, as with any other field.
553
+ //
554
+ // The provided side holds the raw door AND its `Workspaced` form. `Workspaced` removes the
555
+ // `Record<string, never>` that `z.infer` gives the empty door, so `Workspaced` alone no longer
556
+ // satisfies a body that writes that type itself (`z.infer<typeof door>`, alone or with the
557
+ // handles). The raw door keeps those bodies legal, and the stripped form keeps a body that
558
+ // declares `Workspaced<…>` legal. A key that the door does not carry is still refused.
559
+ /** The input of a door-less Workspace: the body's, without the handles. `unknown` when the body
560
+ * asks for nothing else, and with `undefined` added when each field it asks for is optional. In
561
+ * both cases a parent has nothing that it must give, and xstate makes an invoke's `input` optional
562
+ * only for a type that holds `undefined`.
563
+ * @internal */
564
+ type DoorlessInput<TBody extends AnyStateMachine> =
565
+ unknown extends InputFrom<TBody>
566
+ ? unknown
567
+ : [keyof Omit<InputFrom<TBody>, "workspace">] extends [never]
568
+ ? unknown
569
+ : {} extends Omit<InputFrom<TBody>, "workspace">
570
+ ? Omit<InputFrom<TBody>, "workspace"> | undefined
571
+ : Omit<InputFrom<TBody>, "workspace">;
572
+
573
+ /** @internal */
438
574
  type BodyAcceptsDoor<TBody extends AnyStateMachine, TDoor, TSlots extends string> =
439
- Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
575
+ TDoor & Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
440
576
  ? BodyNamesNoSlotUnderOpenMap<TBody, TSlots>
441
577
  : { "the body's declared input must accept the door plus the injected handles": Workspaced<TDoor, TSlots> };
442
578
 
@@ -467,6 +603,8 @@ type BodyNamesNoSlotUnderOpenMap<TBody extends AnyStateMachine, TSlots extends s
467
603
  * is the same one-direction assignability, on the `workspace` field alone: the handles the wrapper
468
604
  * will inject must satisfy what the body declares for them, so a body that names fewer slots, or
469
605
  * no handles at all, is fed a superset and passes, as ever.
606
+ *
607
+ * @internal
470
608
  */
471
609
  type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
472
610
  InputFrom<TBody> extends { workspace: infer THandles }
@@ -475,141 +613,452 @@ type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
475
613
  : { "the body's declared handles must accept the Repo Slots the wrapper declares": WorkspaceHandles<TSlots> }
476
614
  : unknown;
477
615
 
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
- */
616
+ // What the Sandbox is MADE OF (ADR-0037, ADR-0005) and which Repos it attaches (ADR-0051), as
617
+ // STATIC options on the wrapper rather than a function of the run input (ADR-0049). Static is the
618
+ // whole point: `jr2 up` walks the registered Machines to find every `file:` context and build it,
619
+ // every bound Repo and warm it, every open slot and refuse it (parts.ts) — and no walk can evaluate a
620
+ // function of run input. They are also never persisted — the provisioning state
621
+ // re-reads them off the Machine it was invoked as, so a restore, a `provide()` and a `customize()`
622
+ // all get the image and the slots the Machine carries NOW.
623
+ //
624
+ // Each image is one string in ADR-0037's two shapes: a `file:` URL to a docker context the
625
+ // Machine's module ships (`import.meta.resolve("./image")`), or a registry ref its owner baked and
626
+ // hosts.
627
+ //
628
+ // `TSlots` has no default, here and on the two option types below (ADR-0050): a value annotated
629
+ // with a `string` slot set types `repos` as `Record<string, RepoSlot>`, under which a body naming
630
+ // any slot passes the wrapper's check — the widening WorkspaceHandles refuses for the
631
+ // same reason. The `workspace()` overloads infer it; an annotation names it.
632
+ /** The options of a `workspace()` that say what its Sandbox is made of and which Repos it
633
+ * clones. They are the same for each run. */
496
634
  export type SandboxOptions<TSlots extends string, TInput = unknown> = {
497
- /** The Sandbox Image. Absent → the Instance's `images/default`, then the stock Harness. */
635
+ /** The Sandbox Image: the image of the container where the Agent's Working tools and
636
+ * `command` Effects run. A `file:` URL to a docker context that your module ships
637
+ * (`import.meta.resolve("./image")`), which `jr2 up` builds, or a registry ref. Its floor is
638
+ * glibc and git; jr2 adds its own runtime at `/opt/jr2`. Default: the Instance's
639
+ * `images/default`, then jr2's Harness image. */
498
640
  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. */
641
+ // A gid is a fact of the images, so it is static.
642
+ /** The group id that owns the files in `/work`. Default: 2000. A non-negative integer. */
643
+ workGroup?: number;
644
+ // The widening ADR-0005 foresaw: `{ image, resources }` states the seat's split of the Size
645
+ // (ADR-0060). There is no default, because the seat's whole identity is "what jr2 does not own"
646
+ // and jr2 has nothing to put there. Env and ports are deliberately not forwarded.
647
+ /** The User Container: a third container in the Sandbox, for a service that must run beside the
648
+ * Agent, such as an sshd. Its image, in the same two forms as `image`, or `{ image, resources }`
649
+ * to give it a part of the Size. It runs its own entrypoint, with `/work` mounted. jr2 adds
650
+ * nothing to it. Default: none. Without `resources`, it has no limit of its own and shares the
651
+ * Size of the Sandbox. */
504
652
  user?: string | UserContainer;
653
+ // Agent Substrate's template size (ADR-0060), so the Workspace moves there unchanged. A fact of
654
+ // the Machine, like its image: the author knows the toolchain, the runner, the worker count.
505
655
  /**
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 })`.
656
+ * The Size: the memory and cpu limit of the whole Sandbox, `{ limits: { memory, cpu } }`, such as
657
+ * `{ limits: { memory: "4Gi", cpu: "2" } }`. jr2 reserves all of it on the node: each request
658
+ * equals its limit. jr2 also enforces the cpu limit. Only `limits`,
659
+ * and in it only `memory` and `cpu`, are accepted. Default: the Instance's `sandbox.resources`,
660
+ * then 2Gi and 1 cpu. Under a Size that you give, a field you leave out takes the kit's value,
661
+ * not the Instance's. The Instance that registers the Machine can change it with
662
+ * `customize(machine, { resources })`.
512
663
  */
513
664
  resources?: Size;
665
+ // The handles keep this map's order, and the kit reads nothing into it (ADR-0051). Required
666
+ // here, at least one; a Workspace with no Repo Slot leaves it out, which is the
667
+ // `NoSlotWorkspaceOptions` overload, not an empty map. `repos: open` is the shape a packaged
668
+ // Machine takes when its body enumerates its checkouts rather than naming them
669
+ // (`@jr2/machines`'s `task`, whose first slot is the one the coder edits).
514
670
  /**
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`).
671
+ * The Repo Slots: the repositories that the Sandbox clones, each under a name of your Machine's
672
+ * choice. The name is the key of `workspace.repos` in the body and the directory under `/work`.
673
+ * At least one slot. Leave `repos` out for a Workspace with no Repo Slot (see
674
+ * {@link NoSlotWorkspaceOptions}). Each value is one of:
675
+ * - a url, or `{ url, ref }`, where `ref` is the branch, the tag or the commit that the clone
676
+ * stays on, and that a new branch starts from (default: the Repo's default branch);
677
+ * - `open`, for the Instance that registers the Machine to bind with `customize()`;
678
+ * - a function of the run input, `({ input }) => url` or `{ url, ref }`, when each run chooses.
520
679
  *
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.
680
+ * Or `repos: open` for the whole map: the Machine names no slot, and the Instance that registers
681
+ * it names each one with `customize()`. The body's input is then `Workspaced<RunInput, string>`.
525
682
  */
526
683
  repos: Record<TSlots, RepoSlot<TInput>> | typeof open;
684
+ // Naming is placement (ADR-0059): a pod gets the Held secrets something in it names — this
685
+ // list, and the model keys of the Agents whose Turns run here, which their providers claim
686
+ // without being listed.
527
687
  /**
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.
688
+ * The Held secrets that this Sandbox can use: names from the `secrets` list in `jr2.config.ts`.
689
+ * The Sandbox's Custodian holds each value and puts it into requests to the hosts of that Held
690
+ * secret. Listing a name also gives its Stand-in to the Agent's programs — each `bash` call, test,
691
+ * build and `command` — in their env under that name, so code in `/work` can spend it (ADR-0076).
692
+ * You need not list the model key of an Agent that runs here: its provider claims it, and the
693
+ * Harness gives it to the model calls alone. List the model key only when code in `/work` must
694
+ * call the model itself: such a call is outside the Turn's usage record. Each name once.
532
695
  */
533
696
  secrets?: readonly string[];
697
+ // The container exists only when an Effect below this `workspace()` is placed there (ADR-0068,
698
+ // ADR-0005). It runs as its own `USER`.
534
699
  /**
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.
700
+ * The image of the Sandbox Effector. This container runs three kinds of Effect for this
701
+ * Workspace: a `push`, an `http` Effect with a Worktree file as its body, and a `command` with an
702
+ * Effect secret in its `env`. It gets the Effect secrets that those Effects name. Give an image
703
+ * when those Effects need tools or credentials that you keep out of the Sandbox Image, such as
704
+ * `terraform`. A `file:` URL or a registry ref, as for `image`. Default: the Sandbox Image,
705
+ * which already holds `git`.
541
706
  */
707
+ // The default is the Sandbox Image, whose floor already holds `git` (ADR-0037).
542
708
  effector?: { image: string };
543
709
  };
544
710
 
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
- */
711
+ // How a Workspace with a declared door is configured (ADR-0012, ADR-0033) — the wrapper's own
712
+ // run-input schema, what the pod is made of, and the mapping from what comes through the door to
713
+ // workspace vocabulary.
714
+ /** The options of a `workspace()` that declares the input that starts a run. */
550
715
  export type WorkspaceOptions<TSchema extends z.ZodObject, TSlots extends string> = SandboxOptions<
551
716
  TSlots,
552
717
  z.infer<TSchema>
553
718
  > & {
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`. */
719
+ // Deliberately NOT the body's schema (ADR-0033): the body is fed the run input PLUS the injected
720
+ // `workspace` handles ({@link Workspaced}), which no caller can send, so the body's contract is
721
+ // the door plus something that does not exist yet. Symmetric with `PoolSpec.input`.
722
+ /** The input that starts a run, as a `z.object`. jr2 checks each start against it. It also types
723
+ * the `input` of a per-run Repo Slot, and the body must accept it plus the
724
+ * `workspace` handles. Write it here, not on the body. */
559
725
  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;
563
726
  };
564
727
 
728
+ // How a Workspace with NO declared door is configured: absence is permissive (ADR-0033), so jr2
729
+ // has nothing to infer from and says `unknown` rather than `any` — an honest "jr2 does not know",
730
+ // which the mapper must narrow before it reads a field.
565
731
  /**
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`.
732
+ * The options of a `workspace()` with no `input`: it starts with any input. The `input` of a
733
+ * per-run Repo Slot is then `unknown`, and the mapper must check it before it reads a field.
734
+ *
735
+ * Use it for a Workspace that a Pool or another Machine starts. The body's `input` type says what
736
+ * that input is, and that also types the input of the Workspace. For a Workspace that a caller
737
+ * starts, declare `input`.
572
738
  */
573
739
  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. */
740
+ // Spelled out so a declared schema can never fall through to the permissive overload, where the
741
+ // body's door would go unchecked.
742
+ /** Not present: a `workspace()` without `input` starts with any input. */
743
+ input?: never;
744
+ };
745
+
746
+ // A Workspace with no Repo Slot (ADR-0051): a Sandbox for one unit of work whose Agents and
747
+ // Effects share `/work`, and no Worktree is in it — the steps of one job. Its own option types, not a
748
+ // conditional on `TSlots`, because the slot overloads infer `TSlots` from `repos`, and no
749
+ // inference reaches through a conditional type.
750
+ /** The options of a `workspace()` with no Repo Slot that declares the input that starts a run.
751
+ * `repos` is left out. */
752
+ export type NoSlotWorkspaceOptions<TSchema extends z.ZodObject> = Omit<SandboxOptions<never>, "repos"> & {
753
+ /** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
754
+ repos?: never;
755
+ /** The input that starts a run, as a `z.object`, as in {@link WorkspaceOptions}. */
756
+ input: TSchema;
757
+ };
758
+
759
+ /** The options of a `workspace()` with no Repo Slot and no `input`: it starts with any input. */
760
+ export type PermissiveNoSlotWorkspaceOptions = Omit<SandboxOptions<never>, "repos"> & {
761
+ /** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
762
+ repos?: never;
763
+ /** Not present: a `workspace()` without `input` starts with any input. */
576
764
  input?: never;
577
- spec: (args: { input: TInput }) => WorkspaceSpec;
578
765
  };
579
766
 
580
767
  /**
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.
768
+ * Run a body Machine in a Sandbox: a pod of its own, with a clone of each Repo Slot. The result is
769
+ * a Machine. Register it as a Workflow, or invoke it from another Machine as
770
+ * an actor slot.
771
+ *
772
+ * When a run enters it, the Workspace makes the Sandbox and waits for room on a node, with no
773
+ * deadline. It clones each Repo Slot to `/work/<slot>/default`, on the Binding's base, and makes no
774
+ * Worktree: the body cuts each Worktree that it needs with `worktree()`. A slot that is only read
775
+ * needs no cut. No work happens in `default/`. Then it runs the body with the run input plus
776
+ * `workspace`, the handles (see {@link Workspaced}). The Agents with Working tools and the `command` Effects in the body run in that Sandbox. An Agent
777
+ * with `workspace: "none"` runs on the Instance Harness. When the body reaches a final state, the
778
+ * Workspace deletes the Sandbox, and its output is the body's output. Push each branch before
779
+ * then: jr2 deletes the Sandbox's commits with it.
780
+ *
781
+ * The author chooses how many Agents work in a Workspace. Agents in one Worktree share git, as
782
+ * people in one checkout do. Each coder that needs its own branch gets its own Worktree of the one
783
+ * clone.
784
+ *
785
+ * Leave out `repos` for a Workspace with no Repo Slot: its Agents and `command` Effects share
786
+ * `/work`, as the steps of one job share a folder. The body's input is then `Workspaced<RunInput, never>`. An `http` Effect sends a
787
+ * file of `/work` with `file(path)`. A `worktree()`, a `push`, and a `command` with an Effect secret in its
788
+ * `env`, need a Repo Slot, so jr2 refuses them there. jr2 deletes `/work` with the Sandbox: send what
789
+ * must stay before the body ends.
790
+ *
791
+ * Give each run a different branch, so that two runs do not push to one branch. Make it from a
792
+ * field of the run input, or from `instanceId` when the run input has no field that is different
793
+ * for each run: ``branch: `fix/${context.instanceId}` `` in the input of the cut. Add
794
+ * `HostInjectedInput` to the body's input type to read it. Only a root `workspace()` gets `instanceId` from jr2. A `workspace()` that a different Machine invokes gets
795
+ * it only when its parent gives it.
796
+ *
797
+ * The Sandbox is Lost when its pod is evicted or its node goes. Then the body gets the event
798
+ * `workspace.lost`, and the body decides what to do. jr2 does not make the Lost Sandbox again.
799
+ *
800
+ * When the body fails (it invokes a `fail()` slot), the Workspace deletes the Sandbox, and then the
801
+ * same Failure goes up to the parent. With no parent to catch it, the run ends `failed`. When the
802
+ * body has a defect (an error that is not a Failure), the run faults: it ends `error`, and the
803
+ * Sandbox stays for you to inspect, until jr2's operator removes it after an idle timeout.
804
+ *
805
+ * Give the run input to `input`, not to the body: a body with an `input` schema of its own is
806
+ * refused. The types refuse a body whose input does not accept the run input plus the handles,
807
+ * with the message `the body's declared input must accept the door plus the injected handles`.
808
+ *
809
+ * It refuses, when you call it:
810
+ * - a body with a state that is not final, when that state and each state above it have no
811
+ * `workspace.lost` route: ``workspace(): the body "<id>" has a state "<state>" that is not final,
812
+ * and no route for "workspace.lost" is on it or on a state above — jr2 sends workspace.lost to the
813
+ * body when its Sandbox is gone, and with no route the run faults: route it on the body's root,
814
+ * as on: { "workspace.lost": "<the state that handles it>" } (ADR-0074)``
815
+ * - a body with its own `input`: ``workspace(): the body "<id>" declares its own run input, which
816
+ * nothing will ever serve or enforce — the wrapper feeds the body the run input PLUS the injected
817
+ * `workspace` handles, so the body's schema is not the door. Move it to the wrapper:
818
+ * workspace(body, { input }) (ADR-0033).``
819
+ * - an empty `image`: ``workspace(): `image` must be a non-empty string (got <value>) — either a
820
+ * `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a
821
+ * registry ref (ADR-0037).``
822
+ * - an empty `repos`: ``workspace(): `repos: {}` names no Repo Slot — leave `repos` out for a
823
+ * Workspace with no Repo Slot, or name a slot: `repos: { app: "https://…" }` (ADR-0051).``
824
+ * - a `repos` that is not a map or `open`: ``workspace(): `repos` must be a map of Repo Slots —
825
+ * `repos: { app: "https://…" }` — or `open` whole, for a map the consumer names; leave it out
826
+ * for a Workspace with no Repo Slot (ADR-0051).``
827
+ * - a bad Repo Slot name or value (see {@link RepoSlot})
828
+ * - a `workGroup` that is not a gid: ``workspace(): `workGroup` must be a gid (got <value>)``
829
+ * - a bad `secrets` list: ``workspace(): `secrets` must be a list of distinct names from
830
+ * jr2.config.ts's `secrets` list (got <value>) — secrets: ["JIRA_TOKEN"] (ADR-0059).``
831
+ * - a bad `effector`: ``workspace(): `effector` is { image } — a `file:` URL to a docker context
832
+ * or a registry ref (got <value>) — effector: { image: import.meta.resolve("./effector") }
833
+ * (ADR-0068).``
834
+ * - a `user` that is not an image or `{ image, resources? }`: ``workspace(): `user` must be a
835
+ * non-empty string (an image), or { image, resources? } (got <value>) — either a `file:` URL to a
836
+ * docker context this module ships (`import.meta.resolve("./image")`) or a registry ref
837
+ * (ADR-0037, ADR-0060).``, or ``workspace(): user.<key> is not accepted — the User Container is {
838
+ * image, resources? } (ADR-0005, ADR-0060)`` (see {@link UserContainer})
839
+ * - a `resources` key other than `limits` or `requests`: ``workspace(): resources.<key> is not accepted — a
840
+ * Size is { limits: { memory?, cpu? } } and nothing else — Agent Substrate's template size
841
+ * (ADR-0060)``, or the same text for `resources.limits.<key>`. The other texts are in
842
+ * {@link Size}.
843
+ *
844
+ * And when a run enters it, a per-run Repo Slot's mapper that gives no `url`, or a bad `ref`:
845
+ * ``workspace invalid: repos.<slot> mapper returned <fields> — the mapper derives from run
846
+ * input; does `jr2 run --input` carry every field this workflow's workspace() slot reads?``
847
+ *
848
+ * @param body - The Machine to run in the Sandbox. Its input is `Workspaced<RunInput, Slots>`.
849
+ * @param options - What the Sandbox is made of (`image`, `user`, `resources`, `effector`), the
850
+ * Repo Slots it clones (`repos`), the Held secrets it can use (`secrets`), the pod's `fsGroup`
851
+ * (`workGroup`), and the run input (`input`).
852
+ * @returns A Machine whose input is the run input and whose output is the body's output.
853
+ *
854
+ * @example
855
+ * ```ts
856
+ * import { z } from "zod";
857
+ * import {
858
+ * command,
859
+ * fail,
860
+ * isReady,
861
+ * jr2Setup,
862
+ * workspace,
863
+ * worktree,
864
+ * type CommandOutput,
865
+ * type Workspaced,
866
+ * } from "@jr2/orchestrator";
867
+ *
868
+ * const runInput = z.object({ ticket: z.string(), repo: z.string() });
869
+ * type BodyInput = Workspaced<z.infer<typeof runInput>, "app" | "docs">;
870
+ *
871
+ * // The Worktree of this run. An Agent or a command that only reads `docs` uses `/work/docs/default`.
872
+ * const buildTree = (context: BodyInput) => `${context.workspace.repos.app}/build` as const;
873
+ *
874
+ * const passed = (output: CommandOutput): boolean =>
875
+ * output.outcome === "exited" && output.code === 0;
876
+ *
877
+ * const body = jr2Setup({
878
+ * types: {} as { input: BodyInput; context: BodyInput },
879
+ * actors: {
880
+ * cut: worktree(),
881
+ * build: command(["npm", "run", "build"]),
882
+ * fail: fail(),
883
+ * },
884
+ * }).createMachine({
885
+ * id: "build",
886
+ * context: ({ input }) => input,
887
+ * on: { "workspace.lost": ".lost" },
888
+ * initial: "cutting",
889
+ * states: {
890
+ * cutting: {
891
+ * invoke: {
892
+ * src: "cut",
893
+ * input: ({ context }) => ({ path: buildTree(context), branch: `fix/${context.ticket}` }),
894
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "building" }, { target: "notCut" }],
895
+ * },
896
+ * },
897
+ * building: {
898
+ * invoke: {
899
+ * src: "build",
900
+ * input: ({ context }) => ({ worktree: buildTree(context) }),
901
+ * onDone: [
902
+ * { guard: ({ event }) => passed(event.output), target: "built" },
903
+ * { target: "broken" },
904
+ * ],
905
+ * },
906
+ * },
907
+ * built: { type: "final" },
908
+ * broken: { invoke: { src: "fail", input: { reason: "npm run build did not pass" } } },
909
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
910
+ * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
911
+ * },
912
+ * });
913
+ *
914
+ * export const machine = workspace(body, {
915
+ * input: runInput,
916
+ * image: "docker.io/library/node:24",
917
+ * resources: { limits: { memory: "4Gi", cpu: "2" } },
918
+ * repos: {
919
+ * app: ({ input }) => input.repo,
920
+ * docs: { url: "https://github.com/you/docs.git", ref: "main" },
921
+ * },
922
+ * });
923
+ * ```
924
+ *
925
+ * @example
926
+ * ```ts
927
+ * import { z } from "zod";
928
+ * import {
929
+ * command,
930
+ * fail,
931
+ * isReady,
932
+ * jr2Setup,
933
+ * workspace,
934
+ * worktree,
935
+ * type CommandOutput,
936
+ * type HostInjectedInput,
937
+ * type Workspaced,
938
+ * } from "@jr2/orchestrator";
939
+ *
940
+ * const passed = (output: CommandOutput): boolean =>
941
+ * output.outcome === "exited" && output.code === 0;
942
+ *
943
+ * // The run input has no field that is different for each run, so the branch comes from
944
+ * // `instanceId`.
945
+ * const runInput = z.object({});
946
+ * type BodyInput = Workspaced<z.infer<typeof runInput> & HostInjectedInput, "app">;
947
+ *
948
+ * const nightlyTree = (context: BodyInput) => `${context.workspace.repos.app}/nightly` as const;
949
+ *
950
+ * const body = jr2Setup({
951
+ * types: {} as { input: BodyInput; context: BodyInput },
952
+ * actors: { cut: worktree(), test: command(["npm", "test"]), fail: fail() },
953
+ * }).createMachine({
954
+ * id: "nightly",
955
+ * context: ({ input }) => input,
956
+ * on: { "workspace.lost": ".lost" },
957
+ * initial: "cutting",
958
+ * states: {
959
+ * cutting: {
960
+ * invoke: {
961
+ * src: "cut",
962
+ * input: ({ context }) => ({ path: nightlyTree(context), branch: `nightly/${context.instanceId}` }),
963
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "testing" }, { target: "notCut" }],
964
+ * },
965
+ * },
966
+ * testing: {
967
+ * invoke: {
968
+ * src: "test",
969
+ * input: ({ context }) => ({ worktree: nightlyTree(context) }),
970
+ * onDone: [
971
+ * { guard: ({ event }) => passed(event.output), target: "tested" },
972
+ * { target: "red" },
973
+ * ],
974
+ * },
975
+ * },
976
+ * tested: { type: "final" },
977
+ * red: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
978
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
979
+ * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
980
+ * },
981
+ * });
982
+ *
983
+ * export const machine = workspace(body, {
984
+ * input: runInput,
985
+ * image: "docker.io/library/node:24",
986
+ * repos: { app: "https://github.com/you/app.git" },
987
+ * });
988
+ * ```
989
+ *
990
+ * @see docs/concepts/workspaces.md
991
+ * @see docs/guides/write-a-workflow.md
992
+ * @see docs/guides/size-a-workspace.md
993
+ * @see docs/guides/add-tools-to-the-sandbox.md
994
+ * @see docs/reference/decisions/0012-workspace-wrapper-machine.md
995
+ * @see docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md
587
996
  */
588
- export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMachine, TSlots extends string>(
997
+ // `TSlots` defaults to `never`: with `repos` left out there is nothing to infer it from, and the
998
+ // Workspace has no Repo Slot (ADR-0051). The no-slot options are a union member, not overloads of
999
+ // their own, so a call with slots reports its error where it always did.
1000
+ export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMachine, TSlots extends string = never>(
589
1001
  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>(
1002
+ options: WorkspaceOptions<TSchema, TSlots> | NoSlotWorkspaceOptions<TSchema>,
1003
+ // The input is the door without the empty door's `Record<string, never>` (`{}` for `z.object({})`), so
1004
+ // a parent can give `instanceId` to a nested body that reads it (ADR-0033).
1005
+ ): WorkspaceMachine<WithoutEmptyIndex<z.infer<TSchema>>, OutputFrom<TBody>, TBody, TSlots>;
1006
+ /**
1007
+ * Run a body Machine in a Sandbox, with no `input` schema: the Workspace starts with any input. Use
1008
+ * it for a Workspace that a Pool or another Machine starts. {@link workspace} tells the rest, and
1009
+ * what it refuses.
1010
+ *
1011
+ * @param body - The Machine to run in the Sandbox. Its input is the input of the Workspace plus
1012
+ * `workspace`, the handles.
1013
+ * @param options - The options of the Sandbox. There is no `input`.
1014
+ * @returns A Machine whose input is the run input and whose output is the body's output.
1015
+ *
1016
+ * @see docs/concepts/workspaces.md
1017
+ * @see docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md
1018
+ */
1019
+ export function workspace<
1020
+ TBody extends AnyStateMachine,
1021
+ TSlots extends string = never,
1022
+ // Nothing else annotates the input of a door-less Workspace, so the body says it.
1023
+ TInput = DoorlessInput<TBody>,
1024
+ >(
593
1025
  body: TBody & BodyAcceptsSlots<TBody, TSlots>,
594
- options: PermissiveWorkspaceOptions<TSlots, TInput>,
1026
+ // `undefined` is for the invoke alone: a run with no input gives a mapper `{}`.
1027
+ options: PermissiveWorkspaceOptions<TSlots, Exclude<TInput, undefined>> | PermissiveNoSlotWorkspaceOptions,
595
1028
  ): WorkspaceMachine<TInput, OutputFrom<TBody>, TBody, TSlots>;
596
1029
  export function workspace(
597
1030
  body: AnyStateMachine,
598
- options: SandboxOptions<string, any> & { input?: z.ZodObject; spec: (args: { input: any }) => WorkspaceSpec },
1031
+ options: Omit<SandboxOptions<string, any>, "repos"> & {
1032
+ repos?: SandboxOptions<string, any>["repos"];
1033
+ input?: z.ZodObject;
1034
+ },
599
1035
  ): AnyStateMachine {
1036
+ // The body receives the wrapper's input plus the handles — `Workspaced<TInput, TSlots>`, which is also what the
1037
+ // declared door checks the body against. A Failure of the body is caught: the wrapper destroys
1038
+ // the Sandbox and fails again with the same payload (ADR-0073). Every other body error (a defect)
1039
+ // is not caught: it faults the run (RunStatus.fault) and leaves the Sandbox to the operator's
1040
+ // idle-timeout GC, so the trail stays as evidence.
1041
+ //
600
1042
  // A body that still declares its own run input is a dead declaration under the door design: the
601
1043
  // wrapper never serves it, never validates against it, and feeds the body something it does not
602
1044
  // describe. Silence would leave an author believing a contract that nothing enforces (ADR-0033).
603
- if (inputSchemaOf(body)) {
1045
+ // A body that `setup()` made keeps its zod `input` in the config with no door attached, so the
1046
+ // check reads the config too: else the author loses the door with no message.
1047
+ if (declaresRunInput(body)) {
604
1048
  throw new Error(
605
1049
  `workspace(): the body "${body.id}" declares its own run input, which nothing will ever ` +
606
1050
  "serve or enforce — the wrapper feeds the body the run input PLUS the injected `workspace` " +
607
1051
  "handles, so the body's schema is not the door. Move it to the wrapper: " +
608
- "workspace(body, { input, spec }) (ADR-0033).",
1052
+ "workspace(body, { input }) (ADR-0033).",
609
1053
  );
610
1054
  }
1055
+ // A body state that cannot route `workspace.lost` (ADR-0074): with no route, the body would show
1056
+ // healthy on the feed with no Sandbox. Here, because `workspace()` is the one place that knows
1057
+ // that a Machine is a body. A body that is the kit's own wrapper (a `workspace()`, a `pool()`) is
1058
+ // not judged: its states are jr2's, and the walk refuses a Workspace below a Workspace (ADR-0059).
1059
+ if (!composesSandbox(body) && wrapperBodyOf(body) === undefined) refuseUnroutedLost(body);
611
1060
  // Static, so checkable NOW rather than at the first provision — an empty or non-string image is
612
- // the same derives-from-a-typo bug `assertSpec` catches for the spec, one build earlier.
1061
+ // a derives-from-a-typo bug, and this catches it one build earlier.
613
1062
  const image = options.image;
614
1063
  if (image !== undefined && (typeof image !== "string" || !image)) {
615
1064
  throw new Error(
@@ -623,16 +1072,22 @@ export function workspace(
623
1072
  // any key Substrate would refuse, is a dependency the move to it would drop.
624
1073
  if (options.resources !== undefined) assertSize("workspace(): resources", options.resources);
625
1074
  // 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;
1075
+ // and every key is a directory name. Left out, the Workspace has no Repo Slot and its steps share
1076
+ // `/work`; written empty, it is refused, so "no slot" has one spelling and an empty map never
1077
+ // reads as a map someone forgot to fill. An Open map defers all of that to the `customize` that
1078
+ // fills it, which still asks for at least one: an Open map says the composer names the slots.
1079
+ const repos = options.repos ?? {};
630
1080
  if (repos !== open) {
631
- if (typeof repos !== "object" || repos === null || Array.isArray(repos) || Object.keys(repos).length === 0) {
1081
+ if (typeof repos !== "object" || repos === null || Array.isArray(repos)) {
632
1082
  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).",
1083
+ 'workspace(): `repos` must be a map of Repo Slots — `repos: { app: "https://…" }` — or `open` whole, ' +
1084
+ "for a map the consumer names; leave it out for a Workspace with no Repo Slot (ADR-0051).",
1085
+ );
1086
+ }
1087
+ if (options.repos !== undefined && Object.keys(repos).length === 0) {
1088
+ throw new Error(
1089
+ "workspace(): `repos: {}` names no Repo Slot — leave `repos` out for a Workspace with no Repo Slot, " +
1090
+ 'or name a slot: `repos: { app: "https://…" }` (ADR-0051).',
636
1091
  );
637
1092
  }
638
1093
  for (const [slot, value] of Object.entries(repos)) assertRepoSlot("workspace()", slot, value);
@@ -664,7 +1119,10 @@ export function workspace(
664
1119
  `${JSON.stringify(effector)}) — effector: { image: import.meta.resolve("./effector") } (ADR-0068).`,
665
1120
  );
666
1121
  }
667
- const wrapper = buildWorkspaceMachine(body, options.spec);
1122
+ const workGroup = options.workGroup;
1123
+ assertWorkGroup(workGroup);
1124
+ if (repos === open || Object.keys(repos).length > 0) refuseUnnamedWorktrees(body);
1125
+ const wrapper = buildWorkspaceMachine(body);
668
1126
  // The wrapper is TRANSPARENT to its body (ADR-0049): `customize(machine, { agents })` on a
669
1127
  // Workspace means the Machine inside, so the composer never spells `body` and never has to know
670
1128
  // that jr2 wrapped anything.
@@ -679,6 +1137,7 @@ export function workspace(
679
1137
  ...(options.user !== undefined ? { user: options.user } : {}),
680
1138
  repos: repos === open ? open : { ...repos },
681
1139
  ...(options.resources !== undefined ? { resources: options.resources } : {}),
1140
+ ...(workGroup !== undefined ? { workGroup } : {}),
682
1141
  ...(secrets !== undefined ? { secrets: [...secrets] } : {}),
683
1142
  ...(effector !== undefined ? { effector: { image: effector.image } } : {}),
684
1143
  });
@@ -696,41 +1155,75 @@ export function workspace(
696
1155
  }
697
1156
 
698
1157
  /**
699
- * Fail a malformed spec BEFORE any pod exists. The spec derives from run input via the workflow's
700
- * mapping fn, so a `jr2 run --input` missing a field the mapping reads arrives here as `undefined` —
701
- * unchecked, it survives until the attach script's string ops and dies as "Cannot read properties
702
- * of undefined", with a Sandbox already provisioned and nothing pointing back at the input.
1158
+ * Each Turn and each Effect that works in a Worktree says which, in a Workspace with Repo Slots: the
1159
+ * walk's half of what `jr2Setup` refuses by type, for a plain `setup()`, for a nested key, and for a
1160
+ * body with no typed handles. It sees an invoke with no input and a literal input. A mapper's answer
1161
+ * is its start's to refuse. A Machine below the body is walked too: a child, and a Pool's worker,
1162
+ * which is no invoke, so each Machine in `actors` is walked. A nested Workspace is not: its own
1163
+ * `workspace()` call walks it, by its own Repo Slots.
703
1164
  */
704
- function assertSpec(spec: WorkspaceSpec): void {
705
- const bad: string[] = [];
706
- if (typeof spec?.branch !== "string" || !spec.branch) bad.push(`branch (got ${JSON.stringify(spec?.branch)})`);
707
- // The branch Worktree is a SIBLING of the pod-local clone at `<slot>/default/` (ADR-0004), so
708
- // the one branch name that is not a worktree directory is `default`.
709
- else if (spec.branch === "default")
710
- bad.push(
711
- 'branch "default" (the pod-local clone\'s own directory — a branch Worktree sits beside `default/`, ADR-0004)',
712
- );
713
- if (spec?.reviewSha !== undefined && (typeof spec.reviewSha !== "string" || !spec.reviewSha))
714
- bad.push(`reviewSha (got ${JSON.stringify(spec?.reviewSha)})`);
715
- // A gid, so an integer — a float or a negative becomes a pod the API server rejects at
716
- // admission, which surfaces as "never reached Ready" with nothing pointing back at the spec.
717
- if (
718
- spec?.workGroup !== undefined &&
719
- (typeof spec.workGroup !== "number" || !Number.isInteger(spec.workGroup) || spec.workGroup < 0)
720
- )
721
- bad.push(`workGroup (got ${JSON.stringify(spec?.workGroup)}; want a gid)`);
722
- if (bad.length) {
723
- throw new Error(
724
- `workspace spec invalid: ${bad.join("; ")} — the spec derives from run input; does ` +
725
- "`jr2 run --input` carry every field this workflow's workspace() mapping reads?",
726
- );
1165
+ function refuseUnnamedWorktrees(body: AnyStateMachine): void {
1166
+ const faults: string[] = [];
1167
+ const seen = new Set<AnyStateMachine>();
1168
+ const walk = (machine: AnyStateMachine): void => {
1169
+ if (seen.has(machine) || (machine !== body && composesSandbox(machine))) return;
1170
+ seen.add(machine);
1171
+ const actors = (machine.implementations.actors ?? {}) as Record<string, unknown>;
1172
+ for (const logic of Object.values(actors)) if (isMachineLogic(logic)) walk(logic);
1173
+ const nodes = [machine.root as unknown as InvokeNode];
1174
+ for (const node of nodes) {
1175
+ nodes.push(...Object.values(node.states));
1176
+ const configs = [node.config.invoke ?? []].flat() as Array<{ input?: unknown } | undefined>;
1177
+ node.invoke.forEach((inv, i) => {
1178
+ const logic = typeof inv.src === "string" ? actors[inv.src] : inv.src;
1179
+ if (isMachineLogic(logic)) walk(logic);
1180
+ const key = keyOf(logic);
1181
+ if (key === undefined) return;
1182
+ const given = configs[i]?.input;
1183
+ const written = (given as { consumerInput?: unknown } | undefined)?.consumerInput ?? given;
1184
+ if (typeof written === "function") return;
1185
+ if (typeof written === "object" && written !== null && key in written) return;
1186
+ faults.push(
1187
+ `state "${node.id}": "${typeof inv.src === "string" ? inv.src : inv.id}" names no \`${key}\`, and a Workspace ` +
1188
+ 'with Repo Slots says where each Turn and Effect works: a Worktree, or `cwd: "/work"` for a Turn',
1189
+ );
1190
+ });
1191
+ }
1192
+ };
1193
+ walk(body);
1194
+ if (faults.length) throw new Error(`workspace(): ${faults.join("; ")} (ADR-0057)`);
1195
+ }
1196
+
1197
+ const isMachineLogic = (logic: unknown): logic is AnyStateMachine =>
1198
+ typeof logic === "object" && logic !== null && "implementations" in logic;
1199
+
1200
+ type InvokeNode = {
1201
+ id: string;
1202
+ states: Record<string, InvokeNode>;
1203
+ config: { invoke?: unknown };
1204
+ invoke: Array<{ src: unknown; id: string }>;
1205
+ };
1206
+
1207
+ /** The key that says where this logic works, or undefined when it works nowhere in `/work`. */
1208
+ function keyOf(logic: unknown): "cwd" | "worktree" | undefined {
1209
+ if (isAgent(logic)) return (logic.definition as { workspace?: unknown }).workspace === "none" ? undefined : "cwd";
1210
+ if (!isEffect(logic)) return undefined;
1211
+ const parts = logic.effect;
1212
+ if (parts.kind === "command") return parts.cut ? undefined : "worktree";
1213
+ return parts.kind === "http" && parts.file ? "worktree" : undefined;
1214
+ }
1215
+
1216
+ /** A gid, so an integer: a float or a negative is a pod the API server rejects at admission. */
1217
+ function assertWorkGroup(workGroup: unknown): void {
1218
+ if (workGroup !== undefined && (typeof workGroup !== "number" || !Number.isInteger(workGroup) || workGroup < 0)) {
1219
+ throw new Error(`workspace(): \`workGroup\` must be a gid (got ${JSON.stringify(workGroup)})`);
727
1220
  }
728
1221
  }
729
1222
 
730
1223
  /**
731
1224
  * Resolve every Repo Slot to a Binding, in declaration order (ADR-0051). A bound slot is its
732
1225
  * Binding; a per-run slot is its mapper called over the run input, validated like the static forms
733
- * because it derives from `jr2 run --input` exactly as the spec does; an open slot nobody bound is
1226
+ * because it derives from `jr2 run --input` ; an open slot nobody bound is
734
1227
  * a fault BEFORE the port, naming the `customize` line that fixes it — the run-time twin of the
735
1228
  * refusal `jr2 up`'s walk makes for a registered Machine, reached here only by a Machine that was
736
1229
  * never registered as itself (a test seam, a nested invoke of an unbound import). The Machine is
@@ -770,7 +1263,7 @@ function resolveBindings(
770
1263
  bad.push(`ref (got ${JSON.stringify(binding.ref)})`);
771
1264
  if (bad.length) {
772
1265
  throw new Error(
773
- `workspace spec invalid: repos.${slot} mapper returned ${bad.join("; ")} — the mapper derives from run ` +
1266
+ `workspace invalid: repos.${slot} mapper returned ${bad.join("; ")} — the mapper derives from run ` +
774
1267
  "input; does `jr2 run --input` carry every field this workflow's workspace() slot reads?",
775
1268
  );
776
1269
  }
@@ -788,7 +1281,7 @@ function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot:
788
1281
  }));
789
1282
  }
790
1283
 
791
- function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any }) => WorkspaceSpec): AnyStateMachine {
1284
+ function buildWorkspaceMachine(body: AnyStateMachine): AnyStateMachine {
792
1285
  /**
793
1286
  * `placing` (ADR-0064): write the Sandbox and wait, with no deadline, for its pod to be
794
1287
  * scheduled. jr2 cannot tell "full now" from "never fits", so a bound is the Machine's own
@@ -807,13 +1300,15 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
807
1300
  * delete rides the abort, after any write in flight has settled. A wait that fails on its own —
808
1301
  * the Sandbox Lost before its node, a write that half landed — is the same end: the delete comes
809
1302
  * 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.
1303
+ * not an end: the restored run re-enters `placing` and keeps its place. Nor is a name that a live
1304
+ * Workspace of the run holds when the delete would run: a parent that re-enters the state, or
1305
+ * catches a Failure and invokes the same Machine again, gives its new Workspace the same invoke id,
1306
+ * and so the same name, which that Workspace has placed again by then (ADR-0073).
811
1307
  */
812
1308
  const place = fromPromise<
813
- { bindings: Record<string, ResolvedBinding> },
814
- { wsId: string; spec: WorkspaceSpec; runInput: unknown }
1309
+ { bindings: Record<string, ResolvedBinding>; runInput: Record<string, unknown> },
1310
+ { wsId: string; runInput: Record<string, unknown> }
815
1311
  >(async ({ input, self, system, signal }) => {
816
- assertSpec(input.spec); // before the port: a bad spec must never cost a pod
817
1312
  const binding = runBindingOf(system);
818
1313
  // The images and the slots come off the WRAPPER, at invoke time, not out of context and not
819
1314
  // out of a build-time closure (ADR-0049, ADR-0051). This state re-runs on every restore, so
@@ -822,14 +1317,21 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
822
1317
  // content-addressed tag, which would outlive the image it names. The RESOLVED bindings are
823
1318
  // persisted, because a per-run mapper's answer is this run's fact.
824
1319
  const parts = sandboxPartsOf(invokingMachine(self));
1320
+ const wrapper = self._parent;
1321
+ // RunHost parsed a root run's door and added `instanceId`, so that value is final: a second
1322
+ // parse would refuse the added key under a strict door and transform a value twice. A parent's
1323
+ // input is parsed here, and a door value that is wrong costs no pod (ADR-0033).
1324
+ const door = wrapper?._parent ? inputSchemaOf(invokingMachine(self)!) : undefined;
1325
+ const runInput = door
1326
+ ? parsedNestedDoor(door, input.runInput, ["instanceId"], "workspace input invalid, before any pod")
1327
+ : input.runInput;
825
1328
  const user = userSeatOf(parts.user);
826
1329
  const path = actorSlotPath(self._parent);
827
1330
  // Read before the port: a Workspace no walk can see placed nothing, and is refused with the slot form.
828
1331
  const route = actorRoute(self._parent);
829
- const bindings = resolveBindings({ workflow: binding.workflow, path }, parts.repos, input.runInput);
1332
+ const bindings = resolveBindings({ workflow: binding.workflow, path }, parts.repos, runInput);
830
1333
  const port = sandboxOf(system);
831
1334
  const name = workspaceName(binding.runId, input.wsId);
832
- const wrapper = self._parent;
833
1335
  const child = wrapper ? actorPath(wrapper).join("/") : "";
834
1336
  // A wait opened before a restart is the same wait: its start is in the wrapper's persisted
835
1337
  // context, never in this actor's input, which a restore replays as it was first invoked.
@@ -862,7 +1364,9 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
862
1364
  // work group's default (ADR-0005 puts it in pod composition, where the pod is built).
863
1365
  ...(parts.image !== undefined ? { image: parts.image } : {}),
864
1366
  ...(user.image !== undefined ? { user: user.image } : {}),
865
- ...(input.spec.workGroup !== undefined ? { workGroup: input.spec.workGroup } : {}),
1367
+ ...(parts.workGroup !== undefined ? { workGroup: parts.workGroup } : {}),
1368
+ // The Held secrets the `workspace()` grants its confined children (ADR-0076): names only.
1369
+ ...(parts.secrets?.length ? { secrets: [...parts.secrets] } : {}),
866
1370
  repos: Object.entries(bindings).map(([slot, b]) => ({ slot, ...b })),
867
1371
  // The Size as the Machine states it, re-read here like the images (ADR-0060): a redeploy
868
1372
  // that retuned it reaches the next provision, and no snapshot holds it.
@@ -879,7 +1383,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
879
1383
  if (binding.hostStopping) return;
880
1384
  void placed
881
1385
  .catch(() => {})
882
- .then(() => port.destroy(name))
1386
+ .then(() => (binding.heldSandboxes?.().has(name) ? undefined : port.destroy(name)))
883
1387
  .catch(() => {}); // a delete that fails leaves the CR to the idle GC, as ever
884
1388
  },
885
1389
  { once: true },
@@ -900,7 +1404,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
900
1404
  openedAt !== undefined ? Math.round(performance.now() - openedAt) : Math.max(0, Date.now() - Date.parse(since));
901
1405
  binding.marker?.({ kind: "placed", child, since, after });
902
1406
  }
903
- return { bindings };
1407
+ return { bindings, runInput };
904
1408
  });
905
1409
 
906
1410
  /** `provisioning`: the placed pod's Ready, under the port's budget, which starts here (ADR-0064). */
@@ -909,9 +1413,12 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
909
1413
  );
910
1414
 
911
1415
  const attach = fromPromise<
912
- { repos: Record<string, string>; review?: Record<string, string>; stale?: Record<string, string> },
913
- { wsId: string; spec: WorkspaceSpec; bindings: Record<string, ResolvedBinding> }
1416
+ { repos: Record<string, string>; bases?: Record<string, string>; stale?: Record<string, string> },
1417
+ { wsId: string; bindings: Record<string, ResolvedBinding> }
914
1418
  >(async ({ input, system, signal }) => {
1419
+ // No Repo Slot, nothing to clone (ADR-0051): the Harness's attach would refuse an empty list,
1420
+ // and `/work` needs nothing from it — the pod's `fsGroup` already makes it the work group's.
1421
+ if (Object.keys(input.bindings).length === 0) return { repos: {} };
915
1422
  const port = sandboxOf(system);
916
1423
  const name = workspaceName(runBindingOf(system).runId, input.wsId);
917
1424
  // The attach is loss-aware (ADR-0021): a Sandbox gone or Lost under it fails the attach at
@@ -930,7 +1437,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
930
1437
  signal.addEventListener("abort", () => unsubscribe(), { once: true });
931
1438
  let out: Awaited<ReturnType<typeof port.attach>>;
932
1439
  try {
933
- out = await Promise.race([port.attach({ name, spec: input.spec, repos: attachedRepos(input.bindings) }), loss]);
1440
+ out = await Promise.race([port.attach({ name, repos: attachedRepos(input.bindings) }), loss]);
934
1441
  } finally {
935
1442
  unsubscribe();
936
1443
  }
@@ -1049,17 +1556,17 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1049
1556
 
1050
1557
  // Every actor this wrapper runs is a NAMED SLOT (ADR-0049), the body first among them: a Machine
1051
1558
  // 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".
1559
+ // the Console's join key and the `jr2 up` parts walk all reach it by. The mechanism's own seven —
1560
+ // place, provision, attach, registrar, lease, destroy, fail — are named for the same price, and the
1561
+ // Console shows what each state is doing instead of "inline". `fail` is the unbranded logic: it
1562
+ // sends the body's Failure up again, and the walk does not list it as a state that fails the run.
1055
1563
  return setup({
1056
- actors: { body, place, provision, attach, registrar, lease, destroy },
1564
+ actors: { body, place, provision, attach, registrar, lease, destroy, fail: failLogic },
1057
1565
  }).createMachine({
1058
1566
  id: "workspace",
1059
1567
  context: ({ input, self }: { input: unknown; self: { id: string } }): WsContext => ({
1060
1568
  runInput: (input ?? {}) as Record<string, unknown>,
1061
1569
  wsId: self.id,
1062
- spec: spec({ input }),
1063
1570
  }),
1064
1571
  initial: "placing",
1065
1572
  states: {
@@ -1070,7 +1577,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1070
1577
  src: "place",
1071
1578
  input: ({ context }) => ({
1072
1579
  wsId: (context as unknown as WsContext).wsId,
1073
- spec: (context as unknown as WsContext).spec,
1074
1580
  runInput: (context as unknown as WsContext).runInput,
1075
1581
  }),
1076
1582
  onDone: {
@@ -1078,6 +1584,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1078
1584
  actions: assign({
1079
1585
  bindings: ({ event }) =>
1080
1586
  (event as unknown as { output: { bindings: Record<string, ResolvedBinding> } }).output.bindings,
1587
+ runInput: ({ event }) =>
1588
+ (event as unknown as { output: { runInput: Record<string, unknown> } }).output.runInput,
1081
1589
  wait: undefined,
1082
1590
  }),
1083
1591
  },
@@ -1115,7 +1623,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1115
1623
  src: "attach",
1116
1624
  input: ({ context }) => ({
1117
1625
  wsId: (context as unknown as WsContext).wsId,
1118
- spec: (context as unknown as WsContext).spec,
1119
1626
  bindings: (context as unknown as WsContext).bindings!,
1120
1627
  }),
1121
1628
  onDone: {
@@ -1125,7 +1632,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1125
1632
  const ctx = context as WsContext;
1126
1633
  const out = (
1127
1634
  event as unknown as {
1128
- output: { repos: Record<string, string>; review?: Record<string, string> };
1635
+ output: { repos: Record<string, string>; bases?: Record<string, string> };
1129
1636
  }
1130
1637
  ).output;
1131
1638
  return {
@@ -1141,8 +1648,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1141
1648
  ...(ctx.bindings
1142
1649
  ? { bindings: Object.fromEntries(Object.entries(ctx.bindings).map(([s, b]) => [s, b.url])) }
1143
1650
  : {}),
1144
- branch: ctx.spec.branch,
1145
- ...(out.review ? { review: out.review } : {}),
1651
+ // Each slot's base: what the attach put `default/` on, sent with every cut.
1652
+ ...(out.bases ? { bases: out.bases } : {}),
1146
1653
  };
1147
1654
  },
1148
1655
  }),
@@ -1166,17 +1673,24 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1166
1673
  src: "body",
1167
1674
  input: ({ context }: { context: WsContext }) => {
1168
1675
  const ctx = context;
1169
- 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
- };
1676
+ const { repos } = ctx.handles!;
1677
+ // Body-facing subset only (ADR-0016): endpoint/sandbox are mechanism-internal. With
1678
+ // no Repo Slot the handles are `{ repos: {} }` (WorkspaceHandles<never>).
1679
+ return { ...ctx.runInput, workspace: { repos } };
1175
1680
  },
1176
1681
  onDone: {
1177
1682
  target: "teardown",
1178
1683
  actions: assign({ output: ({ event }) => (event as unknown as { output: unknown }).output }),
1179
1684
  },
1685
+ // A Failure deletes the Sandbox before it goes up (ADR-0073). Guarded, so a defect
1686
+ // takes no transition and goes up at once, as before: its Sandbox stays as evidence.
1687
+ onError: {
1688
+ guard: ({ event }: { event: { error?: unknown } }) => isFailure(event.error),
1689
+ target: "failing",
1690
+ actions: assign({
1691
+ failure: ({ event }) => failurePayload((event as unknown as { error: Failure }).error),
1692
+ }),
1693
+ },
1180
1694
  },
1181
1695
  leaseInvoke(true),
1182
1696
  ],
@@ -1194,6 +1708,25 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1194
1708
  },
1195
1709
  },
1196
1710
  done: { type: "final" },
1711
+ // The body failed (ADR-0073): the same delete as `teardown`, then the same Failure goes up to
1712
+ // the parent unchanged. No lease here: nothing judges a Sandbox that is being deleted.
1713
+ failing: {
1714
+ initial: "destroying",
1715
+ states: {
1716
+ destroying: {
1717
+ invoke: {
1718
+ src: "destroy",
1719
+ input: ({ context }) => ({ wsId: (context as unknown as WsContext).wsId }),
1720
+ onDone: "raising",
1721
+ // A failed delete is the operator GC's problem, and the run host's (ADR-0012 backstop).
1722
+ onError: "raising",
1723
+ },
1724
+ },
1725
+ raising: {
1726
+ invoke: { src: "fail", input: ({ context }) => (context as unknown as WsContext).failure! },
1727
+ },
1728
+ },
1729
+ },
1197
1730
  },
1198
1731
  // Machine output must be declared at the ROOT in xstate v5 (a final state's own `output`
1199
1732
  // only rides the done event); the workspace's output is the body's, verbatim (ADR-0012).