@jr2/orchestrator 0.5.1 → 0.7.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 (350) hide show
  1. package/console/components/app.ts +55 -15
  2. package/console/components/bottom-drawer.ts +259 -0
  3. package/console/components/drawer.ts +14 -6
  4. package/console/components/fleet.ts +36 -13
  5. package/console/components/machine-pane.ts +61 -5
  6. package/console/components/nav.ts +54 -8
  7. package/console/conversation.ts +161 -0
  8. package/console/login.ts +123 -0
  9. package/console/main.ts +600 -60
  10. package/console/store.ts +611 -64
  11. package/console/style.css +231 -0
  12. package/docs/agents-block.md +2 -2
  13. package/docs/concepts/agents-and-turns.md +142 -29
  14. package/docs/concepts/architecture.md +60 -34
  15. package/docs/concepts/callers-and-hooks.md +205 -0
  16. package/docs/concepts/charts.md +13 -8
  17. package/docs/concepts/composition.md +10 -1
  18. package/docs/concepts/converge.md +7 -2
  19. package/docs/concepts/durable-runs.md +43 -16
  20. package/docs/concepts/effects.md +133 -50
  21. package/docs/concepts/gates.md +95 -25
  22. package/docs/concepts/machines-and-workflows.md +14 -4
  23. package/docs/concepts/pools-and-sources.md +193 -17
  24. package/docs/concepts/secrets-and-trust.md +186 -62
  25. package/docs/concepts/the-menu.md +14 -0
  26. package/docs/concepts/workspaces.md +135 -56
  27. package/docs/get-started/a-model-decides.md +14 -13
  28. package/docs/get-started/an-agent-in-a-sandbox.md +46 -35
  29. package/docs/get-started/connect-a-model.md +1 -1
  30. package/docs/get-started/create-an-instance.md +17 -14
  31. package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
  32. package/docs/get-started/install.md +4 -0
  33. package/docs/guides/add-tools-to-the-sandbox.md +72 -15
  34. package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
  35. package/docs/guides/answer-a-gate.md +74 -37
  36. package/docs/guides/bind-open-parts.md +29 -12
  37. package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
  38. package/docs/guides/cancel-a-run.md +26 -16
  39. package/docs/guides/cut-a-worktree.md +560 -0
  40. package/docs/guides/fix-a-failed-converge.md +19 -7
  41. package/docs/guides/give-a-caller-a-scope.md +203 -0
  42. package/docs/guides/give-an-agent-mcp-tools.md +422 -0
  43. package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
  44. package/docs/guides/let-an-agent-write-a-chart.md +14 -7
  45. package/docs/guides/look-inside-a-sandbox.md +115 -42
  46. package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
  47. package/docs/guides/open-the-console.md +63 -14
  48. package/docs/guides/push-and-sign-a-branch.md +67 -21
  49. package/docs/guides/read-a-conversation.md +225 -0
  50. package/docs/guides/run-a-chart-by-name.md +17 -16
  51. package/docs/guides/run-a-command-or-call-a-url.md +84 -41
  52. package/docs/guides/run-one-worker-per-item.md +34 -27
  53. package/docs/guides/set-an-agents-access-and-dials.md +69 -22
  54. package/docs/guides/size-a-workspace.md +19 -6
  55. package/docs/guides/start-a-run-for-each-alert.md +290 -0
  56. package/docs/guides/start-a-run-from-a-run.md +18 -16
  57. package/docs/guides/start-a-run.md +24 -18
  58. package/docs/guides/steer-an-agent.md +332 -0
  59. package/docs/guides/use-a-hosted-provider.md +7 -1
  60. package/docs/guides/watch-a-run.md +146 -117
  61. package/docs/guides/write-a-hook-kind.md +238 -0
  62. package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
  63. package/docs/guides/write-a-workflow.md +18 -8
  64. package/docs/llms.txt +110 -23
  65. package/docs/reference/authoring-surface/agent.md +35 -12
  66. package/docs/reference/authoring-surface/answer.md +20 -4
  67. package/docs/reference/authoring-surface/arg.md +28 -6
  68. package/docs/reference/authoring-surface/basic.md +1 -1
  69. package/docs/reference/authoring-surface/bearer.md +1 -1
  70. package/docs/reference/authoring-surface/branchname.md +15 -0
  71. package/docs/reference/authoring-surface/carry.md +23 -0
  72. package/docs/reference/authoring-surface/chartschema.md +1 -1
  73. package/docs/reference/authoring-surface/chartschemafor.md +1 -1
  74. package/docs/reference/authoring-surface/command.md +37 -22
  75. package/docs/reference/authoring-surface/customize.md +4 -1
  76. package/docs/reference/authoring-surface/defineconfig.md +1 -1
  77. package/docs/reference/authoring-surface/defineevent.md +1 -1
  78. package/docs/reference/authoring-surface/dispatch.md +5 -4
  79. package/docs/reference/authoring-surface/fail.md +18 -5
  80. package/docs/reference/authoring-surface/file.md +37 -17
  81. package/docs/reference/authoring-surface/frompick.md +1 -1
  82. package/docs/reference/authoring-surface/gate.md +118 -0
  83. package/docs/reference/authoring-surface/group-agents.md +15 -0
  84. package/docs/reference/authoring-surface/group-charts.md +16 -0
  85. package/docs/reference/authoring-surface/group-composition.md +25 -0
  86. package/docs/reference/authoring-surface/group-config.md +17 -0
  87. package/docs/reference/authoring-surface/group-effects.md +48 -0
  88. package/docs/reference/authoring-surface/group-failure.md +13 -0
  89. package/docs/reference/authoring-surface/group-gates.md +16 -0
  90. package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
  91. package/docs/reference/authoring-surface/group-steering.md +12 -0
  92. package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
  93. package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
  94. package/docs/reference/authoring-surface/http.md +109 -9
  95. package/docs/reference/authoring-surface/isfailure.md +1 -1
  96. package/docs/reference/authoring-surface/isready.md +14 -0
  97. package/docs/reference/authoring-surface/jr2setup.md +14 -4
  98. package/docs/reference/authoring-surface/mcp.md +107 -0
  99. package/docs/reference/authoring-surface/open.md +41 -8
  100. package/docs/reference/authoring-surface/openbody.md +1 -1
  101. package/docs/reference/authoring-surface/paramsof.md +23 -5
  102. package/docs/reference/authoring-surface/pool.md +79 -11
  103. package/docs/reference/authoring-surface/push.md +31 -13
  104. package/docs/reference/authoring-surface/readyof.md +21 -0
  105. package/docs/reference/authoring-surface/reread.md +19 -0
  106. package/docs/reference/authoring-surface/secret.md +26 -5
  107. package/docs/reference/authoring-surface/sentby.md +1 -1
  108. package/docs/reference/authoring-surface/skip.md +22 -0
  109. package/docs/reference/authoring-surface/source.md +59 -4
  110. package/docs/reference/authoring-surface/steer.md +93 -0
  111. package/docs/reference/authoring-surface/text.md +1 -1
  112. package/docs/reference/authoring-surface/type-agentdefinition.md +16 -3
  113. package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
  114. package/docs/reference/authoring-surface/type-agentslots.md +4 -1
  115. package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
  116. package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
  117. package/docs/reference/authoring-surface/type-bindactors.md +14 -0
  118. package/docs/reference/authoring-surface/type-binding.md +6 -7
  119. package/docs/reference/authoring-surface/type-caller.md +40 -0
  120. package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
  121. package/docs/reference/authoring-surface/type-carry.md +19 -0
  122. package/docs/reference/authoring-surface/type-chainlink.md +3 -3
  123. package/docs/reference/authoring-surface/type-commandinput.md +4 -3
  124. package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
  125. package/docs/reference/authoring-surface/type-commandoutput.md +23 -5
  126. package/docs/reference/authoring-surface/type-customize.md +20 -3
  127. package/docs/reference/authoring-surface/type-delivered.md +17 -0
  128. package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
  129. package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
  130. package/docs/reference/authoring-surface/type-dispatchoutput.md +9 -2
  131. package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
  132. package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
  133. package/docs/reference/authoring-surface/type-filebody.md +4 -8
  134. package/docs/reference/authoring-surface/type-framepath.md +14 -0
  135. package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
  136. package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
  137. package/docs/reference/authoring-surface/type-gatelook.md +37 -0
  138. package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
  139. package/docs/reference/authoring-surface/type-gatereply.md +19 -0
  140. package/docs/reference/authoring-surface/type-gateslot.md +21 -0
  141. package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
  142. package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
  143. package/docs/reference/authoring-surface/type-gateslots.md +12 -0
  144. package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
  145. package/docs/reference/authoring-surface/type-hostinjectedinput.md +3 -5
  146. package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
  147. package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
  148. package/docs/reference/authoring-surface/type-httpoutput.md +8 -2
  149. package/docs/reference/authoring-surface/type-jr2config.md +37 -2
  150. package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
  151. package/docs/reference/authoring-surface/type-lends.md +5 -1
  152. package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
  153. package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
  154. package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
  155. package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
  156. package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
  157. package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
  158. package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
  159. package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
  160. package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
  161. package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
  162. package/docs/reference/authoring-surface/type-namestart.md +77 -0
  163. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
  164. package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
  165. package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
  166. package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
  167. package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
  168. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
  169. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
  170. package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
  171. package/docs/reference/authoring-surface/type-poolspec.md +11 -6
  172. package/docs/reference/authoring-surface/type-pushinput.md +22 -3
  173. package/docs/reference/authoring-surface/type-pushoutput.md +33 -8
  174. package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
  175. package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
  176. package/docs/reference/authoring-surface/type-reposlot.md +6 -6
  177. package/docs/reference/authoring-surface/type-reread.md +18 -0
  178. package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
  179. package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
  180. package/docs/reference/authoring-surface/type-sender.md +26 -1
  181. package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
  182. package/docs/reference/authoring-surface/type-size.md +1 -1
  183. package/docs/reference/authoring-surface/type-skip.md +19 -0
  184. package/docs/reference/authoring-surface/type-slothandles.md +5 -13
  185. package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
  186. package/docs/reference/authoring-surface/type-sourcein.md +25 -0
  187. package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
  188. package/docs/reference/authoring-surface/type-sourcespec.md +26 -17
  189. package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
  190. package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
  191. package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
  192. package/docs/reference/authoring-surface/type-steerargs.md +20 -0
  193. package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
  194. package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
  195. package/docs/reference/authoring-surface/type-workspaced.md +30 -9
  196. package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
  197. package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
  198. package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
  199. package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
  200. package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
  201. package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
  202. package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
  203. package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
  204. package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
  205. package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
  206. package/docs/reference/authoring-surface/workspace.md +66 -38
  207. package/docs/reference/authoring-surface/worktree.md +71 -0
  208. package/docs/reference/authoring-surface.md +88 -147
  209. package/docs/reference/cli.md +413 -74
  210. package/docs/reference/config.md +248 -6
  211. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
  212. package/docs/reference/decisions/0002-duplex-streaming-actor.md +81 -14
  213. package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
  214. package/docs/reference/decisions/0005-sandbox-pod-composition.md +39 -25
  215. package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
  216. package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
  217. package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
  218. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
  219. package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
  220. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +39 -32
  221. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +54 -30
  222. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
  223. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +26 -16
  224. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
  225. package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -10
  226. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
  227. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +53 -27
  228. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +16 -2
  229. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
  230. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +124 -43
  231. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +36 -38
  232. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
  233. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +16 -7
  234. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +36 -19
  235. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
  236. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
  237. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -15
  238. package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
  239. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +22 -22
  240. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
  241. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +13 -12
  242. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +23 -23
  243. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
  244. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
  245. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
  246. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
  247. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
  248. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
  249. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +31 -24
  250. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +18 -10
  251. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +137 -86
  252. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +165 -91
  253. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +7 -7
  254. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
  255. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
  256. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +53 -23
  257. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +221 -153
  258. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +161 -96
  259. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
  260. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +48 -33
  261. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
  262. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
  263. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
  264. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +296 -0
  265. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +347 -0
  266. package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
  267. package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
  268. package/docs/reference/decisions/0079-an-agent-holds-mcp-tools-a-machine-calls-them-as-an-effect-and-the-machine-is-the-approval.md +198 -0
  269. package/docs/reference/decisions.md +9 -4
  270. package/docs/reference/glossary.md +138 -45
  271. package/docs/reference/hooks.md +1077 -0
  272. package/docs/reference/http-api.md +988 -123
  273. package/docs/reference/machines.md +0 -1
  274. package/docs/why.md +7 -5
  275. package/package.json +10 -3
  276. package/src/actor.ts +194 -42
  277. package/src/agent.ts +56 -6
  278. package/src/ambient.ts +3 -5
  279. package/src/assembly-checks.ts +530 -4
  280. package/src/callers.ts +245 -0
  281. package/src/chart.ts +238 -26
  282. package/src/charts.ts +9 -2
  283. package/src/config.ts +566 -7
  284. package/src/conversations.ts +453 -0
  285. package/src/custodian.lua +17 -5
  286. package/src/custodian.ts +257 -77
  287. package/src/customize.ts +154 -7
  288. package/src/dispatch-effect.ts +142 -26
  289. package/src/durability.ts +2 -0
  290. package/src/duration.ts +20 -0
  291. package/src/effect-checks.ts +52 -4
  292. package/src/effect.ts +401 -132
  293. package/src/effector-wire.ts +278 -30
  294. package/src/fail.ts +17 -4
  295. package/src/gate-brand.ts +16 -0
  296. package/src/gate.ts +634 -48
  297. package/src/harness-client.ts +84 -35
  298. package/src/harness-env.ts +33 -0
  299. package/src/held-secrets.ts +197 -39
  300. package/src/holds.ts +5 -2
  301. package/src/hook-kinds/discord.ts +92 -0
  302. package/src/hook-kinds/door.ts +107 -0
  303. package/src/hook-kinds/github.ts +89 -0
  304. package/src/hook-kinds/gitlab.ts +72 -0
  305. package/src/hook-kinds/hook.ts +7 -0
  306. package/src/hook-kinds/index.ts +465 -0
  307. package/src/hook-kinds/slack.ts +151 -0
  308. package/src/hook-kinds/webhook.ts +113 -0
  309. package/src/hooks.ts +506 -0
  310. package/src/http-effect.ts +164 -70
  311. package/src/http.ts +933 -99
  312. package/src/index.ts +30 -5
  313. package/src/instance.ts +196 -26
  314. package/src/internal.ts +23 -3
  315. package/src/kube-client.ts +6 -3
  316. package/src/machine-doc.ts +29 -5
  317. package/src/mcp-effect.ts +424 -0
  318. package/src/model-keys.ts +24 -0
  319. package/src/mount-digest.ts +41 -7
  320. package/src/names.ts +82 -7
  321. package/src/notices.ts +576 -59
  322. package/src/open-body.ts +110 -30
  323. package/src/open-effect.ts +17 -4
  324. package/src/open.ts +40 -7
  325. package/src/parts.ts +125 -35
  326. package/src/party.ts +35 -1
  327. package/src/pick.ts +43 -9
  328. package/src/pool.ts +1172 -172
  329. package/src/registration.ts +249 -17
  330. package/src/repo-identity.ts +7 -3
  331. package/src/repos.ts +20 -2
  332. package/src/run-host.ts +1614 -190
  333. package/src/run-list.ts +191 -0
  334. package/src/sandbox-effect.ts +217 -66
  335. package/src/sandbox-kube.ts +116 -21
  336. package/src/secret-placement.ts +248 -35
  337. package/src/server.ts +53 -4
  338. package/src/setup.ts +158 -38
  339. package/src/snapshot-store.ts +565 -48
  340. package/src/steer.ts +111 -0
  341. package/src/stub-harness.ts +6 -2
  342. package/src/tokens.ts +362 -46
  343. package/src/vocabulary.ts +23 -4
  344. package/src/wire.ts +27 -10
  345. package/src/workspace.ts +314 -259
  346. package/src/worktree.ts +249 -0
  347. package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
  348. package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
  349. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
  350. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +0 -65
package/src/workspace.ts CHANGED
@@ -1,8 +1,8 @@
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
4
- // per Repo Slot, run the author's body Machine inside it as the named slot `body`, with
5
- // `{ workspace: { repos, branch } }` appended to its input (the mechanism-facing endpoint/sandbox
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
6
  // are published ambiently — ADR-0016, ambient.ts), and destroy the Sandbox when the body reaches a
7
7
  // final state or fails (ADR-0073). Teardown lives INSIDE the
8
8
  // wrapper's own states because an xstate stop is synchronous — multi-step async cleanup must be
@@ -44,6 +44,8 @@ import {
44
44
  } from "xstate";
45
45
  import type { z } from "zod";
46
46
  import { registerAmbientHandles, type AmbientHandles } from "./ambient.ts";
47
+ import { isAgent } from "./agent.ts";
48
+ import { isEffect } from "./effect.ts";
47
49
  import { failLogic, failurePayload, isFailure, type Failure, type FailureInput } from "./fail.ts";
48
50
  import { refuseUnroutedLost } from "./mechanism-routes.ts";
49
51
  import {
@@ -68,7 +70,14 @@ import {
68
70
  } from "./parts.ts";
69
71
  import { actorPath, runBindingOf, type AnyActorSystem } from "./registration.ts";
70
72
  import { assertSize, assertUserSeat, userSeatOf, type Size, type UserContainer } from "./size.ts";
71
- import { attachInputSchema, declaresRunInput, 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";
72
81
 
73
82
  /** Lease cadence when the backend names none. Well inside the 30m default idle timeout, so a
74
83
  * few missed renewals in a row are survivable. It bounds only how long an orphan waits to be
@@ -90,47 +99,6 @@ export function leaseDelay(interval: number, which: "first" | "next", random: nu
90
99
  return interval * (1 - LEASE_JITTER + 2 * LEASE_JITTER * random);
91
100
  }
92
101
 
93
- // What to attach, in workspace vocabulary only (ADR-0012 boundary): the one branch the body works
94
- // on, the pod's work group, and the review sha. Derived PER RUN from the wrapper's input, which is
95
- // what keeps it out here rather than in the options — and which is exactly why the two IMAGES and
96
- // the REPOS are NOT here (ADR-0049, ADR-0051): `jr2 up` must find them by walking the Machine, and
97
- // no walk can evaluate a function of run input. They are static `workspace()` options instead; a
98
- // Repo that IS a function of run input is a per-run slot, a mapper the walk can see the shape of
99
- // even though it cannot see the url.
100
- /** What the `spec` of a `workspace()` returns: the branch that the Workspace works on, and two
101
- * optional fields. jr2 calls `spec` with the run input when the run enters the Workspace. */
102
- export type WorkspaceSpec = {
103
- /** The branch that the Workspace works on: each Repo Slot gets a Worktree for it, at
104
- * `/work/<slot>/<branch>`, with each `/` in the branch written as `-`. Required, and not
105
- * `default`, which is the directory of the clone. Your Machine decides its name. */
106
- branch: string;
107
- // The pod's work group (ADR-0005): `fsGroup`. The two writing seats may run different uids —
108
- // each image's own `USER` decides — and POSIX would then make the other seat's files read-only;
109
- // fsGroup (group ownership) plus the default ACL the attach stamps on each repo root (group
110
- // writability, umask-proof) closes that, both inert when the uids already match. The override
111
- // exists for the image whose sessions already hold a gid of their own — a root sshd's logins
112
- // rebuild groups from `/etc/group`, so pointing the work group at one they have costs no
113
- // rebuild. Never a config key — pod composition is the spec's business.
114
- /** The group id that owns the files in `/work`. With it, the Sandbox Image and the User
115
- * Container can both write those files when they run as different users. Default: 2000. Set it
116
- * to a group id that the User Container's sessions already have, for example when a root sshd
117
- * builds their groups from `/etc/group`. A non-negative integer. */
118
- workGroup?: number;
119
- // `<branchDir>-review`, a sibling of the branch worktree, forced to exactly this sha on every
120
- // attach (ADR-0028). Creation-time seat only; the per-round refresh verb (the sha moves between
121
- // review rounds) is a later, workflow-driven change.
122
- /** A commit to check out in a second, detached Worktree beside each branch Worktree, for a
123
- * reviewer: `/work/<slot>/<branch>-review`. The body gets its paths as `workspace.review`. */
124
- reviewSha?: string;
125
- };
126
-
127
- // A Workspace with no Repo Slot (ADR-0051) attaches nothing, so a branch and a review sha name
128
- // nothing: `never` refuses them where the spec is written, and `assertSpec` refuses them at run
129
- // time for the caller the types do not see.
130
- /** What the `spec` of a `workspace()` with no Repo Slot returns: only `workGroup`. There is no
131
- * branch and no review Worktree, because the Workspace has no Repo Slot. */
132
- export type NoSlotSpec = Pick<WorkspaceSpec, "workGroup"> & { branch?: never; reviewSha?: never };
133
-
134
102
  // What the workspace hands the BODY (ADR-0012, ADR-0016): worktree geography only, keyed by Repo
135
103
  // Slot (ADR-0051) — so a path a prompt names is right in every Instance that consumes the Machine.
136
104
  // `endpoint` and `sandbox` are mechanism-internal — the Agent actor resolves them ambiently from
@@ -145,14 +113,13 @@ export type NoSlotSpec = Pick<WorkspaceSpec, "workGroup"> & { branch?: never; re
145
113
  // body under a wrapper that declares its map Open (`repos: open`, ADR-0051), where the slots are
146
114
  // the composer's words and no body could name them.
147
115
  /**
148
- * What the body of a `workspace()` gets as `input.workspace`: the path of each Worktree, and the
149
- * branch. A path is correct in each Instance that registers the Machine.
116
+ * 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.
150
118
  *
151
119
  * `TSlots` is the names of the Repo Slots that the body reads. `workspace()` refuses a body that
152
120
  * names a slot that the Workspace does not have. Write `string` for a body that names no slot and
153
- * reads the Worktrees in the order of `repos`, as under `repos: open`. Write `never` for a
154
- * Workspace with no Repo Slot: its handles are `{ repos: {} }`, with no branch, and its Agents work
155
- * in `/work`.
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`.
156
123
  */
157
124
  // `[TSlots] extends [never]`, not `TSlots extends never`: a bare `never` would distribute to `never`.
158
125
  export type WorkspaceHandles<TSlots extends string> = [TSlots] extends [never]
@@ -161,20 +128,10 @@ export type WorkspaceHandles<TSlots extends string> = [TSlots] extends [never]
161
128
 
162
129
  /** The handles of a Workspace that declares Repo Slots. @internal */
163
130
  type SlotHandles<TSlots extends string> = {
164
- // The kit gives no slot a privileged meaning — there is no `workdir` — because which tree an
165
- // Agent works in is a fact about that Agent's Turn, not about the Workspace: a body that names
166
- // its slots frames each Agent with its own, and a body that names none may give the order a
167
- // meaning of its own (`task`: the first is the one the coder edits). The order is the kit's
168
- // promise; the meaning is the Machine's (ADR-0051).
169
- /** The absolute path of each Repo Slot's Worktree, `/work/<slot>/<branch>`, in the order that
170
- * `repos` declares the slots. Give one to an Agent's Turn as its `cwd`. */
171
- repos: Record<TSlots, string>;
172
- /** The branch that the Workspace works on: the `branch` of the spec. */
173
- branch: string;
174
- // The review seat (ADR-0028).
175
- /** The path of each Repo Slot's review Worktree. Present only when the spec has `reviewSha`.
176
- * Give one to a reviewer's Turn as its `cwd`. */
177
- review?: Partial<Record<TSlots, 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}` };
178
135
  };
179
136
 
180
137
  // The composition is the whole reason the door is declared on the wrapper and not on the body
@@ -190,7 +147,7 @@ type SlotHandles<TSlots extends string> = {
190
147
  // the composition the WRAPPER makes.
191
148
  /**
192
149
  * The input of a `workspace()` body: the run input, plus `workspace`, the handles that the
193
- * Workspace adds (`workspace.repos`, `workspace.branch`, `workspace.review`). Use it as the type of
150
+ * Workspace adds (`workspace.repos`, the folder of each Repo Slot). Use it as the type of
194
151
  * the body's input.
195
152
  *
196
153
  * The first argument is the run input: the `input` schema of the `workspace()`. The second is the
@@ -207,16 +164,29 @@ type SlotHandles<TSlots extends string> = {
207
164
  * @example
208
165
  * ```ts
209
166
  * import { z } from "zod";
210
- * import { agent, defineEvent, fail, jr2Setup, workspace, type Workspaced } from "@jr2/orchestrator";
167
+ * import {
168
+ * agent,
169
+ * defineEvent,
170
+ * fail,
171
+ * isReady,
172
+ * jr2Setup,
173
+ * workspace,
174
+ * worktree,
175
+ * type Workspaced,
176
+ * } from "@jr2/orchestrator";
211
177
  *
212
178
  * const done = defineEvent({
213
179
  * name: "done",
214
180
  * description: "Call this when REVIEW.md is written.",
215
181
  * input: z.object({}),
216
182
  * });
217
- * const runInput = z.object({ branch: z.string(), sha: z.string() });
183
+ * const runInput = z.object({ sha: z.string() });
218
184
  * type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
219
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
+ *
220
190
  * const body = jr2Setup({
221
191
  * types: {} as { input: BodyInput; context: BodyInput },
222
192
  * events: [done],
@@ -226,25 +196,34 @@ type SlotHandles<TSlots extends string> = {
226
196
  * instructions: "You review one commit and write REVIEW.md.",
227
197
  * workspace: "read",
228
198
  * }),
199
+ * cut: worktree(),
229
200
  * fail: fail(),
230
201
  * },
231
202
  * }).createMachine({
232
203
  * id: "review",
233
204
  * context: ({ input }) => input,
234
205
  * on: { "workspace.lost": ".lost", "agent.fault": ".failed" },
235
- * initial: "reviewing",
206
+ * initial: "cutting",
236
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
+ * },
237
215
  * reviewing: {
238
216
  * invoke: {
239
217
  * src: "reviewer",
240
218
  * input: ({ context }) => ({
241
- * prompt: `Review commit ${context.sha} on ${context.workspace.branch}.`,
242
- * cwd: context.workspace.review?.app ?? context.workspace.repos.app,
219
+ * prompt: `Review commit ${context.sha}.`,
220
+ * cwd: reviewTree(context),
243
221
  * }),
244
222
  * },
245
223
  * on: { done: "finished" },
246
224
  * },
247
225
  * finished: { type: "final" },
226
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
248
227
  * failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
249
228
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
250
229
  * },
@@ -253,7 +232,6 @@ type SlotHandles<TSlots extends string> = {
253
232
  * export const machine = workspace(body, {
254
233
  * input: runInput,
255
234
  * repos: { app: "https://github.com/you/app.git" },
256
- * spec: ({ input }) => ({ branch: input.branch, reviewSha: input.sha }),
257
235
  * });
258
236
  * ```
259
237
  *
@@ -317,12 +295,18 @@ export type PlaceRequest = {
317
295
  name: string;
318
296
  runId: string;
319
297
  workflow: string;
298
+ /** The Workspace's path in the run's Machine: the wrapper's actor ids joined by `/` (ADR-0012).
299
+ * The port writes it on the CR, where the `jr2 workspace` verbs read it (ADR-0009). */
300
+ path: string;
320
301
  /** The placement route from the registered Machine to this `workspace()` (`actorRoute`). With
321
302
  * `workflow`, it names the pod whose Held secrets and Sandbox Effector `jr2 up` placed (ADR-0059). */
322
303
  route: PlacementRoute;
323
304
  image?: string;
324
305
  user?: string;
325
306
  workGroup?: number;
307
+ /** The Held secrets the `workspace()`'s `secrets` list names (ADR-0059): the ones the Harness may
308
+ * put in a confined child's env, by Stand-in (ADR-0076). Names, never a value. Absent → none. */
309
+ secrets?: string[];
326
310
  repos: ProvisionedRepo[];
327
311
  resources?: Size;
328
312
  userResources?: Size;
@@ -346,9 +330,10 @@ export type ProvisionedRepo = { slot: string; url: string; ref?: string; perRun:
346
330
  */
347
331
  export interface SandboxPort {
348
332
  /**
349
- * Write the Sandbox (its token Secret, then the CR, labeled with its run for `jr2 ls`) and wait,
350
- * with NO deadline, until the operator says its pod is scheduled (ADR-0064). `image`/`user` are
351
- * the wrapper's static image options (ADR-0037/0005/0049) — a `file:` context or a registry ref,
333
+ * Write the Sandbox (its token Secret, then the CR, labeled with its run, its Workflow and its
334
+ * path for `jr2 workspace ls`) and wait, with NO deadline, until the operator says its pod is
335
+ * scheduled (ADR-0064). `image`/`user` are the wrapper's static image options
336
+ * (ADR-0037/0005/0049) — a `file:` context or a registry ref,
352
337
  * the port resolves both, and a context the last converge did not build fails here rather than
353
338
  * converge-time. `repos` are the wrapper's slots, resolved, in declaration order (ADR-0051): the
354
339
  * port names each Repo on the CR so the cluster mounts its cache, and refuses a per-run url no
@@ -370,17 +355,12 @@ export interface SandboxPort {
370
355
  */
371
356
  provision(name: string): Promise<{ endpoint: string }>;
372
357
  /** Post-Ready attach (ADR-0004): per slot, `git clone --shared` off the node's read-only
373
- * cache (the `default/` checkout), then a branch worktree sibling — and, with `spec.reviewSha`, the detached
374
- * review worktree (ADR-0028). Resolves with the worktree paths by slot; `stale` names the slots
375
- * whose cache could not be fetched before this attach, with git's own error (ADR-0051: freshness
376
- * degrades, absence does not). */
377
- attach(req: {
378
- name: string;
379
- spec: WorkspaceSpec;
380
- repos: Array<{ slot: string; url: string; ref?: string }>;
381
- }): Promise<{
358
+ * cache (the `default/` checkout). A Machine cuts each Worktree (ADR-0075). Resolves with the
359
+ * folder of each slot; `stale` names the slots whose cache could not be fetched before this
360
+ * attach, with git's own error (ADR-0051: freshness degrades, absence does not). */
361
+ attach(req: { name: string; repos: Array<{ slot: string; url: string; ref?: string }> }): Promise<{
382
362
  repos: Record<string, string>;
383
- review?: Record<string, string>;
363
+ bases?: Record<string, string>;
384
364
  stale?: Record<string, string>;
385
365
  }>;
386
366
  /**
@@ -391,6 +371,20 @@ export interface SandboxPort {
391
371
  * the next renewal is minutes away and the idle timeout is thirty.
392
372
  */
393
373
  renew(name: string): Promise<void>;
374
+ /**
375
+ * Mark the Sandbox as held by no run (ADR-0021): the Lease stopped for the body's final state, a
376
+ * fault, a cancel or a parent that left the Workspace — never for a park or a process shutdown.
377
+ * `jr2 workspace rm` reads the mark with the cluster alone (ADR-0009). A write, and only that; a
378
+ * failure rejects and the caller shrugs.
379
+ */
380
+ releaseHold(name: string): Promise<void>;
381
+ /**
382
+ * Mark every Sandbox of a run as held by no run (ADR-0021), found by its `jr2.dev/run` label: a
383
+ * restore ended the run `drifted` or `lost` without hosting it, so no Lease runs to release them.
384
+ * A write for each, and only that; a failure rejects and the caller shrugs. Optional: a port
385
+ * with no cluster has nothing to mark.
386
+ */
387
+ releaseRun?(runId: string): Promise<void>;
394
388
  /**
395
389
  * Hear this workspace's Continuity from the watch (ADR-0021, ADR-0063): once the watch has
396
390
  * listed, what it holds now, then again on every change the cluster reports — a Sandbox gone, or
@@ -483,12 +477,11 @@ export type WorkspaceWait = PlacingWait & { since: string };
483
477
  export const WORKSPACE_WAIT_KEY = "wait";
484
478
 
485
479
  type WsContext = {
486
- /** The wrapper's own input, passed through to the body untouched (plus `workspace`). */
480
+ /** The wrapper's input, which the body gets (plus `workspace`). Under a parent, `place` replaces
481
+ * it with the door's parsed value. */
487
482
  runInput: Record<string, unknown>;
488
483
  /** This invocation's stable identity (the actor id the parent invoked/spawned us under). */
489
484
  wsId: string;
490
- /** The resolved spec — computed once from input and persisted, like any other context data. */
491
- spec: AnySpec;
492
485
  /** Every slot's resolved Binding, by slot, in declaration order — assigned at provision. */
493
486
  bindings?: Record<string, ResolvedBinding>;
494
487
  endpoint?: string;
@@ -581,6 +574,20 @@ export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSl
581
574
  // satisfies a body that writes that type itself (`z.infer<typeof door>`, alone or with the
582
575
  // handles). The raw door keeps those bodies legal, and the stripped form keeps a body that
583
576
  // declares `Workspaced<…>` legal. A key that the door does not carry is still refused.
577
+ /** The input of a door-less Workspace: the body's, without the handles. `unknown` when the body
578
+ * asks for nothing else, and with `undefined` added when each field it asks for is optional. In
579
+ * both cases a parent has nothing that it must give, and xstate makes an invoke's `input` optional
580
+ * only for a type that holds `undefined`.
581
+ * @internal */
582
+ type DoorlessInput<TBody extends AnyStateMachine> =
583
+ unknown extends InputFrom<TBody>
584
+ ? unknown
585
+ : [keyof Omit<InputFrom<TBody>, "workspace">] extends [never]
586
+ ? unknown
587
+ : {} extends Omit<InputFrom<TBody>, "workspace">
588
+ ? Omit<InputFrom<TBody>, "workspace"> | undefined
589
+ : Omit<InputFrom<TBody>, "workspace">;
590
+
584
591
  /** @internal */
585
592
  type BodyAcceptsDoor<TBody extends AnyStateMachine, TDoor, TSlots extends string> =
586
593
  TDoor & Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
@@ -625,10 +632,10 @@ type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
625
632
  : unknown;
626
633
 
627
634
  // What the Sandbox is MADE OF (ADR-0037, ADR-0005) and which Repos it attaches (ADR-0051), as
628
- // STATIC options on the wrapper rather than fields of the per-run spec (ADR-0049). Static is the
635
+ // STATIC options on the wrapper rather than a function of the run input (ADR-0049). Static is the
629
636
  // whole point: `jr2 up` walks the registered Machines to find every `file:` context and build it,
630
- // every bound Repo and warm it, every open slot and refuse it (parts.ts) — and a spec is a function
631
- // of run input that no walk can evaluate. They are also never persisted — the provisioning state
637
+ // every bound Repo and warm it, every open slot and refuse it (parts.ts) — and no walk can evaluate a
638
+ // function of run input. They are also never persisted — the provisioning state
632
639
  // re-reads them off the Machine it was invoked as, so a restore, a `provide()` and a `customize()`
633
640
  // all get the image and the slots the Machine carries NOW.
634
641
  //
@@ -649,6 +656,9 @@ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
649
656
  * glibc and git; jr2 adds its own runtime at `/opt/jr2`. Default: the Instance's
650
657
  * `images/default`, then jr2's Harness image. */
651
658
  image?: string;
659
+ // A gid is a fact of the images, so it is static.
660
+ /** The group id that owns the files in `/work`. Default: 2000. A non-negative integer. */
661
+ workGroup?: number;
652
662
  // The widening ADR-0005 foresaw: `{ image, resources }` states the seat's split of the Size
653
663
  // (ADR-0060). There is no default, because the seat's whole identity is "what jr2 does not own"
654
664
  // and jr2 has nothing to put there. Env and ports are deliberately not forwarded.
@@ -680,8 +690,8 @@ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
680
690
  * choice. The name is the key of `workspace.repos` in the body and the directory under `/work`.
681
691
  * At least one slot. Leave `repos` out for a Workspace with no Repo Slot (see
682
692
  * {@link NoSlotWorkspaceOptions}). Each value is one of:
683
- * - a url, or `{ url, ref }`, where `ref` is the branch or commit that the Worktree starts from
684
- * (default: the Repo's default branch);
693
+ * - a url, or `{ url, ref }`, where `ref` is the branch, the tag or the commit that the clone
694
+ * stays on, and that a new branch starts from (default: the Repo's default branch);
685
695
  * - `open`, for the Instance that registers the Machine to bind with `customize()`;
686
696
  * - a function of the run input, `({ input }) => url` or `{ url, ref }`, when each run chooses.
687
697
  *
@@ -695,8 +705,11 @@ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
695
705
  /**
696
706
  * The Held secrets that this Sandbox can use: names from the `secrets` list in `jr2.config.ts`.
697
707
  * The Sandbox's Custodian holds each value and puts it into requests to the hosts of that Held
698
- * secret. The Agent sees only a Stand-in. You need not list the model key of an Agent that runs
699
- * here: its provider claims it. Each name once.
708
+ * secret. Listing a name also gives its Stand-in to the Agent's programs — each `bash` call, test,
709
+ * build and `command` — in their env under that name, so code in `/work` can spend it (ADR-0076).
710
+ * You need not list the model key of an Agent that runs here: its provider claims it, and the
711
+ * Harness gives it to the model calls alone. List the model key only when code in `/work` must
712
+ * call the model itself: such a call is outside the Turn's usage record. Each name once.
700
713
  */
701
714
  secrets?: readonly string[];
702
715
  // The container exists only when an Effect below this `workspace()` is placed there (ADR-0068,
@@ -725,47 +738,27 @@ export type WorkspaceOptions<TSchema extends z.ZodObject, TSlots extends string>
725
738
  // `workspace` handles ({@link Workspaced}), which no caller can send, so the body's contract is
726
739
  // the door plus something that does not exist yet. Symmetric with `PoolSpec.input`.
727
740
  /** The input that starts a run, as a `z.object`. jr2 checks each start against it. It also types
728
- * the `input` of `spec` and of a per-run Repo Slot, and the body must accept it plus the
741
+ * the `input` of a per-run Repo Slot, and the body must accept it plus the
729
742
  * `workspace` handles. Write it here, not on the body. */
730
743
  input: TSchema;
731
- // ADR-0012's boundary: workflow configuration never enters the spec.
732
- /**
733
- * A function from the run input to the Workspace's {@link WorkspaceSpec}: the `branch` it works
734
- * on, and optionally `reviewSha` and `workGroup`. jr2 calls it when the run enters the
735
- * Workspace.
736
- *
737
- * The input of `spec` also holds `instanceId`, the seed Instance ID of the run (see
738
- * {@link HostInjectedInput}). Use it to give each run a branch of its own:
739
- * ``spec: ({ input }) => ({ branch: `fix/${input.instanceId}` })``. Only a root `workspace()` gets
740
- * it from jr2. A `workspace()` that a different Machine invokes gets only the input that its
741
- * parent gives it, so that parent must give `instanceId` when `spec` reads it.
742
- */
743
- // The type says `instanceId` is present also below the root, where only the parent can supply it:
744
- // the same permissive answer the body guard gives (BodyAcceptsDoor), because no type can see where
745
- // the wrapper sits. The alternative, `Partial<HostInjectedInput>`, makes each root `spec` that reads
746
- // it write a fallback for a value that is always there.
747
- spec: (args: { input: WithoutEmptyIndex<z.infer<TSchema>> & HostInjectedInput }) => WorkspaceSpec;
748
744
  };
749
745
 
750
746
  // How a Workspace with NO declared door is configured: absence is permissive (ADR-0033), so jr2
751
747
  // has nothing to infer from and says `unknown` rather than `any` — an honest "jr2 does not know",
752
748
  // which the mapper must narrow before it reads a field.
753
749
  /**
754
- * The options of a `workspace()` with no `input`: it starts with any input. The `input` of `spec`
755
- * is then `unknown`, and `spec` must check it before it reads a field.
750
+ * The options of a `workspace()` with no `input`: it starts with any input. The `input` of a
751
+ * per-run Repo Slot is then `unknown`, and the mapper must check it before it reads a field.
756
752
  *
757
- * Use it for a Workspace that a Pool or another Machine starts. Give the parameter of `spec` a
758
- * type to say what that input is: `spec: ({ input }: { input: Item }) => …`. That also types the
759
- * input of the Workspace. For a Workspace that a caller starts, declare `input`.
753
+ * Use it for a Workspace that a Pool or another Machine starts. The body's `input` type says what
754
+ * that input is, and that also types the input of the Workspace. For a Workspace that a caller
755
+ * starts, declare `input`.
760
756
  */
761
757
  export type PermissiveWorkspaceOptions<TSlots extends string, TInput = unknown> = SandboxOptions<TSlots, TInput> & {
762
758
  // Spelled out so a declared schema can never fall through to the permissive overload, where the
763
759
  // body's door would go unchecked.
764
760
  /** Not present: a `workspace()` without `input` starts with any input. */
765
761
  input?: never;
766
- /** A function from the input to the Workspace's {@link WorkspaceSpec}. Its `input` is
767
- * `unknown` unless you give the parameter a type: `spec: ({ input }: { input: Item }) => …`. */
768
- spec: (args: { input: TInput }) => WorkspaceSpec;
769
762
  };
770
763
 
771
764
  // A Workspace with no Repo Slot (ADR-0051): a Sandbox for one unit of work whose Agents and
@@ -773,53 +766,50 @@ export type PermissiveWorkspaceOptions<TSlots extends string, TInput = unknown>
773
766
  // conditional on `TSlots`, because the slot overloads infer `TSlots` from `repos`, and no
774
767
  // inference reaches through a conditional type.
775
768
  /** The options of a `workspace()` with no Repo Slot that declares the input that starts a run.
776
- * `repos` is left out, and `spec` is optional. */
769
+ * `repos` is left out. */
777
770
  export type NoSlotWorkspaceOptions<TSchema extends z.ZodObject> = Omit<SandboxOptions<never>, "repos"> & {
778
771
  /** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
779
772
  repos?: never;
780
773
  /** The input that starts a run, as a `z.object`, as in {@link WorkspaceOptions}. */
781
774
  input: TSchema;
782
- /** A function from the run input to the Workspace's {@link NoSlotSpec}: only `workGroup`.
783
- * Default: no `workGroup`. */
784
- spec?: (args: { input: WithoutEmptyIndex<z.infer<TSchema>> & HostInjectedInput }) => NoSlotSpec;
785
775
  };
786
776
 
787
777
  /** The options of a `workspace()` with no Repo Slot and no `input`: it starts with any input. */
788
- export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOptions<never>, "repos"> & {
778
+ export type PermissiveNoSlotWorkspaceOptions = Omit<SandboxOptions<never>, "repos"> & {
789
779
  /** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
790
780
  repos?: never;
791
781
  /** Not present: a `workspace()` without `input` starts with any input. */
792
782
  input?: never;
793
- /** A function from the input to the Workspace's {@link NoSlotSpec}: only `workGroup`. Default:
794
- * no `workGroup`. */
795
- spec?: (args: { input: TInput }) => NoSlotSpec;
796
783
  };
797
784
 
798
785
  /**
799
- * Run a body Machine in a Sandbox: a pod of its own, with a Worktree of each Repo Slot on one
800
- * branch. The result is a Machine. Register it as a Workflow, or invoke it from another Machine as
786
+ * Run a body Machine in a Sandbox: a pod of its own, with a clone of each Repo Slot. The result is
787
+ * a Machine. Register it as a Workflow, or invoke it from another Machine as
801
788
  * an actor slot.
802
789
  *
803
790
  * When a run enters it, the Workspace makes the Sandbox and waits for room on a node, with no
804
- * deadline. It clones each Repo Slot and makes its Worktree on the `branch` that `spec` gives. Then
805
- * it runs the body with the run input plus `workspace`, the handles (see {@link Workspaced}). The
806
- * Agents with Working tools and the `command` Effects in the body run in that Sandbox. An Agent
791
+ * deadline. It clones each Repo Slot to `/work/<slot>/default`, on the Binding's base, and makes no
792
+ * Worktree: the body cuts each Worktree that it needs with `worktree()`. A slot that is only read
793
+ * needs no cut. No work happens in `default/`. Then it runs the body with the run input plus
794
+ * `workspace`, the handles (see {@link Workspaced}). The Agents with Working tools and the `command` Effects in the body run in that Sandbox. An Agent
807
795
  * with `workspace: "none"` runs on the Instance Harness. When the body reaches a final state, the
808
- * Workspace deletes the Sandbox, and its output is the body's output. Push the branch before
796
+ * Workspace deletes the Sandbox, and its output is the body's output. Push each branch before
809
797
  * then: jr2 deletes the Sandbox's commits with it.
810
798
  *
799
+ * The author chooses how many Agents work in a Workspace. Agents in one Worktree share git, as
800
+ * people in one checkout do. Each coder that needs its own branch gets its own Worktree of the one
801
+ * clone.
802
+ *
811
803
  * Leave out `repos` for a Workspace with no Repo Slot: its Agents and `command` Effects share
812
- * `/work`, as the steps of one job share a folder, and `spec` is optional and gives only
813
- * `workGroup`. The body's input is then `Workspaced<RunInput, never>`. An `http` Effect sends a
814
- * file of `/work` with `file(path)`. A `push`, and a `command` with an Effect secret in its `env`,
815
- * need a Repo Slot, so jr2 refuses them there. jr2 deletes `/work` with the Sandbox: send what
804
+ * `/work`, as the steps of one job share a folder. The body's input is then `Workspaced<RunInput, never>`. An `http` Effect sends a
805
+ * file of `/work` with `file(path)`. A `worktree()`, a `push`, and a `command` with an Effect secret in its
806
+ * `env`, need a Repo Slot, so jr2 refuses them there. jr2 deletes `/work` with the Sandbox: send what
816
807
  * must stay before the body ends.
817
808
  *
818
809
  * Give each run a different branch, so that two runs do not push to one branch. Make it from a
819
810
  * field of the run input, or from `instanceId` when the run input has no field that is different
820
- * for each run: ``spec: ({ input }) => ({ branch: `fix/${input.instanceId}` })``. The input of
821
- * `spec` holds `instanceId` with no cast, also for the door `z.object({})`. Only a root
822
- * `workspace()` gets `instanceId` from jr2. A `workspace()` that a different Machine invokes gets
811
+ * for each run: ``branch: `fix/${context.instanceId}` `` in the input of the cut. Add
812
+ * `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
823
813
  * it only when its parent gives it.
824
814
  *
825
815
  * The Sandbox is Lost when its pod is evicted or its node goes. Then the body gets the event
@@ -843,7 +833,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
843
833
  * - a body with its own `input`: ``workspace(): the body "<id>" declares its own run input, which
844
834
  * nothing will ever serve or enforce — the wrapper feeds the body the run input PLUS the injected
845
835
  * `workspace` handles, so the body's schema is not the door. Move it to the wrapper:
846
- * workspace(body, { input, spec }) (ADR-0033).``
836
+ * workspace(body, { input }) (ADR-0033).``
847
837
  * - an empty `image`: ``workspace(): `image` must be a non-empty string (got <value>) — either a
848
838
  * `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a
849
839
  * registry ref (ADR-0037).``
@@ -853,9 +843,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
853
843
  * `repos: { app: "https://…" }` — or `open` whole, for a map the consumer names; leave it out
854
844
  * for a Workspace with no Repo Slot (ADR-0051).``
855
845
  * - a bad Repo Slot name or value (see {@link RepoSlot})
856
- * - a `spec` that is not a function, or no `spec` on a Workspace with Repo Slots: ``workspace():
857
- * `spec` must be a function from the run input to the spec — spec: ({ input }) => ({ branch }) —
858
- * and only a Workspace with no Repo Slot may leave it out (ADR-0051).``
846
+ * - a `workGroup` that is not a gid: ``workspace(): `workGroup` must be a gid (got <value>)``
859
847
  * - a bad `secrets` list: ``workspace(): `secrets` must be a list of distinct names from
860
848
  * jr2.config.ts's `secrets` list (got <value>) — secrets: ["JIRA_TOKEN"] (ADR-0059).``
861
849
  * - a bad `effector`: ``workspace(): `effector` is { image } — a `file:` URL to a docker context
@@ -871,17 +859,14 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
871
859
  * (ADR-0060)``, or the same text for `resources.limits.<key>`. The other texts are in
872
860
  * {@link Size}.
873
861
  *
874
- * And when a run enters it, a spec without a `branch` (or with `branch: "default"`), or with a bad
875
- * `reviewSha` or `workGroup`: ``workspace spec invalid: <fields> — the spec derives from run input;
876
- * does `jr2 run --input` carry every field this workflow's workspace() mapping reads?``. With no
877
- * Repo Slot, a spec with a `branch` or a `reviewSha`: ``workspace spec invalid: <field> (the
878
- * Workspace has no Repo Slot, so it has no branch and no review Worktree; its spec gives only
879
- * workGroup) …``
862
+ * And when a run enters it, a per-run Repo Slot's mapper that gives no `url`, or a bad `ref`:
863
+ * ``workspace invalid: repos.<slot> mapper returned <fields> — the mapper derives from run
864
+ * input; does `jr2 run --input` carry every field this workflow's workspace() slot reads?``
880
865
  *
881
866
  * @param body - The Machine to run in the Sandbox. Its input is `Workspaced<RunInput, Slots>`.
882
867
  * @param options - What the Sandbox is made of (`image`, `user`, `resources`, `effector`), the
883
- * Repo Slots it clones (`repos`), the Held secrets it can use (`secrets`), the run input
884
- * (`input`), and the function from the run input to the branch (`spec`).
868
+ * Repo Slots it clones (`repos`), the Held secrets it can use (`secrets`), the pod's `fsGroup`
869
+ * (`workGroup`), and the run input (`input`).
885
870
  * @returns A Machine whose input is the run input and whose output is the body's output.
886
871
  *
887
872
  * @example
@@ -890,8 +875,10 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
890
875
  * import {
891
876
  * command,
892
877
  * fail,
878
+ * isReady,
893
879
  * jr2Setup,
894
880
  * workspace,
881
+ * worktree,
895
882
  * type CommandOutput,
896
883
  * type Workspaced,
897
884
  * } from "@jr2/orchestrator";
@@ -899,23 +886,36 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
899
886
  * const runInput = z.object({ ticket: z.string(), repo: z.string() });
900
887
  * type BodyInput = Workspaced<z.infer<typeof runInput>, "app" | "docs">;
901
888
  *
889
+ * // The Worktree of this run. An Agent or a command that only reads `docs` uses `/work/docs/default`.
890
+ * const buildTree = (context: BodyInput) => `${context.workspace.repos.app}/build` as const;
891
+ *
902
892
  * const passed = (output: CommandOutput): boolean =>
903
893
  * output.outcome === "exited" && output.code === 0;
904
894
  *
905
895
  * const body = jr2Setup({
906
- * types: {} as { input: BodyInput },
896
+ * types: {} as { input: BodyInput; context: BodyInput },
907
897
  * actors: {
908
- * build: command(["npm", "run", "build"], { slot: "app" }),
898
+ * cut: worktree(),
899
+ * build: command(["npm", "run", "build"]),
909
900
  * fail: fail(),
910
901
  * },
911
902
  * }).createMachine({
912
903
  * id: "build",
904
+ * context: ({ input }) => input,
913
905
  * on: { "workspace.lost": ".lost" },
914
- * initial: "building",
906
+ * initial: "cutting",
915
907
  * states: {
908
+ * cutting: {
909
+ * invoke: {
910
+ * src: "cut",
911
+ * input: ({ context }) => ({ path: buildTree(context), branch: `fix/${context.ticket}` }),
912
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "building" }, { target: "notCut" }],
913
+ * },
914
+ * },
916
915
  * building: {
917
916
  * invoke: {
918
917
  * src: "build",
918
+ * input: ({ context }) => ({ worktree: buildTree(context) }),
919
919
  * onDone: [
920
920
  * { guard: ({ event }) => passed(event.output), target: "built" },
921
921
  * { target: "broken" },
@@ -924,6 +924,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
924
924
  * },
925
925
  * built: { type: "final" },
926
926
  * broken: { invoke: { src: "fail", input: { reason: "npm run build did not pass" } } },
927
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
927
928
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
928
929
  * },
929
930
  * });
@@ -936,7 +937,6 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
936
937
  * app: ({ input }) => input.repo,
937
938
  * docs: { url: "https://github.com/you/docs.git", ref: "main" },
938
939
  * },
939
- * spec: ({ input }) => ({ branch: `fix/${input.ticket}` }),
940
940
  * });
941
941
  * ```
942
942
  *
@@ -946,9 +946,12 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
946
946
  * import {
947
947
  * command,
948
948
  * fail,
949
+ * isReady,
949
950
  * jr2Setup,
950
951
  * workspace,
952
+ * worktree,
951
953
  * type CommandOutput,
954
+ * type HostInjectedInput,
952
955
  * type Workspaced,
953
956
  * } from "@jr2/orchestrator";
954
957
  *
@@ -958,18 +961,30 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
958
961
  * // The run input has no field that is different for each run, so the branch comes from
959
962
  * // `instanceId`.
960
963
  * const runInput = z.object({});
964
+ * type BodyInput = Workspaced<z.infer<typeof runInput> & HostInjectedInput, "app">;
965
+ *
966
+ * const nightlyTree = (context: BodyInput) => `${context.workspace.repos.app}/nightly` as const;
961
967
  *
962
968
  * const body = jr2Setup({
963
- * types: {} as { input: Workspaced<z.infer<typeof runInput>, "app"> },
964
- * actors: { test: command(["npm", "test"], { slot: "app" }), fail: fail() },
969
+ * types: {} as { input: BodyInput; context: BodyInput },
970
+ * actors: { cut: worktree(), test: command(["npm", "test"]), fail: fail() },
965
971
  * }).createMachine({
966
972
  * id: "nightly",
973
+ * context: ({ input }) => input,
967
974
  * on: { "workspace.lost": ".lost" },
968
- * initial: "testing",
975
+ * initial: "cutting",
969
976
  * states: {
977
+ * cutting: {
978
+ * invoke: {
979
+ * src: "cut",
980
+ * input: ({ context }) => ({ path: nightlyTree(context), branch: `nightly/${context.instanceId}` }),
981
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "testing" }, { target: "notCut" }],
982
+ * },
983
+ * },
970
984
  * testing: {
971
985
  * invoke: {
972
986
  * src: "test",
987
+ * input: ({ context }) => ({ worktree: nightlyTree(context) }),
973
988
  * onDone: [
974
989
  * { guard: ({ event }) => passed(event.output), target: "tested" },
975
990
  * { target: "red" },
@@ -978,6 +993,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
978
993
  * },
979
994
  * tested: { type: "final" },
980
995
  * red: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
996
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
981
997
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
982
998
  * },
983
999
  * });
@@ -986,7 +1002,6 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
986
1002
  * input: runInput,
987
1003
  * image: "docker.io/library/node:24",
988
1004
  * repos: { app: "https://github.com/you/app.git" },
989
- * spec: ({ input }) => ({ branch: `fix/${input.instanceId}` }),
990
1005
  * });
991
1006
  * ```
992
1007
  *
@@ -1004,7 +1019,7 @@ export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMac
1004
1019
  body: TBody & BodyAcceptsDoor<TBody, z.infer<TSchema>, TSlots>,
1005
1020
  options: WorkspaceOptions<TSchema, TSlots> | NoSlotWorkspaceOptions<TSchema>,
1006
1021
  // The input is the door without the empty door's `Record<string, never>` (`{}` for `z.object({})`), so
1007
- // a parent can give `instanceId` to a nested `spec` that reads it (ADR-0033).
1022
+ // a parent can give `instanceId` to a nested body that reads it (ADR-0033).
1008
1023
  ): WorkspaceMachine<WithoutEmptyIndex<z.infer<TSchema>>, OutputFrom<TBody>, TBody, TSlots>;
1009
1024
  /**
1010
1025
  * Run a body Machine in a Sandbox, with no `input` schema: the Workspace starts with any input. Use
@@ -1013,26 +1028,30 @@ export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMac
1013
1028
  *
1014
1029
  * @param body - The Machine to run in the Sandbox. Its input is the input of the Workspace plus
1015
1030
  * `workspace`, the handles.
1016
- * @param options - The options of the Sandbox, and `spec`. There is no `input`.
1031
+ * @param options - The options of the Sandbox. There is no `input`.
1017
1032
  * @returns A Machine whose input is the run input and whose output is the body's output.
1018
1033
  *
1019
1034
  * @see docs/concepts/workspaces.md
1020
1035
  * @see docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md
1021
1036
  */
1022
- export function workspace<TBody extends AnyStateMachine, TSlots extends string = never, TInput = unknown>(
1037
+ export function workspace<
1038
+ TBody extends AnyStateMachine,
1039
+ TSlots extends string = never,
1040
+ // Nothing else annotates the input of a door-less Workspace, so the body says it.
1041
+ TInput = DoorlessInput<TBody>,
1042
+ >(
1023
1043
  body: TBody & BodyAcceptsSlots<TBody, TSlots>,
1024
- options: PermissiveWorkspaceOptions<TSlots, TInput> | PermissiveNoSlotWorkspaceOptions<TInput>,
1044
+ // `undefined` is for the invoke alone: a run with no input gives a mapper `{}`.
1045
+ options: PermissiveWorkspaceOptions<TSlots, Exclude<TInput, undefined>> | PermissiveNoSlotWorkspaceOptions,
1025
1046
  ): WorkspaceMachine<TInput, OutputFrom<TBody>, TBody, TSlots>;
1026
1047
  export function workspace(
1027
1048
  body: AnyStateMachine,
1028
1049
  options: Omit<SandboxOptions<string, any>, "repos"> & {
1029
1050
  repos?: SandboxOptions<string, any>["repos"];
1030
1051
  input?: z.ZodObject;
1031
- spec?: (args: { input: any }) => WorkspaceSpec | NoSlotSpec;
1032
1052
  },
1033
1053
  ): AnyStateMachine {
1034
- // `spec` maps the wrapper's input to the workspace-domain spec (ADR-0012); the body receives
1035
- // the wrapper's input plus the handles — `Workspaced<TInput, TSlots>`, which is also what the
1054
+ // The body receives the wrapper's input plus the handles — `Workspaced<TInput, TSlots>`, which is also what the
1036
1055
  // declared door checks the body against. A Failure of the body is caught: the wrapper destroys
1037
1056
  // the Sandbox and fails again with the same payload (ADR-0073). Every other body error (a defect)
1038
1057
  // is not caught: it faults the run (RunStatus.fault) and leaves the Sandbox to the operator's
@@ -1048,7 +1067,7 @@ export function workspace(
1048
1067
  `workspace(): the body "${body.id}" declares its own run input, which nothing will ever ` +
1049
1068
  "serve or enforce — the wrapper feeds the body the run input PLUS the injected `workspace` " +
1050
1069
  "handles, so the body's schema is not the door. Move it to the wrapper: " +
1051
- "workspace(body, { input, spec }) (ADR-0033).",
1070
+ "workspace(body, { input }) (ADR-0033).",
1052
1071
  );
1053
1072
  }
1054
1073
  // A body state that cannot route `workspace.lost` (ADR-0074): with no route, the body would show
@@ -1057,7 +1076,7 @@ export function workspace(
1057
1076
  // not judged: its states are jr2's, and the walk refuses a Workspace below a Workspace (ADR-0059).
1058
1077
  if (!composesSandbox(body) && wrapperBodyOf(body) === undefined) refuseUnroutedLost(body);
1059
1078
  // Static, so checkable NOW rather than at the first provision — an empty or non-string image is
1060
- // the same derives-from-a-typo bug `assertSpec` catches for the spec, one build earlier.
1079
+ // a derives-from-a-typo bug, and this catches it one build earlier.
1061
1080
  const image = options.image;
1062
1081
  if (image !== undefined && (typeof image !== "string" || !image)) {
1063
1082
  throw new Error(
@@ -1118,15 +1137,10 @@ export function workspace(
1118
1137
  `${JSON.stringify(effector)}) — effector: { image: import.meta.resolve("./effector") } (ADR-0068).`,
1119
1138
  );
1120
1139
  }
1121
- // A Workspace with slots cuts a branch, so it needs `spec`; one with none may leave it out. The
1122
- // types say so; this says so to a caller the types do not see, by name, before any run.
1123
- if (options.spec !== undefined ? typeof options.spec !== "function" : repos === open || Object.keys(repos).length) {
1124
- throw new Error(
1125
- "workspace(): `spec` must be a function from the run input to the spec — spec: ({ input }) => ({ branch }) — " +
1126
- "and only a Workspace with no Repo Slot may leave it out (ADR-0051).",
1127
- );
1128
- }
1129
- const wrapper = buildWorkspaceMachine(body, options.spec ?? (() => ({})));
1140
+ const workGroup = options.workGroup;
1141
+ assertWorkGroup(workGroup);
1142
+ if (repos === open || Object.keys(repos).length > 0) refuseUnnamedWorktrees(body);
1143
+ const wrapper = buildWorkspaceMachine(body);
1130
1144
  // The wrapper is TRANSPARENT to its body (ADR-0049): `customize(machine, { agents })` on a
1131
1145
  // Workspace means the Machine inside, so the composer never spells `body` and never has to know
1132
1146
  // that jr2 wrapped anything.
@@ -1141,6 +1155,7 @@ export function workspace(
1141
1155
  ...(options.user !== undefined ? { user: options.user } : {}),
1142
1156
  repos: repos === open ? open : { ...repos },
1143
1157
  ...(options.resources !== undefined ? { resources: options.resources } : {}),
1158
+ ...(workGroup !== undefined ? { workGroup } : {}),
1144
1159
  ...(secrets !== undefined ? { secrets: [...secrets] } : {}),
1145
1160
  ...(effector !== undefined ? { effector: { image: effector.image } } : {}),
1146
1161
  });
@@ -1158,54 +1173,75 @@ export function workspace(
1158
1173
  }
1159
1174
 
1160
1175
  /**
1161
- * Fail a malformed spec BEFORE any pod exists. The spec derives from run input via the workflow's
1162
- * mapping fn, so a `jr2 run --input` missing a field the mapping reads arrives here as `undefined` —
1163
- * unchecked, it survives until the attach script's string ops and dies as "Cannot read properties
1164
- * of undefined", with a Sandbox already provisioned and nothing pointing back at the input.
1176
+ * Each Turn and each Effect that works in a Worktree says which, in a Workspace with Repo Slots: the
1177
+ * walk's half of what `jr2Setup` refuses by type, for a plain `setup()`, for a nested key, and for a
1178
+ * body with no typed handles. It sees an invoke with no input and a literal input. A mapper's answer
1179
+ * is its start's to refuse. A Machine below the body is walked too: a child, and a Pool's worker,
1180
+ * which is no invoke, so each Machine in `actors` is walked. A nested Workspace is not: its own
1181
+ * `workspace()` call walks it, by its own Repo Slots.
1165
1182
  */
1166
- function assertSpec(spec: AnySpec, noSlot: boolean): void {
1167
- if (typeof spec !== "object" || spec === null) {
1168
- throw new Error(
1169
- `workspace spec invalid: the spec is ${JSON.stringify(spec)}, not an object — \`spec\` returns one, ` +
1170
- "such as { branch } (ADR-0051)",
1171
- );
1172
- }
1173
- const bad: string[] = [];
1174
- // No Repo Slot, nothing attached (ADR-0051): a branch or a review sha would name nothing, and a
1175
- // value jr2 silently ignores is the failure the refusals exist for.
1176
- const nothing =
1177
- "(the Workspace has no Repo Slot, so it has no branch and no review Worktree; its spec gives only workGroup)";
1178
- if (noSlot) {
1179
- if (spec?.branch !== undefined) bad.push(`branch ${nothing}`);
1180
- if (spec?.reviewSha !== undefined) bad.push(`reviewSha ${nothing}`);
1181
- } else if (typeof spec?.branch !== "string" || !spec.branch) bad.push(`branch (got ${JSON.stringify(spec?.branch)})`);
1182
- // The branch Worktree is a SIBLING of the pod-local clone at `<slot>/default/` (ADR-0004), so
1183
- // the one branch name that is not a worktree directory is `default`.
1184
- else if (spec.branch === "default")
1185
- bad.push(
1186
- 'branch "default" (the pod-local clone\'s own directory — a branch Worktree sits beside `default/`, ADR-0004)',
1187
- );
1188
- if (!noSlot && spec?.reviewSha !== undefined && (typeof spec.reviewSha !== "string" || !spec.reviewSha))
1189
- bad.push(`reviewSha (got ${JSON.stringify(spec?.reviewSha)})`);
1190
- // A gid, so an integer — a float or a negative becomes a pod the API server rejects at
1191
- // admission, which surfaces as "never reached Ready" with nothing pointing back at the spec.
1192
- if (
1193
- spec?.workGroup !== undefined &&
1194
- (typeof spec.workGroup !== "number" || !Number.isInteger(spec.workGroup) || spec.workGroup < 0)
1195
- )
1196
- bad.push(`workGroup (got ${JSON.stringify(spec?.workGroup)}; want a gid)`);
1197
- if (bad.length) {
1198
- throw new Error(
1199
- `workspace spec invalid: ${bad.join("; ")} — the spec derives from run input; does ` +
1200
- "`jr2 run --input` carry every field this workflow's workspace() mapping reads?",
1201
- );
1183
+ function refuseUnnamedWorktrees(body: AnyStateMachine): void {
1184
+ const faults: string[] = [];
1185
+ const seen = new Set<AnyStateMachine>();
1186
+ const walk = (machine: AnyStateMachine): void => {
1187
+ if (seen.has(machine) || (machine !== body && composesSandbox(machine))) return;
1188
+ seen.add(machine);
1189
+ const actors = (machine.implementations.actors ?? {}) as Record<string, unknown>;
1190
+ for (const logic of Object.values(actors)) if (isMachineLogic(logic)) walk(logic);
1191
+ const nodes = [machine.root as unknown as InvokeNode];
1192
+ for (const node of nodes) {
1193
+ nodes.push(...Object.values(node.states));
1194
+ const configs = [node.config.invoke ?? []].flat() as Array<{ input?: unknown } | undefined>;
1195
+ node.invoke.forEach((inv, i) => {
1196
+ const logic = typeof inv.src === "string" ? actors[inv.src] : inv.src;
1197
+ if (isMachineLogic(logic)) walk(logic);
1198
+ const key = keyOf(logic);
1199
+ if (key === undefined) return;
1200
+ const given = configs[i]?.input;
1201
+ const written = (given as { consumerInput?: unknown } | undefined)?.consumerInput ?? given;
1202
+ if (typeof written === "function") return;
1203
+ if (typeof written === "object" && written !== null && key in written) return;
1204
+ faults.push(
1205
+ `state "${node.id}": "${typeof inv.src === "string" ? inv.src : inv.id}" names no \`${key}\`, and a Workspace ` +
1206
+ 'with Repo Slots says where each Turn and Effect works: a Worktree, or `cwd: "/work"` for a Turn',
1207
+ );
1208
+ });
1209
+ }
1210
+ };
1211
+ walk(body);
1212
+ if (faults.length) throw new Error(`workspace(): ${faults.join("; ")} (ADR-0057)`);
1213
+ }
1214
+
1215
+ const isMachineLogic = (logic: unknown): logic is AnyStateMachine =>
1216
+ typeof logic === "object" && logic !== null && "implementations" in logic;
1217
+
1218
+ type InvokeNode = {
1219
+ id: string;
1220
+ states: Record<string, InvokeNode>;
1221
+ config: { invoke?: unknown };
1222
+ invoke: Array<{ src: unknown; id: string }>;
1223
+ };
1224
+
1225
+ /** The key that says where this logic works, or undefined when it works nowhere in `/work`. */
1226
+ function keyOf(logic: unknown): "cwd" | "worktree" | undefined {
1227
+ if (isAgent(logic)) return (logic.definition as { workspace?: unknown }).workspace === "none" ? undefined : "cwd";
1228
+ if (!isEffect(logic)) return undefined;
1229
+ const parts = logic.effect;
1230
+ if (parts.kind === "command") return parts.cut ? undefined : "worktree";
1231
+ return parts.kind === "http" && parts.file ? "worktree" : undefined;
1232
+ }
1233
+
1234
+ /** A gid, so an integer: a float or a negative is a pod the API server rejects at admission. */
1235
+ function assertWorkGroup(workGroup: unknown): void {
1236
+ if (workGroup !== undefined && (typeof workGroup !== "number" || !Number.isInteger(workGroup) || workGroup < 0)) {
1237
+ throw new Error(`workspace(): \`workGroup\` must be a gid (got ${JSON.stringify(workGroup)})`);
1202
1238
  }
1203
1239
  }
1204
1240
 
1205
1241
  /**
1206
1242
  * Resolve every Repo Slot to a Binding, in declaration order (ADR-0051). A bound slot is its
1207
1243
  * Binding; a per-run slot is its mapper called over the run input, validated like the static forms
1208
- * because it derives from `jr2 run --input` exactly as the spec does; an open slot nobody bound is
1244
+ * because it derives from `jr2 run --input` ; an open slot nobody bound is
1209
1245
  * a fault BEFORE the port, naming the `customize` line that fixes it — the run-time twin of the
1210
1246
  * refusal `jr2 up`'s walk makes for a registered Machine, reached here only by a Machine that was
1211
1247
  * never registered as itself (a test seam, a nested invoke of an unbound import). The Machine is
@@ -1245,7 +1281,7 @@ function resolveBindings(
1245
1281
  bad.push(`ref (got ${JSON.stringify(binding.ref)})`);
1246
1282
  if (bad.length) {
1247
1283
  throw new Error(
1248
- `workspace spec invalid: repos.${slot} mapper returned ${bad.join("; ")} — the mapper derives from run ` +
1284
+ `workspace invalid: repos.${slot} mapper returned ${bad.join("; ")} — the mapper derives from run ` +
1249
1285
  "input; does `jr2 run --input` carry every field this workflow's workspace() slot reads?",
1250
1286
  );
1251
1287
  }
@@ -1254,15 +1290,6 @@ function resolveBindings(
1254
1290
  return bindings;
1255
1291
  }
1256
1292
 
1257
- /** A spec as the wrapper holds it: a Workspace's with Repo Slots, or one with none (ADR-0051). */
1258
- type AnySpec = WorkspaceSpec | NoSlotSpec;
1259
-
1260
- /** Does this Workspace declare no Repo Slot (ADR-0051)? An Open map is not "none": its slots are
1261
- * the composer's, and `customize()` names at least one. */
1262
- function noSlot(slots: SandboxParts["repos"]): boolean {
1263
- return slots !== open && Object.keys(slots).length === 0;
1264
- }
1265
-
1266
1293
  /** The port-facing view of the persisted bindings, in declaration order. */
1267
1294
  function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot: string; url: string; ref?: string }> {
1268
1295
  return Object.entries(bindings).map(([slot, b]) => ({
@@ -1272,7 +1299,7 @@ function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot:
1272
1299
  }));
1273
1300
  }
1274
1301
 
1275
- function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any }) => AnySpec): AnyStateMachine {
1302
+ function buildWorkspaceMachine(body: AnyStateMachine): AnyStateMachine {
1276
1303
  /**
1277
1304
  * `placing` (ADR-0064): write the Sandbox and wait, with no deadline, for its pod to be
1278
1305
  * scheduled. jr2 cannot tell "full now" from "never fits", so a bound is the Machine's own
@@ -1297,8 +1324,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1297
1324
  * and so the same name, which that Workspace has placed again by then (ADR-0073).
1298
1325
  */
1299
1326
  const place = fromPromise<
1300
- { bindings: Record<string, ResolvedBinding> },
1301
- { wsId: string; spec: AnySpec; runInput: unknown }
1327
+ { bindings: Record<string, ResolvedBinding>; runInput: Record<string, unknown> },
1328
+ { wsId: string; runInput: Record<string, unknown> }
1302
1329
  >(async ({ input, self, system, signal }) => {
1303
1330
  const binding = runBindingOf(system);
1304
1331
  // The images and the slots come off the WRAPPER, at invoke time, not out of context and not
@@ -1308,15 +1335,21 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1308
1335
  // content-addressed tag, which would outlive the image it names. The RESOLVED bindings are
1309
1336
  // persisted, because a per-run mapper's answer is this run's fact.
1310
1337
  const parts = sandboxPartsOf(invokingMachine(self));
1311
- assertSpec(input.spec, noSlot(parts.repos)); // before the port: a bad spec must never cost a pod
1338
+ const wrapper = self._parent;
1339
+ // RunHost parsed a root run's door and added `instanceId`, so that value is final: a second
1340
+ // parse would refuse the added key under a strict door and transform a value twice. A parent's
1341
+ // input is parsed here, and a door value that is wrong costs no pod (ADR-0033).
1342
+ const door = wrapper?._parent ? inputSchemaOf(invokingMachine(self)!) : undefined;
1343
+ const runInput = door
1344
+ ? parsedNestedDoor(door, input.runInput, ["instanceId"], "workspace input invalid, before any pod")
1345
+ : input.runInput;
1312
1346
  const user = userSeatOf(parts.user);
1313
1347
  const path = actorSlotPath(self._parent);
1314
1348
  // Read before the port: a Workspace no walk can see placed nothing, and is refused with the slot form.
1315
1349
  const route = actorRoute(self._parent);
1316
- const bindings = resolveBindings({ workflow: binding.workflow, path }, parts.repos, input.runInput);
1350
+ const bindings = resolveBindings({ workflow: binding.workflow, path }, parts.repos, runInput);
1317
1351
  const port = sandboxOf(system);
1318
1352
  const name = workspaceName(binding.runId, input.wsId);
1319
- const wrapper = self._parent;
1320
1353
  const child = wrapper ? actorPath(wrapper).join("/") : "";
1321
1354
  // A wait opened before a restart is the same wait: its start is in the wrapper's persisted
1322
1355
  // context, never in this actor's input, which a restore replays as it was first invoked.
@@ -1344,12 +1377,15 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1344
1377
  name,
1345
1378
  runId: binding.runId,
1346
1379
  workflow: binding.workflow,
1380
+ path: child || input.wsId,
1347
1381
  route,
1348
1382
  // The image strings straight through (ADR-0037/0005) — the port owns resolution, and the
1349
1383
  // work group's default (ADR-0005 puts it in pod composition, where the pod is built).
1350
1384
  ...(parts.image !== undefined ? { image: parts.image } : {}),
1351
1385
  ...(user.image !== undefined ? { user: user.image } : {}),
1352
- ...(input.spec.workGroup !== undefined ? { workGroup: input.spec.workGroup } : {}),
1386
+ ...(parts.workGroup !== undefined ? { workGroup: parts.workGroup } : {}),
1387
+ // The Held secrets the `workspace()` grants its confined children (ADR-0076): names only.
1388
+ ...(parts.secrets?.length ? { secrets: [...parts.secrets] } : {}),
1353
1389
  repos: Object.entries(bindings).map(([slot, b]) => ({ slot, ...b })),
1354
1390
  // The Size as the Machine states it, re-read here like the images (ADR-0060): a redeploy
1355
1391
  // that retuned it reaches the next provision, and no snapshot holds it.
@@ -1387,7 +1423,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1387
1423
  openedAt !== undefined ? Math.round(performance.now() - openedAt) : Math.max(0, Date.now() - Date.parse(since));
1388
1424
  binding.marker?.({ kind: "placed", child, since, after });
1389
1425
  }
1390
- return { bindings };
1426
+ return { bindings, runInput };
1391
1427
  });
1392
1428
 
1393
1429
  /** `provisioning`: the placed pod's Ready, under the port's budget, which starts here (ADR-0064). */
@@ -1396,8 +1432,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1396
1432
  );
1397
1433
 
1398
1434
  const attach = fromPromise<
1399
- { repos: Record<string, string>; review?: Record<string, string>; stale?: Record<string, string> },
1400
- { wsId: string; spec: AnySpec; bindings: Record<string, ResolvedBinding> }
1435
+ { repos: Record<string, string>; bases?: Record<string, string>; stale?: Record<string, string> },
1436
+ { wsId: string; bindings: Record<string, ResolvedBinding> }
1401
1437
  >(async ({ input, system, signal }) => {
1402
1438
  // No Repo Slot, nothing to clone (ADR-0051): the Harness's attach would refuse an empty list,
1403
1439
  // and `/work` needs nothing from it — the pod's `fsGroup` already makes it the work group's.
@@ -1420,9 +1456,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1420
1456
  signal.addEventListener("abort", () => unsubscribe(), { once: true });
1421
1457
  let out: Awaited<ReturnType<typeof port.attach>>;
1422
1458
  try {
1423
- // A Workspace with slots holds a `WorkspaceSpec`: `place` refused one without a branch.
1424
- const spec = input.spec as WorkspaceSpec;
1425
- out = await Promise.race([port.attach({ name, spec, repos: attachedRepos(input.bindings) }), loss]);
1459
+ out = await Promise.race([port.attach({ name, repos: attachedRepos(input.bindings) }), loss]);
1426
1460
  } finally {
1427
1461
  unsubscribe();
1428
1462
  }
@@ -1507,10 +1541,22 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1507
1541
  let timer: ReturnType<typeof setTimeout>;
1508
1542
  const schedule = (ms: number) => {
1509
1543
  timer = setTimeout(() => {
1544
+ if (stopped) return;
1510
1545
  // A Workspace that ended in error stopped processing without stopping its children (xstate
1511
- // does not), and a parent that handled the error only stops what still processes: the
1512
- // lease asks, and asserts nothing for a Workspace that is over.
1513
- if (stopped || self._parent?.getSnapshot().status !== "active") return;
1546
+ // does not), and a parent that handled the error only stops what still processes: so this
1547
+ // lease is never stopped, and its cleanup below never runs. The lease asks, asserts nothing
1548
+ // for a Workspace that is over, and releases the hold mark here, once: no run holds the
1549
+ // Sandbox now (ADR-0021). A host that stops writes nothing, as below.
1550
+ if (self._parent?.getSnapshot().status !== "active") {
1551
+ if (runBindingOf(system).hostStopping) return;
1552
+ stopped = true;
1553
+ unsubscribe();
1554
+ // A parent that caught the defect can invoke the same Machine again: the new Workspace
1555
+ // has the same invoke id, so the same Sandbox name, and its own Lease holds it now.
1556
+ if (runBindingOf(system).heldSandboxes?.().has(name)) return;
1557
+ port.releaseHold(name).catch(() => {});
1558
+ return;
1559
+ }
1514
1560
  // A failed renewal is unknown, never lost: the next one is an interval away, well inside
1515
1561
  // the idle timeout, and loss is the watch's to report. Before the CR's write lands it is a
1516
1562
  // 404, and the same shrug.
@@ -1524,6 +1570,19 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1524
1570
  stopped = true;
1525
1571
  clearTimeout(timer);
1526
1572
  unsubscribe();
1573
+ // The hold mark (ADR-0021). A park writes nothing, so the CR stays `held` and the restored
1574
+ // run's Lease renews it. Else the stop is a step of the wrapper or its end: read the wrapper
1575
+ // once its stop or its transition is over. Still active, it is in a next state with a Lease
1576
+ // of its own, or deletes the Sandbox; over (a cancel, a fault, a parent that left it), no
1577
+ // run holds the Sandbox now.
1578
+ if (runBindingOf(system).hostStopping) return;
1579
+ queueMicrotask(() => {
1580
+ if (runBindingOf(system).hostStopping) return;
1581
+ if (self._parent?.getSnapshot().status === "active") return;
1582
+ // A live Workspace of the same name (a re-invoke after a caught defect) holds it now.
1583
+ if (runBindingOf(system).heldSandboxes?.().has(name)) return;
1584
+ port.releaseHold(name).catch(() => {});
1585
+ });
1527
1586
  };
1528
1587
  },
1529
1588
  );
@@ -1552,7 +1611,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1552
1611
  context: ({ input, self }: { input: unknown; self: { id: string } }): WsContext => ({
1553
1612
  runInput: (input ?? {}) as Record<string, unknown>,
1554
1613
  wsId: self.id,
1555
- spec: spec({ input }),
1556
1614
  }),
1557
1615
  initial: "placing",
1558
1616
  states: {
@@ -1563,7 +1621,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1563
1621
  src: "place",
1564
1622
  input: ({ context }) => ({
1565
1623
  wsId: (context as unknown as WsContext).wsId,
1566
- spec: (context as unknown as WsContext).spec,
1567
1624
  runInput: (context as unknown as WsContext).runInput,
1568
1625
  }),
1569
1626
  onDone: {
@@ -1571,6 +1628,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1571
1628
  actions: assign({
1572
1629
  bindings: ({ event }) =>
1573
1630
  (event as unknown as { output: { bindings: Record<string, ResolvedBinding> } }).output.bindings,
1631
+ runInput: ({ event }) =>
1632
+ (event as unknown as { output: { runInput: Record<string, unknown> } }).output.runInput,
1574
1633
  wait: undefined,
1575
1634
  }),
1576
1635
  },
@@ -1608,7 +1667,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1608
1667
  src: "attach",
1609
1668
  input: ({ context }) => ({
1610
1669
  wsId: (context as unknown as WsContext).wsId,
1611
- spec: (context as unknown as WsContext).spec,
1612
1670
  bindings: (context as unknown as WsContext).bindings!,
1613
1671
  }),
1614
1672
  onDone: {
@@ -1618,7 +1676,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1618
1676
  const ctx = context as WsContext;
1619
1677
  const out = (
1620
1678
  event as unknown as {
1621
- output: { repos: Record<string, string>; review?: Record<string, string> };
1679
+ output: { repos: Record<string, string>; bases?: Record<string, string> };
1622
1680
  }
1623
1681
  ).output;
1624
1682
  return {
@@ -1634,9 +1692,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1634
1692
  ...(ctx.bindings
1635
1693
  ? { bindings: Object.fromEntries(Object.entries(ctx.bindings).map(([s, b]) => [s, b.url])) }
1636
1694
  : {}),
1637
- // No branch with no Repo Slot (ADR-0051): nothing was cut.
1638
- ...(ctx.spec.branch !== undefined ? { branch: ctx.spec.branch } : {}),
1639
- ...(out.review ? { review: out.review } : {}),
1695
+ // Each slot's base: what the attach put `default/` on, sent with every cut.
1696
+ ...(out.bases ? { bases: out.bases } : {}),
1640
1697
  };
1641
1698
  },
1642
1699
  }),
@@ -1660,12 +1717,10 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1660
1717
  src: "body",
1661
1718
  input: ({ context }: { context: WsContext }) => {
1662
1719
  const ctx = context;
1663
- const { repos, branch, review } = ctx.handles!;
1720
+ const { repos } = ctx.handles!;
1664
1721
  // Body-facing subset only (ADR-0016): endpoint/sandbox are mechanism-internal. With
1665
1722
  // no Repo Slot the handles are `{ repos: {} }` (WorkspaceHandles<never>).
1666
- const handles: WorkspaceHandles<string> | WorkspaceHandles<never> =
1667
- branch === undefined ? { repos: {} } : { repos, branch, ...(review ? { review } : {}) };
1668
- return { ...ctx.runInput, workspace: handles };
1723
+ return { ...ctx.runInput, workspace: { repos } };
1669
1724
  },
1670
1725
  onDone: {
1671
1726
  target: "teardown",