@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
@@ -53,7 +53,7 @@ import type {
53
53
  PushParts,
54
54
  Unknown,
55
55
  } from "./effect.ts";
56
- import { lastPushOf, needsASlot, pushPairsOf } from "./effect.ts";
56
+ import { cutRowOf, lastPushOf, needsASlot, pushPairsOf, worktreePathFault, type LaterOutcome } from "./effect.ts";
57
57
  import {
58
58
  createEffectorClient,
59
59
  EffectorForged,
@@ -89,6 +89,7 @@ import {
89
89
  import { open } from "./open.ts";
90
90
  import type { OpenOf } from "./open-effect.ts";
91
91
  import { boundRun, type AnyActorSystem } from "./registration.ts";
92
+ import type { AnyWorktreePath } from "./worktree.ts";
92
93
  import { repoKey } from "./repo-identity.ts";
93
94
  import { harnessSyncPort, type SyncPortFactory } from "./sync-client.ts";
94
95
  import type { SyncAnswer } from "./wire.ts";
@@ -135,32 +136,42 @@ function slotOf(kind: string, slot: string, named: string | undefined, ambient:
135
136
  );
136
137
  }
137
138
 
138
- /** Where a file body's path is read from, relative to the work root: the Repo Slot's Worktree, or
139
- * the work root itself (`.`) in a Workspace with no Repo Slot, whose steps share `/work` (ADR-0051).
140
- * The Effector opens the file beneath it either way (ADR-0067). */
141
- function fileRootOf(slot: string, named: string | undefined, ambient: AmbientHandles): string {
142
- if (named === undefined && Object.keys(ambient.repos).length === 0) return ".";
143
- return posix.relative(WORK_ROOT, ambient.repos[slotOf("http", slot, named, ambient)]!);
144
- }
145
-
146
- /** The branch of a Workspace with Repo Slots. A kind that reads it resolved a slot first, and a
147
- * Workspace with a slot always has a branch, so its absence is a jr2 defect. */
148
- function branchOf(ambient: AmbientHandles): string {
149
- if (ambient.branch === undefined) throw new Error("a Workspace with a Repo Slot has no branch — a jr2 defect");
150
- return ambient.branch;
139
+ /**
140
+ * The cut behind an Effect's `worktree`, or why there is none. A push, a credentialed command and a
141
+ * file body act with a credential, so a path alone does not name their target: the path must be a
142
+ * Worktree a cut made in this Sandbox, and the branch is the cut's record. Nothing reads HEAD, and
143
+ * no input names a branch, so a Chart cannot fill one, and `git switch` in the Worktree moves nothing.
144
+ */
145
+ function cutTargetOf(
146
+ worktree: unknown,
147
+ ctx: Pick<KindContext, "ambient" | "binding">,
148
+ opts: { repo?: string },
149
+ ): { target: { repo: string; path: string; branch: string | null } } | { fault: string } {
150
+ const bad = worktreePathFault(worktree, ctx.ambient);
151
+ if (bad !== undefined) return { fault: bad };
152
+ const row = cutRowOf(ctx.binding?.effectRows?.() ?? {}, ctx.ambient.sandbox, worktree);
153
+ if (row === undefined) return { fault: `no cut made ${String(worktree)} in this Sandbox: cut it with worktree()` };
154
+ if (opts.repo !== undefined && row.repo !== opts.repo) {
155
+ return { fault: `${String(worktree)} is a Worktree of Repo Slot "${row.repo}", not of "${opts.repo}"` };
156
+ }
157
+ return { target: row };
151
158
  }
152
159
 
153
160
  /** The slot's repositories, relative to the work root: the Agent's common git dir is the pod-local
154
161
  * clone's, beside the branch Worktree (ADR-0004); the cache is the Binding's key (ADR-0051). */
155
- export function repoRefOf(ambient: AmbientHandles, repoSlot: string): RepoRef & { url: string } {
156
- const worktree = ambient.repos[repoSlot]!;
162
+ export function repoRefOf(
163
+ ambient: AmbientHandles,
164
+ repoSlot: string,
165
+ worktree: string = ambient.repos[repoSlot]!,
166
+ ): RepoRef & { url: string } {
157
167
  const url = ambient.bindings?.[repoSlot];
158
168
  if (url === undefined) {
159
169
  throw new Error(`Repo Slot "${repoSlot}" has no Binding url in its Workspace's handles — a jr2 defect`);
160
170
  }
161
171
  const rel = posix.relative(WORK_ROOT, worktree);
162
172
  return {
163
- gitDir: posix.join(posix.dirname(rel), "default", ".git"),
173
+ // The clone of the slot, read from the slot and not from the Worktree's folder.
174
+ gitDir: posix.join(repoSlot, "default", ".git"),
164
175
  worktree: rel,
165
176
  cache: repoKey(url),
166
177
  url,
@@ -225,6 +236,7 @@ export type PushOptions = {
225
236
  * (ADR-0051)`. `jr2 up` refuses it first, with the same reason.
226
237
  */
227
238
  slot?: string;
239
+ // Static, so that `jr2 up` places the credential per slot.
228
240
  /**
229
241
  * How long jr2 waits for the push: a number and a unit, such as `"90s"` or `"5m"`. Default
230
242
  * `"5m"`. With no answer in that time, the output is `unknown`. It refuses a value that is not a
@@ -233,8 +245,18 @@ export type PushOptions = {
233
245
  timeout?: Duration;
234
246
  };
235
247
 
236
- /** The input of a `push` Effect: `{ sha }` with `pin`, and `{ message }` with `squash`. */
237
- export type PushInput<O extends PushOptions> = (O["pin"] extends true ? { sha: string } : unknown) &
248
+ /** The input of a `push` Effect: the Worktree that a cut made, `{ sha }` with `pin`, and
249
+ * `{ message }` with `squash`. It names no branch: jr2 reads it from the cut's record, so no word of
250
+ * a Chart reaches a remote branch. The other keys are refused by name, because each is a key of a
251
+ * cut or a Turn, and a mapper's excess key is no compile error otherwise. */
252
+ export type PushInput<O extends PushOptions> = {
253
+ /** The Worktree to push: `event.output.path` of its cut, of the push's Repo Slot. */
254
+ worktree: O extends { slot: infer S extends string } ? `/work/${S}/${string}` : AnyWorktreePath;
255
+ branch?: never;
256
+ to?: never;
257
+ cwd?: never;
258
+ path?: never;
259
+ } & (O["pin"] extends true ? { sha: string } : unknown) &
238
260
  (O["squash"] extends true ? { message: string } : unknown);
239
261
 
240
262
  /**
@@ -273,11 +295,24 @@ export type SyncState = SyncAnswer["sync"];
273
295
  *
274
296
  * With `busy` or `not-synced`, `syncReason` tells why, and jr2 tries again before the next push.
275
297
  *
298
+ * The union is open (`LaterOutcome`): give the rest a last arm with no guard.
299
+ *
276
300
  * @example
277
301
  * ```ts
278
- * import { fail, jr2Setup, push, type PushOutput, workspace, type Workspaced } from "@jr2/orchestrator";
302
+ * import {
303
+ * fail,
304
+ * isReady,
305
+ * jr2Setup,
306
+ * push,
307
+ * type PushOutput,
308
+ * workspace,
309
+ * worktree,
310
+ * type Workspaced,
311
+ * } from "@jr2/orchestrator";
312
+ *
313
+ * type Input = Workspaced<{ branch: string; sha: string }, "app">;
279
314
  *
280
- * type Input = Workspaced<{ sha: string }, "app">;
315
+ * const releaseTree = (context: Input) => `${context.workspace.repos.app}/release` as const;
281
316
  *
282
317
  * // A push that did not happen (`rejected` or `diverged`) is a different problem from one jr2
283
318
  * // cannot see (`unknown`).
@@ -286,17 +321,24 @@ export type SyncState = SyncAnswer["sync"];
286
321
  *
287
322
  * const body = jr2Setup({
288
323
  * types: {} as { context: Input; input: Input },
289
- * actors: { publish: push({ pin: true }), fail: fail() },
324
+ * actors: { cut: worktree({ expect: "existing" }), publish: push({ pin: true }), fail: fail() },
290
325
  * }).createMachine({
291
326
  * id: "release",
292
327
  * context: ({ input }) => input,
293
328
  * on: { "workspace.lost": ".lost" },
294
- * initial: "publishing",
329
+ * initial: "cutting",
295
330
  * states: {
331
+ * cutting: {
332
+ * invoke: {
333
+ * src: "cut",
334
+ * input: ({ context }) => ({ path: releaseTree(context), branch: context.branch }),
335
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "publishing" }, { target: "notCut" }],
336
+ * },
337
+ * },
296
338
  * publishing: {
297
339
  * invoke: {
298
340
  * src: "publish",
299
- * input: ({ context }) => ({ sha: context.sha }),
341
+ * input: ({ context }) => ({ worktree: releaseTree(context), sha: context.sha }),
300
342
  * onDone: [
301
343
  * { guard: ({ event }) => event.output.outcome === "pushed", target: "published" },
302
344
  * { guard: ({ event }) => notPushed(event.output), target: "notPushed" },
@@ -308,13 +350,13 @@ export type SyncState = SyncAnswer["sync"];
308
350
  * // Not final states: each one fails the run, with its own reason.
309
351
  * notPushed: { invoke: { src: "fail", input: { reason: "the remote refused the push" } } },
310
352
  * unknown: { invoke: { src: "fail", input: { reason: "jr2 cannot tell if the push happened" } } },
353
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
311
354
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
312
355
  * },
313
356
  * });
314
357
  *
315
358
  * export const machine = workspace(body, {
316
359
  * repos: { app: { url: "https://github.com/you/app.git" } },
317
- * spec: () => ({ branch: "release" }),
318
360
  * });
319
361
  * ```
320
362
  *
@@ -324,10 +366,11 @@ export type SyncState = SyncAnswer["sync"];
324
366
  */
325
367
  export type PushOutput =
326
368
  | Exclude<PushWireOutput, { outcome: "pushed" }>
327
- | (Omit<Extract<PushWireOutput, { outcome: "pushed" }>, "bundle"> & { sync: SyncState; syncReason?: string });
369
+ | (Omit<Extract<PushWireOutput, { outcome: "pushed" }>, "bundle"> & { sync: SyncState; syncReason?: string })
370
+ | LaterOutcome;
328
371
 
329
372
  export function pushParts(options: PushOptions = {}): PushParts {
330
- const inputKeys = [...(options.pin ? ["sha"] : []), ...(options.squash ? ["message"] : [])];
373
+ const inputKeys = ["worktree", ...(options.pin ? ["sha"] : []), ...(options.squash ? ["message"] : [])];
331
374
  return {
332
375
  kind: "push",
333
376
  pin: options.pin === true,
@@ -451,6 +494,7 @@ type KindContext = {
451
494
  slot: string;
452
495
  signal: AbortSignal;
453
496
  mark: (marker: Omit<EffectMarker, "kind" | "slot" | "effect">) => void;
497
+ input: unknown;
454
498
  };
455
499
 
456
500
  /** How one Sandbox kind builds its start and what a restore may send again. */
@@ -463,13 +507,25 @@ type KindSpec = {
463
507
  /** The Repo Slot the Effect runs on, written to its settled row. */
464
508
  repoOf: (ambient: AmbientHandles, slot: string) => string;
465
509
  /** The start for this run; `pinned` is the commit the intent row recorded. */
466
- build: (input: unknown, ambient: AmbientHandles, slot: string, pinned?: string, extra?: StartExtra) => StartRequest;
510
+ build: (
511
+ input: unknown,
512
+ ambient: AmbientHandles,
513
+ slot: string,
514
+ pinned?: string,
515
+ extra?: StartExtra,
516
+ binding?: ReturnType<typeof boundRun>,
517
+ ) => StartRequest;
467
518
  /** Run before a start is sent (a push: the last sync again, and the ledger's pair). */
468
519
  before?: (ctx: KindContext) => Promise<StartExtra>;
469
520
  /** Run on the Effector's settled output before it is ledgered (a push: the sync). */
470
521
  finish?: (output: unknown, ctx: KindContext) => Promise<unknown>;
471
522
  /** What a settled output becomes for the Machine. */
472
523
  deliver?: (output: unknown) => unknown;
524
+ /** Why this start cannot be sent, as the kind's own output: a word an Agent or a Chart can say.
525
+ * It settles before any intent row: nothing went out. */
526
+ refuse?: (ctx: KindContext) => unknown;
527
+ /** The fields of the settled row that a later Effect reads. */
528
+ rowFields?: (ctx: KindContext) => { branch?: string; worktree?: string; repo?: string };
473
529
  };
474
530
 
475
531
  type StartExtra = { pairs?: readonly Pair[] };
@@ -548,6 +604,9 @@ async function drive<I, O>(
548
604
  kind: spec.kind,
549
605
  slot,
550
606
  ...(ambient ? { sandbox: ambient.sandbox, ...repoField(spec, ambient, slot) } : {}),
607
+ ...(ambient && spec.rowFields
608
+ ? spec.rowFields({ ambient, binding, id, slot, signal, mark, input: snapshot.input })
609
+ : {}),
551
610
  output,
552
611
  at: now(),
553
612
  });
@@ -589,7 +648,7 @@ async function drive<I, O>(
589
648
  // A credentialed command's stop goes through this port when its state exits (ADR-0067).
590
649
  const run = running.get(self);
591
650
  if (spec.kind === "command" && run) run.port = port;
592
- const ctx: KindContext = { ambient, binding, id, slot, signal, mark };
651
+ const ctx: KindContext = { ambient, binding, id, slot, signal, mark, input: snapshot.input };
593
652
 
594
653
  const find = async (): Promise<{ found: EffectState | undefined } | undefined> => {
595
654
  try {
@@ -619,12 +678,12 @@ async function drive<I, O>(
619
678
  });
620
679
  const extra = spec.before ? await spec.before(ctx) : {};
621
680
  if (signal.aborted) return;
622
- await send(spec.build(snapshot.input, ambient, slot, pinned, extra), pinned);
681
+ await send(spec.build(snapshot.input, ambient, slot, pinned, extra, binding), pinned);
623
682
  return;
624
683
  }
625
684
  if (spec.kind === "http" && spec.idempotent) {
626
685
  mark({ stage: "started", text: `${spec.label(ambient)} (sent again: it is idempotent)` });
627
- await send(spec.build(snapshot.input, ambient, slot), undefined);
686
+ await send(spec.build(snapshot.input, ambient, slot, undefined, undefined, binding), undefined);
628
687
  return;
629
688
  }
630
689
  settle(unknown(`${why}; not run again`));
@@ -645,9 +704,14 @@ async function drive<I, O>(
645
704
  return;
646
705
  }
647
706
 
707
+ const refusal = spec.refuse?.(ctx);
708
+ if (refusal !== undefined) {
709
+ settle(refusal);
710
+ return;
711
+ }
648
712
  const extra = spec.before ? await spec.before(ctx) : {};
649
713
  if (signal.aborted) return;
650
- const req = spec.build(snapshot.input, ambient, slot, undefined, extra);
714
+ const req = spec.build(snapshot.input, ambient, slot, undefined, extra, binding);
651
715
  const big = tooLarge(req);
652
716
  if (big) {
653
717
  settle(big);
@@ -857,40 +921,65 @@ export function pushWith<const O extends PushOptions>(
857
921
  portFactory: EffectorPortFactory,
858
922
  options?: O,
859
923
  syncPorts: SyncPortFactory = harnessSyncPort,
860
- ): EffectLogic<PushInput<O> | void, PushOutput> {
924
+ ): EffectLogic<PushInput<O>, PushOutput> {
861
925
  const parts = pushParts(options);
862
926
  const repoOf = (ambient: AmbientHandles, slot: string) => slotOf("push", slot, parts.slot, ambient);
863
- const worktreeOf = (ctx: KindContext) => ctx.ambient.repos[repoOf(ctx.ambient, ctx.slot)]!;
864
927
  const syncOf = (ctx: KindContext) =>
865
928
  syncPorts(ctx.ambient.endpoint, ctx.binding?.harnessBearer?.(ctx.ambient.sandbox));
929
+ const worktreeOf = (input: unknown) => (input as { worktree?: unknown } | undefined)?.worktree;
930
+ /** The target of this push: the cut's row. `refuse` has run, so a build finds one; a restored push
931
+ * finds it too, because a row outlives the restart. */
932
+ const targetOf = (ctx: Pick<KindContext, "ambient" | "binding" | "input" | "slot">) => {
933
+ const found = cutTargetOf(worktreeOf(ctx.input), ctx, { repo: repoOf(ctx.ambient, ctx.slot) });
934
+ if ("fault" in found) throw new Error(`push "${ctx.slot}": ${found.fault}`);
935
+ return found.target as { repo: string; path: string; branch: string };
936
+ };
866
937
  return sandboxLogic(portFactory, parts, {
867
938
  kind: "push",
868
939
  idempotent: false,
869
940
  repeatsPinned: true,
870
- label: (ambient) => `push ${ambient.branch}${parts.sign ? " (signed)" : ""}`,
941
+ label: () => `push${parts.sign ? " (signed)" : ""}`,
871
942
  repoOf,
872
- build: (input, ambient, slot, pinned, extra) => {
873
- const repoSlot = slotOf("push", slot, parts.slot, ambient);
874
- const ref = repoRefOf(ambient, repoSlot);
875
- const req = pushStartOf(parts, ref, branchOf(ambient), input as { sha?: string; message?: string }, extra?.pairs);
943
+ // Anything a Chart or a pick can say, so it is output (ADR-0067): `rejected` at stage start.
944
+ refuse: (ctx) => {
945
+ const found = cutTargetOf(worktreeOf(ctx.input), ctx, { repo: repoOf(ctx.ambient, ctx.slot) });
946
+ const fault =
947
+ "fault" in found
948
+ ? found.fault
949
+ : found.target.branch === null
950
+ ? `${found.target.path} is detached: a push publishes the branch of a Worktree, and it holds none`
951
+ : undefined;
952
+ if (fault === undefined) return undefined;
953
+ return { outcome: "rejected", stage: "start", reason: `${fault}; nothing was pushed` };
954
+ },
955
+ rowFields: (ctx) => {
956
+ const found = cutTargetOf(worktreeOf(ctx.input), ctx, { repo: repoOf(ctx.ambient, ctx.slot) });
957
+ if ("fault" in found || found.target.branch === null) return {};
958
+ return { branch: found.target.branch };
959
+ },
960
+ build: (input, ambient, slot, pinned, extra, binding) => {
961
+ const target = targetOf({ ambient, binding, input, slot });
962
+ const ref = repoRefOf(ambient, target.repo, target.path);
963
+ const req = pushStartOf(parts, ref, target.branch, input as { sha?: string; message?: string }, extra?.pairs);
876
964
  if (pinned) req.push.pin = pinned;
877
965
  return req;
878
966
  },
879
- // Every push of this Workspace's Repo Slot — one branch, one remote — that published gives the
880
- // Effector a pair, oldest first (ADR-0067). The newest one's sync, when it did not finish, runs
881
- // again first, in that slot's Worktree and from the bundle the row keeps while it is the newest
967
+ // Every push of this Repo Slot's branch that published gives the Effector a pair, oldest first
968
+ // (ADR-0067). The newest one's sync, when it did not finish, runs again first, in this push's
969
+ // Worktree and from the bundle the row keeps while it is the newest
882
970
  // push and its sync did not finish (`dropSavedTails`); the push does not depend on it.
883
971
  before: async (ctx) => {
884
972
  const rows = ctx.binding?.effectRows?.() ?? {};
885
973
  const repo = repoOf(ctx.ambient, ctx.slot);
886
- const last = lastPushOf(rows, ctx.ambient.sandbox, repo, ctx.id);
974
+ const target = targetOf(ctx);
975
+ const last = lastPushOf(rows, ctx.ambient.sandbox, repo, target.branch, ctx.id);
887
976
  if (last === undefined) return {};
888
977
  const { row, rowId, output } = last;
889
978
  if (output.sync === "busy" || output.sync === "not-synced") {
890
979
  const answer = await syncOf(ctx).sync(
891
980
  {
892
- worktree: worktreeOf(ctx),
893
- branch: branchOf(ctx.ambient),
981
+ worktree: target.path,
982
+ branch: target.branch,
894
983
  published: output.published,
895
984
  source: output.source,
896
985
  ...(typeof output.bundle === "string" ? { bundle: output.bundle } : {}),
@@ -902,7 +991,7 @@ export function pushWith<const O extends PushOptions>(
902
991
  ctx.binding?.recordEffect?.(rowId, { ...row, output: { ...output, ...syncFields(answer) } });
903
992
  }
904
993
  // Each row's `squashed` rides beside its pair: a push that maps the same commit names what it is.
905
- return { pairs: pushPairsOf(rows, ctx.ambient.sandbox, repo, ctx.id) };
994
+ return { pairs: pushPairsOf(rows, ctx.ambient.sandbox, repo, target.branch, ctx.id) };
906
995
  },
907
996
  // After every pushed outcome whose published commit is not the Agent's own — signed copies, or
908
997
  // one squashed commit, signed or not (ADR-0067): the sync, in the Workspace's Harness, from the
@@ -913,10 +1002,11 @@ export function pushWith<const O extends PushOptions>(
913
1002
  const o = output as PushWireOutput;
914
1003
  if (o.outcome !== "pushed") return o;
915
1004
  if (o.published === o.source) return { ...o, sync: "skipped" satisfies SyncState };
1005
+ const target = targetOf(ctx);
916
1006
  const answer = await syncOf(ctx).sync(
917
1007
  {
918
- worktree: worktreeOf(ctx),
919
- branch: branchOf(ctx.ambient),
1008
+ worktree: target.path,
1009
+ branch: target.branch,
920
1010
  published: o.published,
921
1011
  source: o.source,
922
1012
  ...(o.bundle !== undefined ? { bundle: o.bundle } : {}),
@@ -931,7 +1021,7 @@ export function pushWith<const O extends PushOptions>(
931
1021
  const { bundle: _bundle, ...rest } = o;
932
1022
  return rest;
933
1023
  },
934
- }) as EffectLogic<PushInput<O> | void, PushOutput>;
1024
+ }) as EffectLogic<PushInput<O>, PushOutput>;
935
1025
  }
936
1026
 
937
1027
  /** The sync's answer as the output's fields: its state, and its reason when it moved nothing. */
@@ -975,16 +1065,35 @@ export function httpFileWith<S extends z.ZodType | undefined = undefined>(
975
1065
  // With no Repo Slot the file is in `/work`, which is no Worktree (ADR-0051).
976
1066
  label: (ambient) =>
977
1067
  `http ${parts.method} ${parts.target.scheme}://${parts.target.host} ` +
978
- (Object.keys(ambient.repos).length === 0 && body.slot === undefined ? "(a file of /work)" : "(a Worktree file)"),
979
- repoOf: (ambient, slot) => slotOf("http", slot, body.slot, ambient),
1068
+ (Object.keys(ambient.repos).length === 0 ? "(a file of /work)" : "(a Worktree file)"),
1069
+ repoOf: (ambient, slot) => slotOf("http", slot, undefined, ambient),
1070
+ // The same row check as a push: a word of a Chart must not choose what leaves the Sandbox with a
1071
+ // credential. `refused`, the kind's own form.
1072
+ refuse: (ctx) => {
1073
+ const named = (ctx.input as { worktree?: unknown } | undefined)?.worktree;
1074
+ if (Object.keys(ctx.ambient.repos).length === 0) {
1075
+ return named === undefined
1076
+ ? undefined
1077
+ : { outcome: "refused", reason: `no Worktree in a Workspace with no Repo Slot; nothing was sent` };
1078
+ }
1079
+ if (named === undefined) {
1080
+ throw new Error(
1081
+ `http "${ctx.slot}" reads a Worktree file, under a Workspace with Repo Slots, and its input names no \`worktree\``,
1082
+ );
1083
+ }
1084
+ const found = cutTargetOf(named, ctx, {});
1085
+ return "fault" in found ? { outcome: "refused", reason: `${found.fault}; nothing was sent` } : undefined;
1086
+ },
980
1087
  build: (input, ambient, slot) => {
981
- let value: unknown = input;
1088
+ const { worktree: named, ...rest } = (input ?? {}) as { worktree?: string };
1089
+ let value: unknown = rest;
982
1090
  if (def.input !== undefined) {
983
- const parsed = def.input.safeParse(input);
1091
+ const parsed = def.input.safeParse(rest);
984
1092
  if (!parsed.success) throw new Error(`http "${slot}": its input does not match the definition's input schema`);
985
1093
  value = parsed.data;
986
1094
  }
987
- const worktree = fileRootOf(slot, body.slot, ambient);
1095
+ // With no Repo Slot the file is in `/work`, which its steps share (ADR-0051).
1096
+ const worktree = named === undefined ? "." : posix.relative(WORK_ROOT, named);
988
1097
  const segments = fromInput<readonly string[]>(body.segments, value);
989
1098
  return fileStartOf(parts, worktree, {
990
1099
  path: fromInput<string>(body.path, value)!,
@@ -1007,10 +1116,30 @@ export function credentialedCommandWith(
1007
1116
  idempotent: false,
1008
1117
  repeatsPinned: true,
1009
1118
  label: () => parts.argv.map((a) => (typeof a === "string" ? a : `<${(a as Arg).arg}>`)).join(" "),
1010
- repoOf: (ambient, slot) => slotOf("command", slot, parts.slot, ambient),
1011
- build: (input, ambient, slot, pinned) => {
1012
- const repoSlot = slotOf("command", slot, parts.slot, ambient);
1013
- const ref = repoRefOf(ambient, repoSlot);
1119
+ repoOf: (ambient, slot) => slotOf("command", slot, undefined, ambient),
1120
+ // The same row check as a push, in the form a command that could not start has (ADR-0067). No
1121
+ // Repo Slot at all is a defect of the Machine, as for a push: a throw, and `onError` (ADR-0051).
1122
+ refuse: (ctx) => {
1123
+ if (Object.keys(ctx.ambient.repos).length === 0) {
1124
+ throw new Error(
1125
+ `command "${ctx.slot}" runs under a Workspace with no Repo Slot: ${needsASlot("command")} (ADR-0051)`,
1126
+ );
1127
+ }
1128
+ const found = cutTargetOf((ctx.input as { worktree?: unknown } | undefined)?.worktree, ctx, {});
1129
+ const fault =
1130
+ "fault" in found
1131
+ ? found.fault
1132
+ : found.target.branch === null
1133
+ ? `${found.target.path} is detached, and a credentialed command runs on a commit of a branch`
1134
+ : undefined;
1135
+ return fault === undefined
1136
+ ? undefined
1137
+ : { outcome: "exited", code: 127, tail: `jr2: ${fault}; nothing was run\n` };
1138
+ },
1139
+ build: (input, ambient, slot, pinned, _extra, binding) => {
1140
+ const found = cutTargetOf((input as { worktree?: unknown } | undefined)?.worktree, { ambient, binding }, {});
1141
+ if ("fault" in found) throw new Error(`command "${slot}": ${found.fault}`);
1142
+ const ref = repoRefOf(ambient, found.target.repo, found.target.path);
1014
1143
  const approved = pin ? (input as { sha?: unknown } | undefined)?.sha : undefined;
1015
1144
  if (pin && (typeof approved !== "string" || !/^[0-9a-f]{40}$/.test(approved))) {
1016
1145
  throw new Error(`command "${slot}" (pin): its input's sha is not a full commit id`);
@@ -1019,7 +1148,7 @@ export function credentialedCommandWith(
1019
1148
  parts,
1020
1149
  argvOf(input, slot),
1021
1150
  ref,
1022
- branchOf(ambient),
1151
+ found.target.branch!,
1023
1152
  pinned ?? (approved as string | undefined),
1024
1153
  );
1025
1154
  },
@@ -1027,10 +1156,13 @@ export function credentialedCommandWith(
1027
1156
  }
1028
1157
 
1029
1158
  /**
1030
- * Make a `push` Effect: it pushes the branch of the Workspace to the Binding of its Repo Slot.
1159
+ * Make a `push` Effect: it pushes the branch of a Worktree to the Binding of its Repo Slot.
1031
1160
  * Put it in `actors`, and invoke it from the body of a `workspace()`: `actors: { publish: push({
1032
1161
  * sign: true }) }`.
1033
1162
  *
1163
+ * Its input names the Worktree: `{ worktree }`. It has no branch. jr2 reads the branch from the
1164
+ * `worktree()` cut of that path, so an Agent that moves `HEAD` does not change what jr2 pushes.
1165
+ *
1034
1166
  * It runs in the Sandbox Effector of the Workspace, with the push credential of the
1035
1167
  * `git.credentials` entry that the Binding matches. The Agent never reads that credential. It
1036
1168
  * pushes one commit: the tip of the Agent's branch when the Effect starts, or, with `pin`, the
@@ -1038,8 +1170,10 @@ export function credentialedCommandWith(
1038
1170
  * again. With `sign` or `squash`, the commit on the remote is not the Agent's own. Then jr2 moves
1039
1171
  * the Agent's branch to it.
1040
1172
  *
1041
- * Every outcome is output (`PushOutput`): `pushed`, `rejected`, `diverged` or `unknown`. Each
1042
- * field of {@link PushOptions} tells what it refuses. When it starts, it also refuses a push
1173
+ * Every outcome is output (`PushOutput`): `pushed`, `rejected`, `diverged` or `unknown`. A
1174
+ * `worktree` that no cut made in this Sandbox, a Worktree of another Repo Slot, and a detached
1175
+ * Worktree are `rejected` at `stage` `start`, and nothing is pushed. Each field of
1176
+ * {@link PushOptions} tells what it refuses. When it starts, it also refuses a push
1043
1177
  * outside a Workspace: `push "<slot>" runs in the Sandbox Effector of a Workspace, and its state is
1044
1178
  * not inside a workspace() — invoke it from a Workspace's body (ADR-0067)`.
1045
1179
  *
@@ -1053,9 +1187,11 @@ export function credentialedCommandWith(
1053
1187
  * agent,
1054
1188
  * defineEvent,
1055
1189
  * fail,
1190
+ * isReady,
1056
1191
  * jr2Setup,
1057
1192
  * push,
1058
1193
  * workspace,
1194
+ * worktree,
1059
1195
  * type Workspaced,
1060
1196
  * } from "@jr2/orchestrator";
1061
1197
  *
@@ -1067,6 +1203,8 @@ export function credentialedCommandWith(
1067
1203
  *
1068
1204
  * type Input = Workspaced<{}, "app">;
1069
1205
  *
1206
+ * const fixTree = (context: Input) => `${context.workspace.repos.app}/fix` as const;
1207
+ *
1070
1208
  * const body = jr2Setup({
1071
1209
  * types: {} as { context: Input; input: Input },
1072
1210
  * events: [committed],
@@ -1075,6 +1213,7 @@ export function credentialedCommandWith(
1075
1213
  * model: "anthropic/claude-haiku-5-5",
1076
1214
  * instructions: "You are a careful engineer.",
1077
1215
  * }),
1216
+ * cut: worktree(),
1078
1217
  * publish: push({ sign: true }),
1079
1218
  * fail: fail(),
1080
1219
  * },
@@ -1082,10 +1221,20 @@ export function credentialedCommandWith(
1082
1221
  * id: "fix-test",
1083
1222
  * context: ({ input }) => input,
1084
1223
  * on: { "workspace.lost": ".failed", "agent.fault": ".failed" },
1085
- * initial: "coding",
1224
+ * initial: "cutting",
1086
1225
  * states: {
1226
+ * cutting: {
1227
+ * invoke: {
1228
+ * src: "cut",
1229
+ * input: ({ context }) => ({ path: fixTree(context), branch: "fix-test" }),
1230
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "coding" }, { target: "notCut" }],
1231
+ * },
1232
+ * },
1087
1233
  * coding: {
1088
- * invoke: { src: "coder", input: { prompt: "Fix the failing test, and commit the fix." } },
1234
+ * invoke: {
1235
+ * src: "coder",
1236
+ * input: ({ context }) => ({ prompt: "Fix the failing test, and commit the fix.", cwd: fixTree(context) }),
1237
+ * },
1089
1238
  * // The Agent moves the Machine on with a pick. An Agent's actor never ends, so `onDone`
1090
1239
  * // never fires.
1091
1240
  * on: { committed: "publishing" },
@@ -1093,6 +1242,8 @@ export function credentialedCommandWith(
1093
1242
  * publishing: {
1094
1243
  * invoke: {
1095
1244
  * src: "publish",
1245
+ * // jr2 reads the branch from the cut of this Worktree.
1246
+ * input: ({ context }) => ({ worktree: fixTree(context) }),
1096
1247
  * onDone: [
1097
1248
  * { guard: ({ event }) => event.output.outcome === "pushed", target: "done" },
1098
1249
  * { target: "failed" },
@@ -1100,6 +1251,7 @@ export function credentialedCommandWith(
1100
1251
  * },
1101
1252
  * },
1102
1253
  * done: { type: "final" },
1254
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
1103
1255
  * // Not a final state: the run ends `failed`, and the Workspace deletes the Sandbox.
1104
1256
  * failed: { invoke: { src: "fail", input: { reason: "the fix was not pushed" } } },
1105
1257
  * },
@@ -1107,7 +1259,6 @@ export function credentialedCommandWith(
1107
1259
  *
1108
1260
  * export const machine = workspace(body, {
1109
1261
  * repos: { app: { url: "https://github.com/you/app.git" } },
1110
- * spec: () => ({ branch: "fix-test" }),
1111
1262
  * });
1112
1263
  * ```
1113
1264
  *
@@ -1117,7 +1268,7 @@ export function credentialedCommandWith(
1117
1268
  * @see docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md
1118
1269
  * @see docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md
1119
1270
  */
1120
- export function push<const O extends PushOptions>(options?: O): EffectLogic<PushInput<O> | void, PushOutput> {
1271
+ export function push<const O extends PushOptions>(options?: O): EffectLogic<PushInput<O>, PushOutput> {
1121
1272
  // After an Orchestrator restart, a push is sent again with only the commit its intent row holds
1122
1273
  // (ADR-0067): a run is a function of the commit, never of the Agent's tip now.
1123
1274
  return pushWith(sandboxEffectorPort, options);