@jr2/orchestrator 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/docs/agents-block.md +2 -2
  2. package/docs/concepts/agents-and-turns.md +56 -20
  3. package/docs/concepts/architecture.md +30 -17
  4. package/docs/concepts/charts.md +6 -3
  5. package/docs/concepts/converge.md +3 -0
  6. package/docs/concepts/effects.md +86 -31
  7. package/docs/concepts/machines-and-workflows.md +9 -0
  8. package/docs/concepts/pools-and-sources.md +6 -6
  9. package/docs/concepts/secrets-and-trust.md +126 -42
  10. package/docs/concepts/the-menu.md +3 -0
  11. package/docs/concepts/workspaces.md +129 -51
  12. package/docs/get-started/an-agent-in-a-sandbox.md +21 -19
  13. package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
  14. package/docs/get-started/install.md +4 -0
  15. package/docs/guides/add-tools-to-the-sandbox.md +17 -4
  16. package/docs/guides/bind-open-parts.md +5 -4
  17. package/docs/guides/cut-a-worktree.md +560 -0
  18. package/docs/guides/fix-a-failed-converge.md +1 -1
  19. package/docs/guides/let-an-agent-write-a-chart.md +5 -1
  20. package/docs/guides/look-inside-a-sandbox.md +18 -15
  21. package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
  22. package/docs/guides/push-and-sign-a-branch.md +67 -21
  23. package/docs/guides/run-a-command-or-call-a-url.md +84 -41
  24. package/docs/guides/run-one-worker-per-item.md +19 -18
  25. package/docs/guides/set-an-agents-access-and-dials.md +67 -22
  26. package/docs/guides/size-a-workspace.md +19 -6
  27. package/docs/guides/use-a-hosted-provider.md +6 -0
  28. package/docs/guides/watch-a-run.md +47 -24
  29. package/docs/guides/write-a-workflow.md +1 -1
  30. package/docs/llms.txt +33 -12
  31. package/docs/reference/authoring-surface/agent.md +34 -11
  32. package/docs/reference/authoring-surface/answer.md +19 -3
  33. package/docs/reference/authoring-surface/arg.md +27 -5
  34. package/docs/reference/authoring-surface/branchname.md +15 -0
  35. package/docs/reference/authoring-surface/command.md +36 -21
  36. package/docs/reference/authoring-surface/fail.md +17 -4
  37. package/docs/reference/authoring-surface/file.md +36 -16
  38. package/docs/reference/authoring-surface/http.md +108 -8
  39. package/docs/reference/authoring-surface/isready.md +14 -0
  40. package/docs/reference/authoring-surface/jr2setup.md +1 -1
  41. package/docs/reference/authoring-surface/open.md +36 -5
  42. package/docs/reference/authoring-surface/paramsof.md +22 -4
  43. package/docs/reference/authoring-surface/pool.md +4 -4
  44. package/docs/reference/authoring-surface/push.md +30 -12
  45. package/docs/reference/authoring-surface/readyof.md +21 -0
  46. package/docs/reference/authoring-surface/secret.md +25 -4
  47. package/docs/reference/authoring-surface/source.md +3 -3
  48. package/docs/reference/authoring-surface/type-agentdefinition.md +2 -2
  49. package/docs/reference/authoring-surface/type-agentslots.md +4 -1
  50. package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
  51. package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
  52. package/docs/reference/authoring-surface/type-bindactors.md +14 -0
  53. package/docs/reference/authoring-surface/type-binding.md +5 -6
  54. package/docs/reference/authoring-surface/type-commandinput.md +4 -3
  55. package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
  56. package/docs/reference/authoring-surface/type-commandoutput.md +22 -4
  57. package/docs/reference/authoring-surface/type-customize.md +4 -2
  58. package/docs/reference/authoring-surface/type-dispatchoutput.md +8 -1
  59. package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
  60. package/docs/reference/authoring-surface/type-filebody.md +4 -8
  61. package/docs/reference/authoring-surface/type-framepath.md +14 -0
  62. package/docs/reference/authoring-surface/type-hostinjectedinput.md +2 -4
  63. package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
  64. package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
  65. package/docs/reference/authoring-surface/type-httpoutput.md +7 -1
  66. package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
  67. package/docs/reference/authoring-surface/type-namestart.md +77 -0
  68. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
  69. package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
  70. package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
  71. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
  72. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
  73. package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
  74. package/docs/reference/authoring-surface/type-poolspec.md +2 -2
  75. package/docs/reference/authoring-surface/type-pushinput.md +22 -3
  76. package/docs/reference/authoring-surface/type-pushoutput.md +32 -7
  77. package/docs/reference/authoring-surface/type-reposlot.md +5 -5
  78. package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
  79. package/docs/reference/authoring-surface/type-slothandles.md +5 -13
  80. package/docs/reference/authoring-surface/type-sourcespec.md +10 -10
  81. package/docs/reference/authoring-surface/type-workspaced.md +29 -8
  82. package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
  83. package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
  84. package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
  85. package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
  86. package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
  87. package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
  88. package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
  89. package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
  90. package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
  91. package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
  92. package/docs/reference/authoring-surface/workspace.md +65 -37
  93. package/docs/reference/authoring-surface/worktree.md +71 -0
  94. package/docs/reference/authoring-surface.md +30 -10
  95. package/docs/reference/cli.md +1 -1
  96. package/docs/reference/config.md +13 -4
  97. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
  98. package/docs/reference/decisions/0002-duplex-streaming-actor.md +17 -10
  99. package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
  100. package/docs/reference/decisions/0005-sandbox-pod-composition.md +29 -19
  101. package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
  102. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +30 -30
  103. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +35 -17
  104. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +8 -2
  105. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
  106. package/docs/reference/decisions/0017-pool-over-a-source-port.md +6 -4
  107. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +21 -9
  108. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +2 -1
  109. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +8 -9
  110. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +62 -65
  111. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +9 -1
  112. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +13 -12
  113. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +4 -1
  114. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +18 -19
  115. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +8 -8
  116. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +4 -4
  117. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
  118. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
  119. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
  120. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +24 -24
  121. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +5 -3
  122. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +110 -67
  123. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +101 -74
  124. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +5 -6
  125. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +2 -0
  126. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +181 -153
  127. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +74 -63
  128. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +31 -20
  129. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
  130. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +295 -0
  131. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +320 -0
  132. package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
  133. package/docs/reference/decisions.md +3 -0
  134. package/docs/reference/glossary.md +51 -12
  135. package/docs/reference/http-api.md +45 -7
  136. package/docs/reference/machines.md +0 -1
  137. package/docs/why.md +5 -4
  138. package/package.json +3 -3
  139. package/src/actor.ts +161 -35
  140. package/src/agent.ts +6 -6
  141. package/src/ambient.ts +3 -5
  142. package/src/chart.ts +186 -19
  143. package/src/charts.ts +9 -2
  144. package/src/config.ts +16 -6
  145. package/src/custodian.lua +17 -5
  146. package/src/custodian.ts +251 -77
  147. package/src/customize.ts +11 -3
  148. package/src/effect-checks.ts +50 -3
  149. package/src/effect.ts +343 -127
  150. package/src/effector-wire.ts +6 -3
  151. package/src/fail.ts +17 -4
  152. package/src/harness-client.ts +74 -31
  153. package/src/harness-env.ts +33 -0
  154. package/src/held-secrets.ts +125 -16
  155. package/src/holds.ts +3 -0
  156. package/src/http-effect.ts +88 -32
  157. package/src/http.ts +56 -17
  158. package/src/index.ts +15 -1
  159. package/src/internal.ts +1 -0
  160. package/src/kube-client.ts +6 -3
  161. package/src/machine-doc.ts +3 -4
  162. package/src/model-keys.ts +24 -0
  163. package/src/names.ts +19 -6
  164. package/src/notices.ts +443 -54
  165. package/src/open-body.ts +85 -19
  166. package/src/open.ts +36 -5
  167. package/src/parts.ts +40 -28
  168. package/src/party.ts +33 -0
  169. package/src/pick.ts +41 -7
  170. package/src/pool.ts +44 -23
  171. package/src/registration.ts +53 -11
  172. package/src/repo-identity.ts +7 -3
  173. package/src/repos.ts +20 -2
  174. package/src/run-host.ts +106 -20
  175. package/src/sandbox-effect.ts +217 -66
  176. package/src/sandbox-kube.ts +63 -16
  177. package/src/secret-placement.ts +9 -2
  178. package/src/setup.ts +86 -6
  179. package/src/tokens.ts +107 -32
  180. package/src/vocabulary.ts +23 -4
  181. package/src/wire.ts +10 -10
  182. package/src/workspace.ts +264 -253
  183. package/src/worktree.ts +249 -0
  184. package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
  185. package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
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
  *
@@ -323,6 +301,9 @@ export type PlaceRequest = {
323
301
  image?: string;
324
302
  user?: string;
325
303
  workGroup?: number;
304
+ /** The Held secrets the `workspace()`'s `secrets` list names (ADR-0059): the ones the Harness may
305
+ * put in a confined child's env, by Stand-in (ADR-0076). Names, never a value. Absent → none. */
306
+ secrets?: string[];
326
307
  repos: ProvisionedRepo[];
327
308
  resources?: Size;
328
309
  userResources?: Size;
@@ -370,17 +351,12 @@ export interface SandboxPort {
370
351
  */
371
352
  provision(name: string): Promise<{ endpoint: string }>;
372
353
  /** 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<{
354
+ * cache (the `default/` checkout). A Machine cuts each Worktree (ADR-0075). Resolves with the
355
+ * folder of each slot; `stale` names the slots whose cache could not be fetched before this
356
+ * attach, with git's own error (ADR-0051: freshness degrades, absence does not). */
357
+ attach(req: { name: string; repos: Array<{ slot: string; url: string; ref?: string }> }): Promise<{
382
358
  repos: Record<string, string>;
383
- review?: Record<string, string>;
359
+ bases?: Record<string, string>;
384
360
  stale?: Record<string, string>;
385
361
  }>;
386
362
  /**
@@ -483,12 +459,11 @@ export type WorkspaceWait = PlacingWait & { since: string };
483
459
  export const WORKSPACE_WAIT_KEY = "wait";
484
460
 
485
461
  type WsContext = {
486
- /** The wrapper's own input, passed through to the body untouched (plus `workspace`). */
462
+ /** The wrapper's input, which the body gets (plus `workspace`). Under a parent, `place` replaces
463
+ * it with the door's parsed value. */
487
464
  runInput: Record<string, unknown>;
488
465
  /** This invocation's stable identity (the actor id the parent invoked/spawned us under). */
489
466
  wsId: string;
490
- /** The resolved spec — computed once from input and persisted, like any other context data. */
491
- spec: AnySpec;
492
467
  /** Every slot's resolved Binding, by slot, in declaration order — assigned at provision. */
493
468
  bindings?: Record<string, ResolvedBinding>;
494
469
  endpoint?: string;
@@ -581,6 +556,20 @@ export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSl
581
556
  // satisfies a body that writes that type itself (`z.infer<typeof door>`, alone or with the
582
557
  // handles). The raw door keeps those bodies legal, and the stripped form keeps a body that
583
558
  // declares `Workspaced<…>` legal. A key that the door does not carry is still refused.
559
+ /** The input of a door-less Workspace: the body's, without the handles. `unknown` when the body
560
+ * asks for nothing else, and with `undefined` added when each field it asks for is optional. In
561
+ * both cases a parent has nothing that it must give, and xstate makes an invoke's `input` optional
562
+ * only for a type that holds `undefined`.
563
+ * @internal */
564
+ type DoorlessInput<TBody extends AnyStateMachine> =
565
+ unknown extends InputFrom<TBody>
566
+ ? unknown
567
+ : [keyof Omit<InputFrom<TBody>, "workspace">] extends [never]
568
+ ? unknown
569
+ : {} extends Omit<InputFrom<TBody>, "workspace">
570
+ ? Omit<InputFrom<TBody>, "workspace"> | undefined
571
+ : Omit<InputFrom<TBody>, "workspace">;
572
+
584
573
  /** @internal */
585
574
  type BodyAcceptsDoor<TBody extends AnyStateMachine, TDoor, TSlots extends string> =
586
575
  TDoor & Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
@@ -625,10 +614,10 @@ type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
625
614
  : unknown;
626
615
 
627
616
  // 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
617
+ // STATIC options on the wrapper rather than a function of the run input (ADR-0049). Static is the
629
618
  // 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
619
+ // every bound Repo and warm it, every open slot and refuse it (parts.ts) — and no walk can evaluate a
620
+ // function of run input. They are also never persisted — the provisioning state
632
621
  // re-reads them off the Machine it was invoked as, so a restore, a `provide()` and a `customize()`
633
622
  // all get the image and the slots the Machine carries NOW.
634
623
  //
@@ -649,6 +638,9 @@ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
649
638
  * glibc and git; jr2 adds its own runtime at `/opt/jr2`. Default: the Instance's
650
639
  * `images/default`, then jr2's Harness image. */
651
640
  image?: string;
641
+ // A gid is a fact of the images, so it is static.
642
+ /** The group id that owns the files in `/work`. Default: 2000. A non-negative integer. */
643
+ workGroup?: number;
652
644
  // The widening ADR-0005 foresaw: `{ image, resources }` states the seat's split of the Size
653
645
  // (ADR-0060). There is no default, because the seat's whole identity is "what jr2 does not own"
654
646
  // and jr2 has nothing to put there. Env and ports are deliberately not forwarded.
@@ -680,8 +672,8 @@ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
680
672
  * choice. The name is the key of `workspace.repos` in the body and the directory under `/work`.
681
673
  * At least one slot. Leave `repos` out for a Workspace with no Repo Slot (see
682
674
  * {@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);
675
+ * - a url, or `{ url, ref }`, where `ref` is the branch, the tag or the commit that the clone
676
+ * stays on, and that a new branch starts from (default: the Repo's default branch);
685
677
  * - `open`, for the Instance that registers the Machine to bind with `customize()`;
686
678
  * - a function of the run input, `({ input }) => url` or `{ url, ref }`, when each run chooses.
687
679
  *
@@ -695,8 +687,11 @@ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
695
687
  /**
696
688
  * The Held secrets that this Sandbox can use: names from the `secrets` list in `jr2.config.ts`.
697
689
  * 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.
690
+ * secret. Listing a name also gives its Stand-in to the Agent's programs — each `bash` call, test,
691
+ * build and `command` — in their env under that name, so code in `/work` can spend it (ADR-0076).
692
+ * You need not list the model key of an Agent that runs here: its provider claims it, and the
693
+ * Harness gives it to the model calls alone. List the model key only when code in `/work` must
694
+ * call the model itself: such a call is outside the Turn's usage record. Each name once.
700
695
  */
701
696
  secrets?: readonly string[];
702
697
  // The container exists only when an Effect below this `workspace()` is placed there (ADR-0068,
@@ -725,47 +720,27 @@ export type WorkspaceOptions<TSchema extends z.ZodObject, TSlots extends string>
725
720
  // `workspace` handles ({@link Workspaced}), which no caller can send, so the body's contract is
726
721
  // the door plus something that does not exist yet. Symmetric with `PoolSpec.input`.
727
722
  /** 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
723
+ * the `input` of a per-run Repo Slot, and the body must accept it plus the
729
724
  * `workspace` handles. Write it here, not on the body. */
730
725
  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
726
  };
749
727
 
750
728
  // How a Workspace with NO declared door is configured: absence is permissive (ADR-0033), so jr2
751
729
  // has nothing to infer from and says `unknown` rather than `any` — an honest "jr2 does not know",
752
730
  // which the mapper must narrow before it reads a field.
753
731
  /**
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.
732
+ * The options of a `workspace()` with no `input`: it starts with any input. The `input` of a
733
+ * per-run Repo Slot is then `unknown`, and the mapper must check it before it reads a field.
756
734
  *
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`.
735
+ * Use it for a Workspace that a Pool or another Machine starts. The body's `input` type says what
736
+ * that input is, and that also types the input of the Workspace. For a Workspace that a caller
737
+ * starts, declare `input`.
760
738
  */
761
739
  export type PermissiveWorkspaceOptions<TSlots extends string, TInput = unknown> = SandboxOptions<TSlots, TInput> & {
762
740
  // Spelled out so a declared schema can never fall through to the permissive overload, where the
763
741
  // body's door would go unchecked.
764
742
  /** Not present: a `workspace()` without `input` starts with any input. */
765
743
  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
744
  };
770
745
 
771
746
  // A Workspace with no Repo Slot (ADR-0051): a Sandbox for one unit of work whose Agents and
@@ -773,53 +748,50 @@ export type PermissiveWorkspaceOptions<TSlots extends string, TInput = unknown>
773
748
  // conditional on `TSlots`, because the slot overloads infer `TSlots` from `repos`, and no
774
749
  // inference reaches through a conditional type.
775
750
  /** 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. */
751
+ * `repos` is left out. */
777
752
  export type NoSlotWorkspaceOptions<TSchema extends z.ZodObject> = Omit<SandboxOptions<never>, "repos"> & {
778
753
  /** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
779
754
  repos?: never;
780
755
  /** The input that starts a run, as a `z.object`, as in {@link WorkspaceOptions}. */
781
756
  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
757
  };
786
758
 
787
759
  /** 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"> & {
760
+ export type PermissiveNoSlotWorkspaceOptions = Omit<SandboxOptions<never>, "repos"> & {
789
761
  /** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
790
762
  repos?: never;
791
763
  /** Not present: a `workspace()` without `input` starts with any input. */
792
764
  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
765
  };
797
766
 
798
767
  /**
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
768
+ * Run a body Machine in a Sandbox: a pod of its own, with a clone of each Repo Slot. The result is
769
+ * a Machine. Register it as a Workflow, or invoke it from another Machine as
801
770
  * an actor slot.
802
771
  *
803
772
  * 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
773
+ * deadline. It clones each Repo Slot to `/work/<slot>/default`, on the Binding's base, and makes no
774
+ * Worktree: the body cuts each Worktree that it needs with `worktree()`. A slot that is only read
775
+ * needs no cut. No work happens in `default/`. Then it runs the body with the run input plus
776
+ * `workspace`, the handles (see {@link Workspaced}). The Agents with Working tools and the `command` Effects in the body run in that Sandbox. An Agent
807
777
  * 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
778
+ * Workspace deletes the Sandbox, and its output is the body's output. Push each branch before
809
779
  * then: jr2 deletes the Sandbox's commits with it.
810
780
  *
781
+ * The author chooses how many Agents work in a Workspace. Agents in one Worktree share git, as
782
+ * people in one checkout do. Each coder that needs its own branch gets its own Worktree of the one
783
+ * clone.
784
+ *
811
785
  * 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
786
+ * `/work`, as the steps of one job share a folder. The body's input is then `Workspaced<RunInput, never>`. An `http` Effect sends a
787
+ * file of `/work` with `file(path)`. A `worktree()`, a `push`, and a `command` with an Effect secret in its
788
+ * `env`, need a Repo Slot, so jr2 refuses them there. jr2 deletes `/work` with the Sandbox: send what
816
789
  * must stay before the body ends.
817
790
  *
818
791
  * Give each run a different branch, so that two runs do not push to one branch. Make it from a
819
792
  * 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
793
+ * for each run: ``branch: `fix/${context.instanceId}` `` in the input of the cut. Add
794
+ * `HostInjectedInput` to the body's input type to read it. Only a root `workspace()` gets `instanceId` from jr2. A `workspace()` that a different Machine invokes gets
823
795
  * it only when its parent gives it.
824
796
  *
825
797
  * The Sandbox is Lost when its pod is evicted or its node goes. Then the body gets the event
@@ -843,7 +815,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
843
815
  * - a body with its own `input`: ``workspace(): the body "<id>" declares its own run input, which
844
816
  * nothing will ever serve or enforce — the wrapper feeds the body the run input PLUS the injected
845
817
  * `workspace` handles, so the body's schema is not the door. Move it to the wrapper:
846
- * workspace(body, { input, spec }) (ADR-0033).``
818
+ * workspace(body, { input }) (ADR-0033).``
847
819
  * - an empty `image`: ``workspace(): `image` must be a non-empty string (got <value>) — either a
848
820
  * `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a
849
821
  * registry ref (ADR-0037).``
@@ -853,9 +825,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
853
825
  * `repos: { app: "https://…" }` — or `open` whole, for a map the consumer names; leave it out
854
826
  * for a Workspace with no Repo Slot (ADR-0051).``
855
827
  * - 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).``
828
+ * - a `workGroup` that is not a gid: ``workspace(): `workGroup` must be a gid (got <value>)``
859
829
  * - a bad `secrets` list: ``workspace(): `secrets` must be a list of distinct names from
860
830
  * jr2.config.ts's `secrets` list (got <value>) — secrets: ["JIRA_TOKEN"] (ADR-0059).``
861
831
  * - a bad `effector`: ``workspace(): `effector` is { image } — a `file:` URL to a docker context
@@ -871,17 +841,14 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
871
841
  * (ADR-0060)``, or the same text for `resources.limits.<key>`. The other texts are in
872
842
  * {@link Size}.
873
843
  *
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) …``
844
+ * And when a run enters it, a per-run Repo Slot's mapper that gives no `url`, or a bad `ref`:
845
+ * ``workspace invalid: repos.<slot> mapper returned <fields> — the mapper derives from run
846
+ * input; does `jr2 run --input` carry every field this workflow's workspace() slot reads?``
880
847
  *
881
848
  * @param body - The Machine to run in the Sandbox. Its input is `Workspaced<RunInput, Slots>`.
882
849
  * @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`).
850
+ * Repo Slots it clones (`repos`), the Held secrets it can use (`secrets`), the pod's `fsGroup`
851
+ * (`workGroup`), and the run input (`input`).
885
852
  * @returns A Machine whose input is the run input and whose output is the body's output.
886
853
  *
887
854
  * @example
@@ -890,8 +857,10 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
890
857
  * import {
891
858
  * command,
892
859
  * fail,
860
+ * isReady,
893
861
  * jr2Setup,
894
862
  * workspace,
863
+ * worktree,
895
864
  * type CommandOutput,
896
865
  * type Workspaced,
897
866
  * } from "@jr2/orchestrator";
@@ -899,23 +868,36 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
899
868
  * const runInput = z.object({ ticket: z.string(), repo: z.string() });
900
869
  * type BodyInput = Workspaced<z.infer<typeof runInput>, "app" | "docs">;
901
870
  *
871
+ * // The Worktree of this run. An Agent or a command that only reads `docs` uses `/work/docs/default`.
872
+ * const buildTree = (context: BodyInput) => `${context.workspace.repos.app}/build` as const;
873
+ *
902
874
  * const passed = (output: CommandOutput): boolean =>
903
875
  * output.outcome === "exited" && output.code === 0;
904
876
  *
905
877
  * const body = jr2Setup({
906
- * types: {} as { input: BodyInput },
878
+ * types: {} as { input: BodyInput; context: BodyInput },
907
879
  * actors: {
908
- * build: command(["npm", "run", "build"], { slot: "app" }),
880
+ * cut: worktree(),
881
+ * build: command(["npm", "run", "build"]),
909
882
  * fail: fail(),
910
883
  * },
911
884
  * }).createMachine({
912
885
  * id: "build",
886
+ * context: ({ input }) => input,
913
887
  * on: { "workspace.lost": ".lost" },
914
- * initial: "building",
888
+ * initial: "cutting",
915
889
  * states: {
890
+ * cutting: {
891
+ * invoke: {
892
+ * src: "cut",
893
+ * input: ({ context }) => ({ path: buildTree(context), branch: `fix/${context.ticket}` }),
894
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "building" }, { target: "notCut" }],
895
+ * },
896
+ * },
916
897
  * building: {
917
898
  * invoke: {
918
899
  * src: "build",
900
+ * input: ({ context }) => ({ worktree: buildTree(context) }),
919
901
  * onDone: [
920
902
  * { guard: ({ event }) => passed(event.output), target: "built" },
921
903
  * { target: "broken" },
@@ -924,6 +906,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
924
906
  * },
925
907
  * built: { type: "final" },
926
908
  * broken: { invoke: { src: "fail", input: { reason: "npm run build did not pass" } } },
909
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
927
910
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
928
911
  * },
929
912
  * });
@@ -936,7 +919,6 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
936
919
  * app: ({ input }) => input.repo,
937
920
  * docs: { url: "https://github.com/you/docs.git", ref: "main" },
938
921
  * },
939
- * spec: ({ input }) => ({ branch: `fix/${input.ticket}` }),
940
922
  * });
941
923
  * ```
942
924
  *
@@ -946,9 +928,12 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
946
928
  * import {
947
929
  * command,
948
930
  * fail,
931
+ * isReady,
949
932
  * jr2Setup,
950
933
  * workspace,
934
+ * worktree,
951
935
  * type CommandOutput,
936
+ * type HostInjectedInput,
952
937
  * type Workspaced,
953
938
  * } from "@jr2/orchestrator";
954
939
  *
@@ -958,18 +943,30 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
958
943
  * // The run input has no field that is different for each run, so the branch comes from
959
944
  * // `instanceId`.
960
945
  * const runInput = z.object({});
946
+ * type BodyInput = Workspaced<z.infer<typeof runInput> & HostInjectedInput, "app">;
947
+ *
948
+ * const nightlyTree = (context: BodyInput) => `${context.workspace.repos.app}/nightly` as const;
961
949
  *
962
950
  * const body = jr2Setup({
963
- * types: {} as { input: Workspaced<z.infer<typeof runInput>, "app"> },
964
- * actors: { test: command(["npm", "test"], { slot: "app" }), fail: fail() },
951
+ * types: {} as { input: BodyInput; context: BodyInput },
952
+ * actors: { cut: worktree(), test: command(["npm", "test"]), fail: fail() },
965
953
  * }).createMachine({
966
954
  * id: "nightly",
955
+ * context: ({ input }) => input,
967
956
  * on: { "workspace.lost": ".lost" },
968
- * initial: "testing",
957
+ * initial: "cutting",
969
958
  * states: {
959
+ * cutting: {
960
+ * invoke: {
961
+ * src: "cut",
962
+ * input: ({ context }) => ({ path: nightlyTree(context), branch: `nightly/${context.instanceId}` }),
963
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "testing" }, { target: "notCut" }],
964
+ * },
965
+ * },
970
966
  * testing: {
971
967
  * invoke: {
972
968
  * src: "test",
969
+ * input: ({ context }) => ({ worktree: nightlyTree(context) }),
973
970
  * onDone: [
974
971
  * { guard: ({ event }) => passed(event.output), target: "tested" },
975
972
  * { target: "red" },
@@ -978,6 +975,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
978
975
  * },
979
976
  * tested: { type: "final" },
980
977
  * red: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
978
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
981
979
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
982
980
  * },
983
981
  * });
@@ -986,7 +984,6 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
986
984
  * input: runInput,
987
985
  * image: "docker.io/library/node:24",
988
986
  * repos: { app: "https://github.com/you/app.git" },
989
- * spec: ({ input }) => ({ branch: `fix/${input.instanceId}` }),
990
987
  * });
991
988
  * ```
992
989
  *
@@ -1004,7 +1001,7 @@ export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMac
1004
1001
  body: TBody & BodyAcceptsDoor<TBody, z.infer<TSchema>, TSlots>,
1005
1002
  options: WorkspaceOptions<TSchema, TSlots> | NoSlotWorkspaceOptions<TSchema>,
1006
1003
  // 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).
1004
+ // a parent can give `instanceId` to a nested body that reads it (ADR-0033).
1008
1005
  ): WorkspaceMachine<WithoutEmptyIndex<z.infer<TSchema>>, OutputFrom<TBody>, TBody, TSlots>;
1009
1006
  /**
1010
1007
  * Run a body Machine in a Sandbox, with no `input` schema: the Workspace starts with any input. Use
@@ -1013,26 +1010,30 @@ export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMac
1013
1010
  *
1014
1011
  * @param body - The Machine to run in the Sandbox. Its input is the input of the Workspace plus
1015
1012
  * `workspace`, the handles.
1016
- * @param options - The options of the Sandbox, and `spec`. There is no `input`.
1013
+ * @param options - The options of the Sandbox. There is no `input`.
1017
1014
  * @returns A Machine whose input is the run input and whose output is the body's output.
1018
1015
  *
1019
1016
  * @see docs/concepts/workspaces.md
1020
1017
  * @see docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md
1021
1018
  */
1022
- export function workspace<TBody extends AnyStateMachine, TSlots extends string = never, TInput = unknown>(
1019
+ export function workspace<
1020
+ TBody extends AnyStateMachine,
1021
+ TSlots extends string = never,
1022
+ // Nothing else annotates the input of a door-less Workspace, so the body says it.
1023
+ TInput = DoorlessInput<TBody>,
1024
+ >(
1023
1025
  body: TBody & BodyAcceptsSlots<TBody, TSlots>,
1024
- options: PermissiveWorkspaceOptions<TSlots, TInput> | PermissiveNoSlotWorkspaceOptions<TInput>,
1026
+ // `undefined` is for the invoke alone: a run with no input gives a mapper `{}`.
1027
+ options: PermissiveWorkspaceOptions<TSlots, Exclude<TInput, undefined>> | PermissiveNoSlotWorkspaceOptions,
1025
1028
  ): WorkspaceMachine<TInput, OutputFrom<TBody>, TBody, TSlots>;
1026
1029
  export function workspace(
1027
1030
  body: AnyStateMachine,
1028
1031
  options: Omit<SandboxOptions<string, any>, "repos"> & {
1029
1032
  repos?: SandboxOptions<string, any>["repos"];
1030
1033
  input?: z.ZodObject;
1031
- spec?: (args: { input: any }) => WorkspaceSpec | NoSlotSpec;
1032
1034
  },
1033
1035
  ): AnyStateMachine {
1034
- // `spec` maps the wrapper's input to the workspace-domain spec (ADR-0012); the body receives
1035
- // the wrapper's input plus the handles — `Workspaced<TInput, TSlots>`, which is also what the
1036
+ // The body receives the wrapper's input plus the handles — `Workspaced<TInput, TSlots>`, which is also what the
1036
1037
  // declared door checks the body against. A Failure of the body is caught: the wrapper destroys
1037
1038
  // the Sandbox and fails again with the same payload (ADR-0073). Every other body error (a defect)
1038
1039
  // is not caught: it faults the run (RunStatus.fault) and leaves the Sandbox to the operator's
@@ -1048,7 +1049,7 @@ export function workspace(
1048
1049
  `workspace(): the body "${body.id}" declares its own run input, which nothing will ever ` +
1049
1050
  "serve or enforce — the wrapper feeds the body the run input PLUS the injected `workspace` " +
1050
1051
  "handles, so the body's schema is not the door. Move it to the wrapper: " +
1051
- "workspace(body, { input, spec }) (ADR-0033).",
1052
+ "workspace(body, { input }) (ADR-0033).",
1052
1053
  );
1053
1054
  }
1054
1055
  // A body state that cannot route `workspace.lost` (ADR-0074): with no route, the body would show
@@ -1057,7 +1058,7 @@ export function workspace(
1057
1058
  // not judged: its states are jr2's, and the walk refuses a Workspace below a Workspace (ADR-0059).
1058
1059
  if (!composesSandbox(body) && wrapperBodyOf(body) === undefined) refuseUnroutedLost(body);
1059
1060
  // 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.
1061
+ // a derives-from-a-typo bug, and this catches it one build earlier.
1061
1062
  const image = options.image;
1062
1063
  if (image !== undefined && (typeof image !== "string" || !image)) {
1063
1064
  throw new Error(
@@ -1118,15 +1119,10 @@ export function workspace(
1118
1119
  `${JSON.stringify(effector)}) — effector: { image: import.meta.resolve("./effector") } (ADR-0068).`,
1119
1120
  );
1120
1121
  }
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 ?? (() => ({})));
1122
+ const workGroup = options.workGroup;
1123
+ assertWorkGroup(workGroup);
1124
+ if (repos === open || Object.keys(repos).length > 0) refuseUnnamedWorktrees(body);
1125
+ const wrapper = buildWorkspaceMachine(body);
1130
1126
  // The wrapper is TRANSPARENT to its body (ADR-0049): `customize(machine, { agents })` on a
1131
1127
  // Workspace means the Machine inside, so the composer never spells `body` and never has to know
1132
1128
  // that jr2 wrapped anything.
@@ -1141,6 +1137,7 @@ export function workspace(
1141
1137
  ...(options.user !== undefined ? { user: options.user } : {}),
1142
1138
  repos: repos === open ? open : { ...repos },
1143
1139
  ...(options.resources !== undefined ? { resources: options.resources } : {}),
1140
+ ...(workGroup !== undefined ? { workGroup } : {}),
1144
1141
  ...(secrets !== undefined ? { secrets: [...secrets] } : {}),
1145
1142
  ...(effector !== undefined ? { effector: { image: effector.image } } : {}),
1146
1143
  });
@@ -1158,54 +1155,75 @@ export function workspace(
1158
1155
  }
1159
1156
 
1160
1157
  /**
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.
1158
+ * Each Turn and each Effect that works in a Worktree says which, in a Workspace with Repo Slots: the
1159
+ * walk's half of what `jr2Setup` refuses by type, for a plain `setup()`, for a nested key, and for a
1160
+ * body with no typed handles. It sees an invoke with no input and a literal input. A mapper's answer
1161
+ * is its start's to refuse. A Machine below the body is walked too: a child, and a Pool's worker,
1162
+ * which is no invoke, so each Machine in `actors` is walked. A nested Workspace is not: its own
1163
+ * `workspace()` call walks it, by its own Repo Slots.
1165
1164
  */
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
- );
1165
+ function refuseUnnamedWorktrees(body: AnyStateMachine): void {
1166
+ const faults: string[] = [];
1167
+ const seen = new Set<AnyStateMachine>();
1168
+ const walk = (machine: AnyStateMachine): void => {
1169
+ if (seen.has(machine) || (machine !== body && composesSandbox(machine))) return;
1170
+ seen.add(machine);
1171
+ const actors = (machine.implementations.actors ?? {}) as Record<string, unknown>;
1172
+ for (const logic of Object.values(actors)) if (isMachineLogic(logic)) walk(logic);
1173
+ const nodes = [machine.root as unknown as InvokeNode];
1174
+ for (const node of nodes) {
1175
+ nodes.push(...Object.values(node.states));
1176
+ const configs = [node.config.invoke ?? []].flat() as Array<{ input?: unknown } | undefined>;
1177
+ node.invoke.forEach((inv, i) => {
1178
+ const logic = typeof inv.src === "string" ? actors[inv.src] : inv.src;
1179
+ if (isMachineLogic(logic)) walk(logic);
1180
+ const key = keyOf(logic);
1181
+ if (key === undefined) return;
1182
+ const given = configs[i]?.input;
1183
+ const written = (given as { consumerInput?: unknown } | undefined)?.consumerInput ?? given;
1184
+ if (typeof written === "function") return;
1185
+ if (typeof written === "object" && written !== null && key in written) return;
1186
+ faults.push(
1187
+ `state "${node.id}": "${typeof inv.src === "string" ? inv.src : inv.id}" names no \`${key}\`, and a Workspace ` +
1188
+ 'with Repo Slots says where each Turn and Effect works: a Worktree, or `cwd: "/work"` for a Turn',
1189
+ );
1190
+ });
1191
+ }
1192
+ };
1193
+ walk(body);
1194
+ if (faults.length) throw new Error(`workspace(): ${faults.join("; ")} (ADR-0057)`);
1195
+ }
1196
+
1197
+ const isMachineLogic = (logic: unknown): logic is AnyStateMachine =>
1198
+ typeof logic === "object" && logic !== null && "implementations" in logic;
1199
+
1200
+ type InvokeNode = {
1201
+ id: string;
1202
+ states: Record<string, InvokeNode>;
1203
+ config: { invoke?: unknown };
1204
+ invoke: Array<{ src: unknown; id: string }>;
1205
+ };
1206
+
1207
+ /** The key that says where this logic works, or undefined when it works nowhere in `/work`. */
1208
+ function keyOf(logic: unknown): "cwd" | "worktree" | undefined {
1209
+ if (isAgent(logic)) return (logic.definition as { workspace?: unknown }).workspace === "none" ? undefined : "cwd";
1210
+ if (!isEffect(logic)) return undefined;
1211
+ const parts = logic.effect;
1212
+ if (parts.kind === "command") return parts.cut ? undefined : "worktree";
1213
+ return parts.kind === "http" && parts.file ? "worktree" : undefined;
1214
+ }
1215
+
1216
+ /** A gid, so an integer: a float or a negative is a pod the API server rejects at admission. */
1217
+ function assertWorkGroup(workGroup: unknown): void {
1218
+ if (workGroup !== undefined && (typeof workGroup !== "number" || !Number.isInteger(workGroup) || workGroup < 0)) {
1219
+ throw new Error(`workspace(): \`workGroup\` must be a gid (got ${JSON.stringify(workGroup)})`);
1202
1220
  }
1203
1221
  }
1204
1222
 
1205
1223
  /**
1206
1224
  * Resolve every Repo Slot to a Binding, in declaration order (ADR-0051). A bound slot is its
1207
1225
  * 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
1226
+ * because it derives from `jr2 run --input` ; an open slot nobody bound is
1209
1227
  * a fault BEFORE the port, naming the `customize` line that fixes it — the run-time twin of the
1210
1228
  * refusal `jr2 up`'s walk makes for a registered Machine, reached here only by a Machine that was
1211
1229
  * never registered as itself (a test seam, a nested invoke of an unbound import). The Machine is
@@ -1245,7 +1263,7 @@ function resolveBindings(
1245
1263
  bad.push(`ref (got ${JSON.stringify(binding.ref)})`);
1246
1264
  if (bad.length) {
1247
1265
  throw new Error(
1248
- `workspace spec invalid: repos.${slot} mapper returned ${bad.join("; ")} — the mapper derives from run ` +
1266
+ `workspace invalid: repos.${slot} mapper returned ${bad.join("; ")} — the mapper derives from run ` +
1249
1267
  "input; does `jr2 run --input` carry every field this workflow's workspace() slot reads?",
1250
1268
  );
1251
1269
  }
@@ -1254,15 +1272,6 @@ function resolveBindings(
1254
1272
  return bindings;
1255
1273
  }
1256
1274
 
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
1275
  /** The port-facing view of the persisted bindings, in declaration order. */
1267
1276
  function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot: string; url: string; ref?: string }> {
1268
1277
  return Object.entries(bindings).map(([slot, b]) => ({
@@ -1272,7 +1281,7 @@ function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot:
1272
1281
  }));
1273
1282
  }
1274
1283
 
1275
- function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any }) => AnySpec): AnyStateMachine {
1284
+ function buildWorkspaceMachine(body: AnyStateMachine): AnyStateMachine {
1276
1285
  /**
1277
1286
  * `placing` (ADR-0064): write the Sandbox and wait, with no deadline, for its pod to be
1278
1287
  * scheduled. jr2 cannot tell "full now" from "never fits", so a bound is the Machine's own
@@ -1297,8 +1306,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1297
1306
  * and so the same name, which that Workspace has placed again by then (ADR-0073).
1298
1307
  */
1299
1308
  const place = fromPromise<
1300
- { bindings: Record<string, ResolvedBinding> },
1301
- { wsId: string; spec: AnySpec; runInput: unknown }
1309
+ { bindings: Record<string, ResolvedBinding>; runInput: Record<string, unknown> },
1310
+ { wsId: string; runInput: Record<string, unknown> }
1302
1311
  >(async ({ input, self, system, signal }) => {
1303
1312
  const binding = runBindingOf(system);
1304
1313
  // The images and the slots come off the WRAPPER, at invoke time, not out of context and not
@@ -1308,15 +1317,21 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1308
1317
  // content-addressed tag, which would outlive the image it names. The RESOLVED bindings are
1309
1318
  // persisted, because a per-run mapper's answer is this run's fact.
1310
1319
  const parts = sandboxPartsOf(invokingMachine(self));
1311
- assertSpec(input.spec, noSlot(parts.repos)); // before the port: a bad spec must never cost a pod
1320
+ const wrapper = self._parent;
1321
+ // RunHost parsed a root run's door and added `instanceId`, so that value is final: a second
1322
+ // parse would refuse the added key under a strict door and transform a value twice. A parent's
1323
+ // input is parsed here, and a door value that is wrong costs no pod (ADR-0033).
1324
+ const door = wrapper?._parent ? inputSchemaOf(invokingMachine(self)!) : undefined;
1325
+ const runInput = door
1326
+ ? parsedNestedDoor(door, input.runInput, ["instanceId"], "workspace input invalid, before any pod")
1327
+ : input.runInput;
1312
1328
  const user = userSeatOf(parts.user);
1313
1329
  const path = actorSlotPath(self._parent);
1314
1330
  // Read before the port: a Workspace no walk can see placed nothing, and is refused with the slot form.
1315
1331
  const route = actorRoute(self._parent);
1316
- const bindings = resolveBindings({ workflow: binding.workflow, path }, parts.repos, input.runInput);
1332
+ const bindings = resolveBindings({ workflow: binding.workflow, path }, parts.repos, runInput);
1317
1333
  const port = sandboxOf(system);
1318
1334
  const name = workspaceName(binding.runId, input.wsId);
1319
- const wrapper = self._parent;
1320
1335
  const child = wrapper ? actorPath(wrapper).join("/") : "";
1321
1336
  // A wait opened before a restart is the same wait: its start is in the wrapper's persisted
1322
1337
  // context, never in this actor's input, which a restore replays as it was first invoked.
@@ -1349,7 +1364,9 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1349
1364
  // work group's default (ADR-0005 puts it in pod composition, where the pod is built).
1350
1365
  ...(parts.image !== undefined ? { image: parts.image } : {}),
1351
1366
  ...(user.image !== undefined ? { user: user.image } : {}),
1352
- ...(input.spec.workGroup !== undefined ? { workGroup: input.spec.workGroup } : {}),
1367
+ ...(parts.workGroup !== undefined ? { workGroup: parts.workGroup } : {}),
1368
+ // The Held secrets the `workspace()` grants its confined children (ADR-0076): names only.
1369
+ ...(parts.secrets?.length ? { secrets: [...parts.secrets] } : {}),
1353
1370
  repos: Object.entries(bindings).map(([slot, b]) => ({ slot, ...b })),
1354
1371
  // The Size as the Machine states it, re-read here like the images (ADR-0060): a redeploy
1355
1372
  // that retuned it reaches the next provision, and no snapshot holds it.
@@ -1387,7 +1404,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1387
1404
  openedAt !== undefined ? Math.round(performance.now() - openedAt) : Math.max(0, Date.now() - Date.parse(since));
1388
1405
  binding.marker?.({ kind: "placed", child, since, after });
1389
1406
  }
1390
- return { bindings };
1407
+ return { bindings, runInput };
1391
1408
  });
1392
1409
 
1393
1410
  /** `provisioning`: the placed pod's Ready, under the port's budget, which starts here (ADR-0064). */
@@ -1396,8 +1413,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1396
1413
  );
1397
1414
 
1398
1415
  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> }
1416
+ { repos: Record<string, string>; bases?: Record<string, string>; stale?: Record<string, string> },
1417
+ { wsId: string; bindings: Record<string, ResolvedBinding> }
1401
1418
  >(async ({ input, system, signal }) => {
1402
1419
  // No Repo Slot, nothing to clone (ADR-0051): the Harness's attach would refuse an empty list,
1403
1420
  // and `/work` needs nothing from it — the pod's `fsGroup` already makes it the work group's.
@@ -1420,9 +1437,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1420
1437
  signal.addEventListener("abort", () => unsubscribe(), { once: true });
1421
1438
  let out: Awaited<ReturnType<typeof port.attach>>;
1422
1439
  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]);
1440
+ out = await Promise.race([port.attach({ name, repos: attachedRepos(input.bindings) }), loss]);
1426
1441
  } finally {
1427
1442
  unsubscribe();
1428
1443
  }
@@ -1552,7 +1567,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1552
1567
  context: ({ input, self }: { input: unknown; self: { id: string } }): WsContext => ({
1553
1568
  runInput: (input ?? {}) as Record<string, unknown>,
1554
1569
  wsId: self.id,
1555
- spec: spec({ input }),
1556
1570
  }),
1557
1571
  initial: "placing",
1558
1572
  states: {
@@ -1563,7 +1577,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1563
1577
  src: "place",
1564
1578
  input: ({ context }) => ({
1565
1579
  wsId: (context as unknown as WsContext).wsId,
1566
- spec: (context as unknown as WsContext).spec,
1567
1580
  runInput: (context as unknown as WsContext).runInput,
1568
1581
  }),
1569
1582
  onDone: {
@@ -1571,6 +1584,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1571
1584
  actions: assign({
1572
1585
  bindings: ({ event }) =>
1573
1586
  (event as unknown as { output: { bindings: Record<string, ResolvedBinding> } }).output.bindings,
1587
+ runInput: ({ event }) =>
1588
+ (event as unknown as { output: { runInput: Record<string, unknown> } }).output.runInput,
1574
1589
  wait: undefined,
1575
1590
  }),
1576
1591
  },
@@ -1608,7 +1623,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1608
1623
  src: "attach",
1609
1624
  input: ({ context }) => ({
1610
1625
  wsId: (context as unknown as WsContext).wsId,
1611
- spec: (context as unknown as WsContext).spec,
1612
1626
  bindings: (context as unknown as WsContext).bindings!,
1613
1627
  }),
1614
1628
  onDone: {
@@ -1618,7 +1632,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1618
1632
  const ctx = context as WsContext;
1619
1633
  const out = (
1620
1634
  event as unknown as {
1621
- output: { repos: Record<string, string>; review?: Record<string, string> };
1635
+ output: { repos: Record<string, string>; bases?: Record<string, string> };
1622
1636
  }
1623
1637
  ).output;
1624
1638
  return {
@@ -1634,9 +1648,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1634
1648
  ...(ctx.bindings
1635
1649
  ? { bindings: Object.fromEntries(Object.entries(ctx.bindings).map(([s, b]) => [s, b.url])) }
1636
1650
  : {}),
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 } : {}),
1651
+ // Each slot's base: what the attach put `default/` on, sent with every cut.
1652
+ ...(out.bases ? { bases: out.bases } : {}),
1640
1653
  };
1641
1654
  },
1642
1655
  }),
@@ -1660,12 +1673,10 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
1660
1673
  src: "body",
1661
1674
  input: ({ context }: { context: WsContext }) => {
1662
1675
  const ctx = context;
1663
- const { repos, branch, review } = ctx.handles!;
1676
+ const { repos } = ctx.handles!;
1664
1677
  // Body-facing subset only (ADR-0016): endpoint/sandbox are mechanism-internal. With
1665
1678
  // 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 };
1679
+ return { ...ctx.runInput, workspace: { repos } };
1669
1680
  },
1670
1681
  onDone: {
1671
1682
  target: "teardown",