@jr2/orchestrator 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -5
  13. package/src/actor.ts +507 -54
  14. package/src/agent.ts +2 -2
  15. package/src/ambient.ts +2 -0
  16. package/src/assembly-checks.ts +165 -0
  17. package/src/assembly-tar.ts +494 -0
  18. package/src/assembly.ts +721 -0
  19. package/src/chart.ts +2050 -0
  20. package/src/charts.ts +720 -0
  21. package/src/config.ts +233 -22
  22. package/src/content-hash.ts +105 -0
  23. package/src/custodian.lua +313 -0
  24. package/src/custodian.ts +951 -0
  25. package/src/customize.ts +216 -25
  26. package/src/effect-checks.ts +557 -0
  27. package/src/effect.ts +905 -0
  28. package/src/effector-wire.ts +525 -0
  29. package/src/fingerprint.ts +4 -3
  30. package/src/gate.ts +8 -0
  31. package/src/git-clone.ts +65 -0
  32. package/src/git-push.ts +336 -0
  33. package/src/harness-client.ts +247 -25
  34. package/src/held-secrets.ts +643 -0
  35. package/src/holds.ts +156 -0
  36. package/src/http-effect.ts +981 -0
  37. package/src/http.ts +514 -119
  38. package/src/images.ts +58 -31
  39. package/src/index.ts +49 -4
  40. package/src/instance.ts +445 -59
  41. package/src/kube-client.ts +333 -0
  42. package/src/machine-doc.ts +88 -6
  43. package/src/model-keys.ts +92 -0
  44. package/src/mount-digest.ts +53 -0
  45. package/src/names.ts +89 -7
  46. package/src/notices.ts +88 -0
  47. package/src/open-body.ts +1444 -0
  48. package/src/open-effect.ts +144 -0
  49. package/src/open.ts +4 -4
  50. package/src/parts.ts +530 -33
  51. package/src/party.ts +468 -0
  52. package/src/pick.ts +309 -0
  53. package/src/pool.ts +1 -1
  54. package/src/registration.ts +301 -17
  55. package/src/repo-fetch.ts +61 -86
  56. package/src/repos.ts +63 -74
  57. package/src/rest.ts +133 -0
  58. package/src/run-host.ts +1841 -105
  59. package/src/sandbox-effect.ts +891 -0
  60. package/src/sandbox-effector.ts +111 -0
  61. package/src/sandbox-kube.ts +1719 -0
  62. package/src/sandbox-watch.ts +260 -0
  63. package/src/secret-placement.ts +494 -0
  64. package/src/server.ts +285 -89
  65. package/src/setup.ts +589 -70
  66. package/src/size.ts +342 -0
  67. package/src/snapshot-store.ts +64 -13
  68. package/src/stub-harness.ts +5 -0
  69. package/src/sync-client.ts +54 -0
  70. package/src/timers.ts +178 -0
  71. package/src/tokens.ts +44 -12
  72. package/src/vocabulary.ts +66 -1
  73. package/src/wire.ts +156 -2
  74. package/src/workspace.ts +529 -194
  75. package/src/sandbox-kubectl.ts +0 -1144
package/src/effect.ts ADDED
@@ -0,0 +1,905 @@
1
+ // The Effect (ADR-0067): work a Machine does outside itself, as an ACTOR SLOT beside `agent()` —
2
+ // `actors: { tests: command(["pnpm", "test"]) }`, invoked as `src: "tests"`. Being an actor gives,
3
+ // from xstate and nothing new: the Effect stops when its state exits, a typed `onDone` output to
4
+ // route on, the input kept in the persisted snapshot, a `src` typo as a compile error, and
5
+ // `.provide()` as the unit-test seam.
6
+ //
7
+ // Two kinds exist: `command`, run in the Harness container of the enclosing Workspace's Sandbox
8
+ // (the Harness's `/exec/:id`, ADR-0067), here; and `http`, run in the Instance Effector
9
+ // (http-effect.ts, ADR-0068). What this module adds to xstate is what makes an Effect safe to
10
+ // restart:
11
+ //
12
+ // - THE LEDGER. The run host keeps one row per Effect, in the run's blob beside the snapshot
13
+ // (`RunBlob.effects`): an INTENT row written before the call — in the same persist as the
14
+ // snapshot whose state invoked it, saved at the write — and a SETTLED row written with the
15
+ // output, saved at the write before the transition it drives and kept by the save that carries
16
+ // that transition. A restore reads the row, never guesses: settled → deliver the output;
17
+ // intent → re-attach to the exec by id, or, when the Harness no longer knows it, settle
18
+ // `unknown`; no row → nothing left, so it starts. A command never runs twice: it runs the
19
+ // Agent's code, which may spend a Stand-in. No row is dropped: the settled and aborted rows keep
20
+ // id, kind and outcome for the run's life, the record of what the run's Effects did. A settled
21
+ // row's output tail is dropped once the save that carries its transition is on disk
22
+ // (`dropSavedTails`): the full stream is on the feed.
23
+ // - D1. The Effect stops when its state exits — xstate stops the child, and this logic kills the
24
+ // exec's process group: the kill is sent until the Harness keeps it, and a kill that arrives
25
+ // before its start keeps that start from running (a fast exit). The abort goes to the ledger
26
+ // and the feed, "outcome unknown"; the Machine does not get it, because the state that asked
27
+ // has left. Two stops are NOT exits: the host parking the run (`hostStopping`, ADR-0024) and an
28
+ // Orchestrator that dies (it stops no actor) — the Effect re-attaches on restore, or settles
29
+ // `unknown`. There is no "finally": cleanup is a state.
30
+ // - EVERY OUTCOME IS OUTPUT: `exited | timeout | killed | unknown`. `onError` is a jr2 or config
31
+ // defect only — a command outside a Workspace, an argv slot with no value.
32
+ //
33
+ // The port factory is the dependency, as for `agentActorWith`: `commandWith(() => fake, argv)` is
34
+ // the seam, and `command()` (bound to the real wire client) lives in harness-client.ts.
35
+
36
+ import { randomUUID } from "node:crypto";
37
+ import type { ActorLogic, AnyActorRef, EventObject, Snapshot } from "xstate";
38
+ import { ambientHandlesFor, type AmbientHandles } from "./ambient.ts";
39
+ import { boundRun, type AnyActorSystem } from "./registration.ts";
40
+ import type { HttpMethod, HttpOutput, TextPart, WireAuth } from "./effector-wire.ts";
41
+ import type { ExecOutcome, ExecRead, ExecRequest, SyncAnswer } from "./wire.ts";
42
+ import { bindOpenSecretIn, type EffectorPortFactory, type OpenSecretIn } from "./http-effect.ts";
43
+ import { commandEnvParts, credentialedCommandWith, sandboxEffectorPort, type CommandEnv } from "./sandbox-effect.ts";
44
+ import { open } from "./open.ts";
45
+ import {
46
+ effectBindingShape,
47
+ openEffectLogic,
48
+ openPartsOf,
49
+ type EffectBinding,
50
+ type OpenEffect,
51
+ type OpenOf,
52
+ } from "./open-effect.ts";
53
+
54
+ // --- Parts: what `jr2 up` and the Machine doc read off a slot, without running anything ---------
55
+
56
+ /** One argv entry an Effect's input fills: ONE argument, whatever its bytes (never shell text). */
57
+ export type Arg<N extends string = string> = { readonly arg: N };
58
+ export const arg = <const N extends string>(name: N): Arg<N> => ({ arg: name });
59
+
60
+ /** A `command` Effect's plain data. */
61
+ export type CommandParts = {
62
+ readonly kind: "command";
63
+ readonly argv: readonly (string | Arg)[];
64
+ /** The env the author named (ADR-0067): each a secret's value, or its file's path. A command
65
+ * that names a secret runs in the Sandbox Effector; one with none in the Harness container. */
66
+ readonly env?: Readonly<Record<string, { readonly secret: string; readonly path?: true }>>;
67
+ /** The secrets `env` names, once each, sorted — what places them (ADR-0068). */
68
+ readonly secrets?: readonly string[];
69
+ /** `env` names `secret(open)`, which the composer binds (ADR-0054): it is a credentialed command
70
+ * before the name is known, and `env` holds `OPEN_SECRET` in its place until then. */
71
+ readonly openSecret?: true;
72
+ /** A credentialed command's `pin`: its input names the commit (`{ sha }`, an approved one) in
73
+ * place of the Agent's branch tip (ADR-0067). */
74
+ readonly pin?: true;
75
+ /** The Repo Slot whose Worktree is the cwd. Needed only where the Workspace carries several. */
76
+ readonly slot?: string;
77
+ readonly timeoutMs: number;
78
+ /** Characters of the stream's end kept in the output. The full stream goes to the feed. */
79
+ readonly tail: number;
80
+ };
81
+
82
+ /**
83
+ * An `http` Effect's plain data (ADR-0068): what `jr2 up` and the Machine doc read — the method,
84
+ * the fixed target (scheme, host, port), the url and headers as `text` parts (a secret is a part,
85
+ * by name), the auth, the secret names, the input's keys, the timeout, the body bound, and whether
86
+ * a restore may send it again.
87
+ */
88
+ export type HttpParts = {
89
+ readonly kind: "http";
90
+ readonly method: HttpMethod;
91
+ readonly target: { readonly scheme: "http" | "https"; readonly host: string; readonly port: number };
92
+ readonly url: readonly TextPart[];
93
+ readonly headers: Readonly<Record<string, readonly TextPart[]>>;
94
+ readonly auth?: WireAuth;
95
+ /** Every secret the definition names, once each, in order — what places it (ADR-0068). */
96
+ readonly secrets: readonly string[];
97
+ /** The auth or a header names `secret(open)`, which the composer binds (ADR-0054); they hold
98
+ * `OPEN_SECRET` in its place until then. */
99
+ readonly openSecret?: true;
100
+ /** The keys of the input schema: what a pick param may fill (ADR-0067's load walk reads them). */
101
+ readonly inputKeys: readonly string[];
102
+ readonly timeoutMs: number;
103
+ readonly maxBody: number;
104
+ /** Marked by the author: a restore that cannot find the Effect sends it again. */
105
+ readonly idempotent: boolean;
106
+ /** The body is a Worktree file (`body: file(...)`, ADR-0067): the Effect runs in the Sandbox Effector
107
+ * of the Workspace whose body invokes it. `slot` is the Repo Slot whose Worktree holds the file —
108
+ * needed only where the Workspace carries several. Absent: a json, text or no body, in the
109
+ * Instance Effector. */
110
+ readonly file?: { readonly slot?: string; readonly contentType?: string };
111
+ };
112
+
113
+ /**
114
+ * A `push` Effect's plain data (ADR-0067, ADR-0051): the Workspace's branch to its Repo Slot's
115
+ * Binding. `sign`, `squash`, `force` are the author's; `pin` says the Effect's input names the
116
+ * commit (an approved one) instead of the Agent's branch tip.
117
+ */
118
+ export type PushParts = {
119
+ readonly kind: "push";
120
+ readonly slot?: string;
121
+ readonly pin: boolean;
122
+ readonly sign: boolean;
123
+ readonly squash: boolean;
124
+ readonly force: boolean;
125
+ readonly timeoutMs: number;
126
+ /** A push names no secret: its credential is the `git.credentials` entry its Binding matches. */
127
+ readonly secrets: readonly string[];
128
+ readonly inputKeys: readonly string[];
129
+ };
130
+
131
+ /**
132
+ * An Effect whose url or argv a package left Open (ADR-0054): the same parts with the sentinel in
133
+ * that one place. The kind, the placement, the timeout and the secrets the other parts name are
134
+ * fixed; the Open part — and an Open secret beside it (`openSecret`) — is the composer's to bind,
135
+ * once, with `customize()` (open-effect.ts).
136
+ */
137
+ export type OpenCommandParts = Omit<CommandParts, "argv"> & { readonly argv: typeof open };
138
+ export type OpenHttpParts = Omit<HttpParts, "url" | "target"> & {
139
+ readonly url: typeof open;
140
+ readonly target: typeof open;
141
+ };
142
+
143
+ /** Every kind's parts, bound or with an Open part. */
144
+ export type EffectParts = CommandParts | HttpParts | PushParts | OpenCommandParts | OpenHttpParts;
145
+
146
+ /** The kinds, as their parts name them. */
147
+ export const EFFECT_KINDS: ReadonlySet<string> = new Set(["command", "http", "push"]);
148
+
149
+ /**
150
+ * Where an Effect runs, derived from its kind and what it names, never authored (ADR-0067,
151
+ * ADR-0068): a `command` that names no secret in the Harness container of its Workspace; an
152
+ * `http` Effect with no Worktree file in the Instance Effector; an `http` whose body is a Worktree
153
+ * file, a `push` and a `command` that names a secret in the Sandbox Effector of its Workspace.
154
+ */
155
+ export type Placement = "harness" | "instance" | "sandbox";
156
+ export function placementOf(parts: EffectParts): Placement {
157
+ switch (parts.kind) {
158
+ case "http":
159
+ return parts.file ? "sandbox" : "instance";
160
+ case "push":
161
+ return "sandbox";
162
+ case "command":
163
+ return (parts.secrets?.length ?? 0) > 0 || parts.openSecret ? "sandbox" : "harness";
164
+ }
165
+ }
166
+
167
+ /** The brand an Effect slot carries, as `definition` marks an Agent slot (ADR-0049). */
168
+ export type EffectSlot = { readonly effect: EffectParts };
169
+
170
+ /** Is this actor logic an Effect slot? A duck test on the brand, like `isAgent`. */
171
+ export function isEffect(logic: unknown): logic is EffectSlot {
172
+ if (typeof logic !== "object" || logic === null) return false;
173
+ const effect = (logic as { effect?: { kind?: unknown } }).effect;
174
+ return (
175
+ typeof effect === "object" && effect !== null && typeof effect.kind === "string" && EFFECT_KINDS.has(effect.kind)
176
+ );
177
+ }
178
+
179
+ /** argv as text a reader can print: an argv slot reads `<name>`; an Open argv reads `<open>`. */
180
+ export function argvText(argv: readonly (string | Arg)[] | typeof open): string[] {
181
+ if (argv === open) return ["<open>"];
182
+ return argv.map((a) => (typeof a === "string" ? a : `<${a.arg}>`));
183
+ }
184
+
185
+ /** An http Effect's target as text: `scheme://host` with the port when it is not the scheme's; an
186
+ * Open url reads `<open>`. */
187
+ export function targetText(parts: { readonly target: HttpParts["target"] | typeof open }): string {
188
+ if (parts.target === open) return "<open>";
189
+ const { scheme, host, port } = parts.target;
190
+ const standard = (scheme === "https" && port === 443) || (scheme === "http" && port === 80);
191
+ return `${scheme}://${host.includes(":") ? `[${host}]` : host}${standard ? "" : `:${port}`}`;
192
+ }
193
+
194
+ /** The keys an Effect's input has, read off its parts: a command's argv slot names (and `sha` with
195
+ * `pin`); an http Effect's input schema keys; a push's `sha` and `message`. Undefined for an Open
196
+ * argv: its slots are the composer's, unknown until `customize()` binds it (ADR-0054). */
197
+ export function inputKeysOf(parts: EffectParts): string[] | undefined {
198
+ if (parts.kind !== "command") return [...parts.inputKeys];
199
+ if (parts.argv === open) return undefined;
200
+ return [...parts.argv.flatMap((a) => (typeof a === "string" ? [] : [a.arg])), ...(parts.pin ? ["sha"] : [])];
201
+ }
202
+
203
+ // --- Output -------------------------------------------------------------------------------------
204
+
205
+ /** The outside world may have acted, and jr2 cannot tell: the Harness lost the exec, or its state
206
+ * exited while it ran. Output the Machine routes on; never repeated by jr2. */
207
+ export type Unknown = { outcome: "unknown"; reason: string };
208
+
209
+ /** A `command` Effect's output: it exited, its timeout killed it, a signal killed it, or unknown. A
210
+ * credentialed command's says `dirty` when the Worktree held edits it did not see (ADR-0067). */
211
+ export type CommandOutput = (ExecOutcome & { dirty?: boolean }) | Unknown;
212
+
213
+ // --- The ledger row (persisted in the run's blob, ADR-0007) --------------------------------------
214
+
215
+ /**
216
+ * One Effect's row. `intent` names where the call went — the Harness's URL and the placement whose
217
+ * bearer reaches it — so a restore re-attaches without the Workspace's handles. `settled` keeps the
218
+ * output: a restore whose transition was not saved delivers it; once that transition is saved, the
219
+ * output less its tail is the record of what ran (`dropSavedTails`). `aborted`: its state exited
220
+ * while it ran. A row replaces the one before it; none is dropped.
221
+ */
222
+ export type EffectRow =
223
+ | { state: "intent"; kind: "command"; slot: string; endpoint: string; placement: string; at: string }
224
+ /** An http Effect's intent names its placement and no replica (ADR-0068): a restore asks every
225
+ * replica. `idempotent` is the author's mark, which the restore rule reads. */
226
+ | { state: "intent"; kind: "http"; slot: string; placement: "instance"; idempotent: boolean; at: string }
227
+ /** An Effect of the Sandbox Effector (ADR-0067): its Sandbox, and — for a push or a credentialed
228
+ * command — the commit it runs against, written once the start's answer names it. A restore
229
+ * re-attaches by id, or sends a push or a credentialed command again with only that commit, and
230
+ * sends an http with a Worktree file body again only when it is marked idempotent. */
231
+ | {
232
+ state: "intent";
233
+ kind: "http" | "push" | "command";
234
+ slot: string;
235
+ placement: "sandbox";
236
+ sandbox: string;
237
+ idempotent: boolean;
238
+ pinned?: string;
239
+ at: string;
240
+ }
241
+ /** `sandbox` and `repo`: the Workspace and the Repo Slot an Effect of the Sandbox Effector ran on
242
+ * — every later push of its own Workspace and Repo Slot (one branch, one remote) sends its pair,
243
+ * and the newest push's unfinished sync runs again in that slot's Worktree. */
244
+ | {
245
+ state: "settled";
246
+ kind: EffectParts["kind"];
247
+ slot: string;
248
+ sandbox?: string;
249
+ repo?: string;
250
+ output: unknown;
251
+ at: string;
252
+ }
253
+ | { state: "aborted"; kind: EffectParts["kind"]; slot: string; at: string };
254
+
255
+ /**
256
+ * Retention (ADR-0067): drop, in place, the output tail of every settled row once a save on disk
257
+ * carries the transition its output drove — `persisted` is the snapshot that save held, and it no
258
+ * longer holds the Effect running (its transition, or with no `onDone` the child done in place). A
259
+ * restore reads a settled row's output only for an Effect its saved snapshot holds running, so
260
+ * nothing reads the tail after that save; the full stream is on the feed. An http response's body
261
+ * and headers are its tail: the settled marker on the feed carries them whole. The row keeps its
262
+ * id, kind and outcome — a command's exit code, signal or reason; a response's status, content type
263
+ * and size — for the run's life: the record of what ran. So an Effect in a loop grows the blob by
264
+ * one short row per pass, never by its tails.
265
+ */
266
+ export function dropSavedTails(effects: Record<string, EffectRow>, persisted: unknown): void {
267
+ const running = new Set<string>();
268
+ const walk = (snapshot: unknown): void => {
269
+ const children = (snapshot as { children?: Record<string, { snapshot?: unknown } | undefined> } | null)?.children;
270
+ if (!children || typeof children !== "object") return;
271
+ for (const child of Object.values(children)) {
272
+ const { effectId, status } = (child?.snapshot ?? {}) as { effectId?: unknown; status?: unknown };
273
+ if (typeof effectId === "string" && status === "active") running.add(effectId);
274
+ walk(child?.snapshot);
275
+ }
276
+ };
277
+ walk(persisted);
278
+ const newest = newestPushesOf(effects);
279
+ for (const [id, row] of Object.entries(effects)) {
280
+ if (row.state !== "settled" || running.has(id)) continue;
281
+ const output = row.output as Record<string, unknown> | null;
282
+ if (typeof output !== "object" || output === null) continue;
283
+ // A command's tail; an http response's body and headers. What stays is short: the outcome, and
284
+ // a response's status, content type and size.
285
+ const long = tailsOf(row.kind, output, newest.has(id)).filter((field) => field in output);
286
+ if (long.length === 0) continue;
287
+ const outcome = Object.fromEntries(Object.entries(output).filter(([field]) => !long.includes(field)));
288
+ effects[id] = { ...row, output: outcome };
289
+ }
290
+ }
291
+
292
+ /** The fields of a settled output that `dropSavedTails` drops, by kind. */
293
+ const TAILS: Record<EffectRow["kind"], readonly string[]> = {
294
+ command: ["tail"],
295
+ http: ["body", "headers"],
296
+ // A push row keeps its pair whole (`published`, `source`, `squashed`) and its sync's answer:
297
+ // every later push of its Repo Slot sends the pair (`pushPairsOf`), and the newest push's sync
298
+ // runs again when it did not finish. The sync's bundle and git's words are its tail.
299
+ push: ["bundle", "detail"],
300
+ };
301
+
302
+ /** A kind's tail for one output. The NEWEST push of its Repo Slot whose sync did not finish
303
+ * (`busy`, `not-synced`) keeps its bundle: the sync that runs again before the next push of that
304
+ * slot moves the branch from it, and the Agent's store may not hold the published commits without it.
305
+ * Once a sync finished, or once a newer push of the slot published, the bundle goes: only the
306
+ * newest push's sync runs again (`lastPushOf`), so an older bundle has no reader. */
307
+ function tailsOf(kind: EffectRow["kind"], output: Record<string, unknown>, newest: boolean): readonly string[] {
308
+ if (kind === "push" && newest && (output.sync === "busy" || output.sync === "not-synced")) return ["detail"];
309
+ return TAILS[kind];
310
+ }
311
+
312
+ /** A settled push row that published: its pair, and its sync's answer and bundle when it has them. */
313
+ export type PushedRow = { published: string; source: string; sync?: SyncAnswer["sync"]; bundle?: string } & Record<
314
+ string,
315
+ unknown
316
+ >;
317
+
318
+ /** The ledger's last push of a Workspace's Repo Slot that published: its row, by `at`, never this
319
+ * Effect's (`self`). A Workspace with two Repo Slots has two branches on two remotes, so two pairs. */
320
+ export function lastPushOf(
321
+ rows: Readonly<Record<string, EffectRow>>,
322
+ sandbox: string,
323
+ repo: string,
324
+ self?: string,
325
+ ): { rowId: string; row: Extract<EffectRow, { state: "settled" }>; output: PushedRow } | undefined {
326
+ let best: { rowId: string; row: Extract<EffectRow, { state: "settled" }>; output: PushedRow } | undefined;
327
+ for (const [rowId, row] of Object.entries(rows)) {
328
+ if (rowId === self || row.state !== "settled" || row.kind !== "push") continue;
329
+ if (row.sandbox !== sandbox || row.repo !== repo) continue;
330
+ const output = row.output as Partial<PushedRow> & { outcome?: unknown };
331
+ if (output?.outcome !== "pushed" || typeof output.published !== "string" || typeof output.source !== "string") {
332
+ continue;
333
+ }
334
+ if (best === undefined || row.at > best.row.at) best = { rowId, row, output: output as PushedRow };
335
+ }
336
+ return best;
337
+ }
338
+
339
+ /** Every pair the ledger holds for a Workspace's Repo Slot (ADR-0067): each settled push row that
340
+ * published, oldest first by `at`, never this Effect's (`self`) — its published commit, its source,
341
+ * and its own `squashed`. What a push start carries, so the Effector sees every push's pair. */
342
+ export function pushPairsOf(
343
+ rows: Readonly<Record<string, EffectRow>>,
344
+ sandbox: string,
345
+ repo: string,
346
+ self?: string,
347
+ ): Array<{ published: string; source: string; squashed?: "signed" | "unsigned" }> {
348
+ const found: Array<{ at: string; pair: { published: string; source: string; squashed?: "signed" | "unsigned" } }> =
349
+ [];
350
+ for (const [rowId, row] of Object.entries(rows)) {
351
+ if (rowId === self || row.state !== "settled" || row.kind !== "push") continue;
352
+ if (row.sandbox !== sandbox || row.repo !== repo) continue;
353
+ const output = row.output as Partial<PushedRow> & { outcome?: unknown; squashed?: unknown };
354
+ if (output?.outcome !== "pushed" || typeof output.published !== "string" || typeof output.source !== "string") {
355
+ continue;
356
+ }
357
+ const squashed = output.squashed === "signed" || output.squashed === "unsigned" ? output.squashed : undefined;
358
+ found.push({
359
+ at: row.at,
360
+ pair: { published: output.published, source: output.source, ...(squashed ? { squashed } : {}) },
361
+ });
362
+ }
363
+ return found.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map((f) => f.pair);
364
+ }
365
+
366
+ /** The row id of each Repo Slot's newest push that published (`lastPushOf`), over the ledger. */
367
+ function newestPushesOf(effects: Readonly<Record<string, EffectRow>>): Set<string> {
368
+ const ids = new Set<string>();
369
+ const seen = new Set<string>();
370
+ for (const row of Object.values(effects)) {
371
+ if (row.state !== "settled" || row.kind !== "push" || row.sandbox === undefined || row.repo === undefined) continue;
372
+ const key = JSON.stringify([row.sandbox, row.repo]);
373
+ if (seen.has(key)) continue;
374
+ seen.add(key);
375
+ const last = lastPushOf(effects, row.sandbox, row.repo);
376
+ if (last !== undefined) ids.add(last.rowId);
377
+ }
378
+ return ids;
379
+ }
380
+
381
+ /** One Effect line on the run's feed (Instance-token band, like the Turn markers). `output` lines
382
+ * carry the stream; `aborted` and an `unknown` settle say the outside world may have acted. */
383
+ export type EffectMarker = {
384
+ kind: "effect";
385
+ slot: string;
386
+ effect: string;
387
+ stage: "started" | "output" | "settled" | "aborted";
388
+ text?: string;
389
+ output?: CommandOutput | HttpOutput;
390
+ };
391
+
392
+ // --- The port -----------------------------------------------------------------------------------
393
+
394
+ /** The Harness's exec, as the Effect drives it. `read` answers undefined for an exec the Harness
395
+ * does not know (404): it restarted, and jr2 settles `unknown`. `start` rejects with `ExecNotSent`
396
+ * only when the request certainly never reached the Harness — the one failure it is sent again on. */
397
+ export interface ExecPort {
398
+ start(id: string, req: ExecRequest, signal: AbortSignal): Promise<void>;
399
+ read(id: string, offset: number | undefined, signal: AbortSignal): Promise<ExecRead | undefined>;
400
+ kill(id: string): Promise<void>;
401
+ forget(id: string): Promise<void>;
402
+ }
403
+ export type ExecPortFactory = (endpoint: string, bearer?: string) => ExecPort;
404
+
405
+ // --- The actor logic ----------------------------------------------------------------------------
406
+
407
+ export type EffectSnapshot<O, I> = Snapshot<O> & {
408
+ input: I | undefined;
409
+ /** Minted once per invocation and persisted with the child: a restored Effect finds its row,
410
+ * and a state entered again gets a new one. */
411
+ effectId: string;
412
+ };
413
+
414
+ type Settle = { type: "jr2.effect.settle"; output: unknown };
415
+ type Fault = { type: "jr2.effect.error"; error: unknown };
416
+
417
+ /** An Effect slot's logic: xstate `ActorLogic`, typed by its input and output, branded with its
418
+ * parts. The brand is a runtime property (`isEffect`) and part of the type, as `definition` is
419
+ * not on an Agent's: nothing substitutes a fake for an Effect's parts. */
420
+ export type EffectLogic<I, O> = ActorLogic<EffectSnapshot<O, I>, EventObject, I> & EffectSlot;
421
+
422
+ type ArgNames<A extends readonly unknown[]> = A[number] extends infer E ? (E extends Arg<infer N> ? N : never) : never;
423
+ /** A command's input: one string per argv slot, and with `pin` the approved commit (`{ sha }`);
424
+ * none when there is neither. */
425
+ export type CommandInput<A extends readonly unknown[], P = false> = [
426
+ ArgNames<A> | ([P] extends [true] ? "sha" : never),
427
+ ] extends [never]
428
+ ? void
429
+ : { [K in ArgNames<A> | ([P] extends [true] ? "sha" : never)]: string };
430
+
431
+ /** An Open argv's input (ADR-0054): its slots are the composer's, so any key is one string — what
432
+ * the package's invoke hands it, read by the argv the composer binds. The compiler checks no slot
433
+ * against it: one the invoke does not supply faults when the Effect starts (`argvOf`). */
434
+ export type OpenCommandInput<P = false> = Readonly<Record<string, string>> &
435
+ ([P] extends [true] ? { sha: string } : unknown);
436
+
437
+ export type Duration = `${number}s` | `${number}m` | `${number}h`;
438
+
439
+ export type CommandOptions = {
440
+ /** The Repo Slot whose Worktree is the cwd — needed only where the Workspace carries several. */
441
+ slot?: string;
442
+ /** Default 10m. On expiry the Harness kills the process group: output `timeout`. */
443
+ timeout?: Duration;
444
+ /** Characters of the stream's end kept in the output. Default 4096. */
445
+ tail?: number;
446
+ /** The env, each a secret's value — `secret("x")` — or its file's path — `secret("x").path`
447
+ * (ADR-0067). Naming a secret places the command in the Sandbox Effector, against the pinned
448
+ * commit; with none it runs in the Harness container. */
449
+ env?: CommandEnv;
450
+ /** A credentialed command's commit: the Effect's input names it (`{ sha }`, an approved one);
451
+ * absent, the Agent's branch tip when it starts. */
452
+ pin?: boolean;
453
+ };
454
+
455
+ /** The Open part a command's options state (ADR-0054): its secret, when its env holds
456
+ * `secret(open)` or its `.path`. */
457
+ export type EnvOpenParts<Opt> = Opt extends { readonly env: infer E }
458
+ ? OpenSecretIn<E extends Readonly<Record<string, infer V>> ? V : never>
459
+ : never;
460
+
461
+ const DEFAULT_TIMEOUT_MS = 10 * 60_000;
462
+ const DEFAULT_TAIL = 4096;
463
+ /** How long a re-asked request waits, at most, after a transport failure. */
464
+ const RETRY_MAX_MS = 10_000;
465
+
466
+ /** The running side of one live Effect actor — what its stop reads. `timeoutMs`: how long its stop
467
+ * keeps sending the kill — past it the Harness's own timer has ended the group. */
468
+ type Running = { controller: AbortController; port?: ExecPort; started: boolean; timeoutMs: number };
469
+ const running = new WeakMap<AnyActorRef, Running>();
470
+
471
+ /**
472
+ * A `command` Effect's slot logic over an injected port factory — the seam `command()` and the
473
+ * unit tests share. `argv` is never shell text: an `arg("x")` entry is ONE argument, filled from
474
+ * the input's `x`.
475
+ */
476
+ export function commandWith<const Opt extends CommandOptions = CommandOptions>(
477
+ portFactory: ExecPortFactory,
478
+ argv: typeof open,
479
+ options?: Opt,
480
+ effectorPorts?: EffectorPortFactory,
481
+ ): EffectLogic<OpenCommandInput<Opt["pin"]>, CommandOutput> & OpenEffect<"argv" | EnvOpenParts<Opt>>;
482
+ export function commandWith<
483
+ const A extends readonly (string | Arg)[],
484
+ const Opt extends CommandOptions = CommandOptions,
485
+ >(
486
+ portFactory: ExecPortFactory,
487
+ argv: A,
488
+ options?: Opt,
489
+ effectorPorts?: EffectorPortFactory,
490
+ ): EffectLogic<CommandInput<A, Opt["pin"]>, CommandOutput> & OpenOf<EnvOpenParts<Opt>>;
491
+ export function commandWith(
492
+ portFactory: ExecPortFactory,
493
+ argv: readonly (string | Arg)[] | typeof open,
494
+ options: CommandOptions = {},
495
+ effectorPorts: EffectorPortFactory = sandboxEffectorPort,
496
+ // The implementation of the overloads above: each one states the input type.
497
+ ): EffectLogic<any, CommandOutput> {
498
+ if (argv === open) {
499
+ // An Open argv (ADR-0054): the options are checked now, by the same constructor a bound one
500
+ // goes through — a probe argv takes the composer's place — and the unbound logic carries the
501
+ // parts with the sentinel in the argv's place. Binding calls this constructor again, whole.
502
+ const probe = commandWith(portFactory, ["<open>"], options, effectorPorts);
503
+ const parts: OpenCommandParts = { ...(probe.effect as CommandParts), argv: open };
504
+ return openCommandOf(parts, (binding) => {
505
+ const bound = bindCommand(binding, options);
506
+ return commandWith(portFactory, (binding.argv ?? open) as readonly (string | Arg)[], bound, effectorPorts);
507
+ });
508
+ }
509
+ if (!Array.isArray(argv) || argv.length === 0) {
510
+ throw new Error("command(argv): argv is a non-empty list — each entry one argument, never shell text");
511
+ }
512
+ for (const a of argv) {
513
+ if (typeof a !== "string" && !(typeof a === "object" && a !== null && typeof (a as Arg).arg === "string")) {
514
+ throw new Error(`command(argv): an argv entry is a string or arg("name"), got ${JSON.stringify(a)}`);
515
+ }
516
+ }
517
+ const parts: CommandParts = {
518
+ kind: "command",
519
+ argv: [...argv],
520
+ timeoutMs: options.timeout === undefined ? DEFAULT_TIMEOUT_MS : parseDuration(options.timeout),
521
+ tail: options.tail ?? DEFAULT_TAIL,
522
+ ...(options.slot !== undefined ? { slot: options.slot } : {}),
523
+ ...(options.env !== undefined && Object.keys(options.env).length > 0 ? commandEnvParts(options.env) : {}),
524
+ ...(options.pin === true ? { pin: true as const } : {}),
525
+ };
526
+ if (parts.pin && (parts.secrets?.length ?? 0) === 0 && !parts.openSecret) {
527
+ // A command that names no secret runs in the Harness container, in the live Worktree: there
528
+ // is no pinned checkout for `pin` to choose (ADR-0067).
529
+ throw new Error(
530
+ "command(argv, { pin: true }) names no secret in its env: `pin` chooses the commit a credentialed command " +
531
+ "runs against in the Sandbox Effector, and a command that names no secret runs in the live Worktree " +
532
+ '(ADR-0067) — name its secret, command(argv, { env: { NAME: secret("x") }, pin: true }), or drop `pin`',
533
+ );
534
+ }
535
+ if (parts.openSecret) {
536
+ // An Open secret in its env (ADR-0054): a credentialed command whose secret the composer names.
537
+ return openCommandOf(parts, (binding) => {
538
+ const bound = bindCommand(binding, options);
539
+ return commandWith(portFactory, argv, bound, effectorPorts);
540
+ });
541
+ }
542
+ if ((parts.secrets?.length ?? 0) > 0) {
543
+ // Naming a secret is the placement (ADR-0067): the Sandbox Effector runs it.
544
+ return credentialedCommandWith(
545
+ effectorPorts,
546
+ parts,
547
+ (input, slot) => argvOf(slot, parts, input),
548
+ parts.pin === true,
549
+ ) as unknown as EffectLogic<unknown, CommandOutput>;
550
+ }
551
+ type I = unknown;
552
+ type O = CommandOutput;
553
+ const logic: ActorLogic<EffectSnapshot<O, I>, EventObject, I> = {
554
+ getInitialSnapshot: (_scope, input) => ({
555
+ status: "active",
556
+ output: undefined,
557
+ error: undefined,
558
+ input,
559
+ effectId: randomUUID(),
560
+ }),
561
+ transition: (snapshot, event, { self, system }) => {
562
+ if (snapshot.status !== "active") return snapshot;
563
+ if (event.type === "jr2.effect.settle") {
564
+ return { ...snapshot, status: "done", output: (event as Settle).output as O, input: undefined };
565
+ }
566
+ if (event.type === "jr2.effect.error") {
567
+ return { ...snapshot, status: "error", error: (event as Fault).error, input: undefined };
568
+ }
569
+ if (event.type === "xstate.stop") {
570
+ stopped(self, system, snapshot.effectId);
571
+ return { ...snapshot, status: "stopped", input: undefined };
572
+ }
573
+ return snapshot;
574
+ },
575
+ start: (snapshot, { self, system }) => {
576
+ if (snapshot.status !== "active") return;
577
+ const run: Running = { controller: new AbortController(), started: false, timeoutMs: parts.timeoutMs };
578
+ running.set(self, run);
579
+ void drive(self, system, snapshot, parts, portFactory, run).catch((error: unknown) => {
580
+ if (run.controller.signal.aborted) return;
581
+ if (self.getSnapshot().status === "active") self.send({ type: "jr2.effect.error", error } as Fault);
582
+ });
583
+ },
584
+ getPersistedSnapshot: (snapshot) => snapshot,
585
+ };
586
+ return Object.assign(logic, { effect: parts });
587
+ }
588
+
589
+ /** The slot's name as its parent invoked it — what the ledger and the feed call the Effect. */
590
+ const slotOf = (self: AnyActorRef): string => (typeof self.src === "string" ? self.src : self.id);
591
+
592
+ /** Run, re-attach to, or deliver one Effect, by its ledger row (see the module header). */
593
+ async function drive<I, O>(
594
+ self: AnyActorRef,
595
+ system: AnyActorSystem,
596
+ snapshot: EffectSnapshot<O, I>,
597
+ parts: CommandParts,
598
+ portFactory: ExecPortFactory,
599
+ run: Running,
600
+ ): Promise<void> {
601
+ const binding = boundRun(system);
602
+ const id = snapshot.effectId;
603
+ const slot = slotOf(self);
604
+ const signal = run.controller.signal;
605
+ const now = (): string => new Date().toISOString();
606
+ const mark = (marker: Omit<EffectMarker, "kind" | "slot" | "effect">): void =>
607
+ binding?.marker?.({ kind: "effect", slot, effect: id, ...marker });
608
+ const settle = (output: CommandOutput): void => {
609
+ if (signal.aborted || self.getSnapshot().status !== "active") return;
610
+ // The settled row first, saved at the write: a restart before the transition is saved delivers
611
+ // this output, and runs nothing.
612
+ binding?.recordEffect?.(id, { state: "settled", kind: parts.kind, slot, output, at: now() });
613
+ mark({ stage: "settled", output });
614
+ self.send({ type: "jr2.effect.settle", output } satisfies Settle);
615
+ };
616
+
617
+ const row = binding?.effectRow?.(id);
618
+ if (row?.state === "settled") {
619
+ // Settled before the restart, its transition unsaved: the output is delivered, nothing runs.
620
+ await Promise.resolve();
621
+ if (!signal.aborted && self.getSnapshot().status === "active") {
622
+ self.send({ type: "jr2.effect.settle", output: row.output } satisfies Settle);
623
+ }
624
+ return;
625
+ }
626
+ if (row?.state === "aborted") {
627
+ settle({ outcome: "unknown", reason: "its state exited while it ran; not run again" });
628
+ return;
629
+ }
630
+ if (row?.state === "intent" && row.kind === "command" && "endpoint" in row) {
631
+ // In flight at an Orchestrator restart: re-attach by id, or settle `unknown`. Never run again.
632
+ const port = portFactory(row.endpoint, binding?.harnessBearer?.(row.placement));
633
+ run.port = port;
634
+ run.started = true;
635
+ const first = await retrying(() => port.read(id, undefined, signal), signal);
636
+ if (signal.aborted) return;
637
+ if (first === undefined) {
638
+ const output: Unknown = {
639
+ outcome: "unknown",
640
+ reason: "it was running when the Orchestrator restarted, and the Harness no longer knows it; not run again",
641
+ };
642
+ settle(output);
643
+ return;
644
+ }
645
+ await follow(port, id, first, signal, mark, settle);
646
+ return;
647
+ }
648
+
649
+ // Fresh: nothing was sent. Where it runs is the enclosing Workspace's Harness — refused outside one.
650
+ const ambient = ambientHandlesFor(self);
651
+ if (!ambient) {
652
+ throw new Error(
653
+ `command "${slot}" runs in the Harness container of a Workspace, and its state is not inside a ` +
654
+ `workspace() — invoke it from a Workspace's body (ADR-0067)`,
655
+ );
656
+ }
657
+ const req: ExecRequest = {
658
+ argv: argvOf(slot, parts, snapshot.input),
659
+ ...cwdOf(slot, parts, ambient),
660
+ timeoutMs: parts.timeoutMs,
661
+ tail: parts.tail,
662
+ };
663
+ const port = portFactory(ambient.endpoint, binding?.harnessBearer?.(ambient.sandbox));
664
+ run.port = port;
665
+ // Write-ahead: the intent row is saved, with the snapshot that holds this child, BEFORE the
666
+ // request leaves. A restart after this line re-attaches; before it, nothing was sent.
667
+ binding?.recordEffect?.(id, {
668
+ state: "intent",
669
+ kind: parts.kind,
670
+ slot,
671
+ endpoint: ambient.endpoint,
672
+ placement: ambient.sandbox,
673
+ at: now(),
674
+ });
675
+ run.started = true;
676
+ mark({ stage: "started", text: req.argv.join(" ") });
677
+ const sent = await startOnce(port, id, req, signal);
678
+ if (signal.aborted) return;
679
+ const first = await retrying(() => port.read(id, 0, signal), signal);
680
+ if (signal.aborted) return;
681
+ if (first === undefined) {
682
+ if (sent === "uncertain") {
683
+ // The start may still be on its way to the Harness that answers now: a kill keeps it from
684
+ // running there (exec.ts keeps an unknown id killed). Best effort — `unknown` either way.
685
+ void killUntilKept(port, id, parts.timeoutMs).catch(() => {});
686
+ }
687
+ settle({
688
+ outcome: "unknown",
689
+ reason:
690
+ sent === "uncertain"
691
+ ? "its start may have reached the Harness, which no longer knows it (it restarted); not run again"
692
+ : "the Harness lost it after it started (it restarted); not run again",
693
+ });
694
+ return;
695
+ }
696
+ await follow(port, id, first, signal, mark, settle);
697
+ }
698
+
699
+ /**
700
+ * Send the start ONCE where it may have reached the Harness. The Harness runs one id once only
701
+ * inside one process: a start that reached a Harness which then restarted, sent again, would run
702
+ * the command a second time in the new one. So a start is sent again only when it certainly never
703
+ * left (`ExecNotSent`: the connection was refused, the name did not resolve). Any other failure is
704
+ * `uncertain`, and the caller reads by id: found, it follows; unknown, it settles `unknown`.
705
+ */
706
+ async function startOnce(
707
+ port: ExecPort,
708
+ id: string,
709
+ req: ExecRequest,
710
+ signal: AbortSignal,
711
+ ): Promise<"sent" | "uncertain"> {
712
+ for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
713
+ try {
714
+ await port.start(id, req, signal);
715
+ return "sent";
716
+ } catch (err) {
717
+ if (signal.aborted) throw err;
718
+ if (err instanceof ExecRefused) throw err;
719
+ if (!(err instanceof ExecNotSent)) return "uncertain";
720
+ await new Promise((resolve) => setTimeout(resolve, wait / 2 + Math.random() * (wait / 2)));
721
+ if (signal.aborted) throw err;
722
+ }
723
+ }
724
+ }
725
+
726
+ /** Read the exec to its end: each chunk of stream to the feed, the output to the Machine. */
727
+ async function follow(
728
+ port: ExecPort,
729
+ id: string,
730
+ first: ExecRead,
731
+ signal: AbortSignal,
732
+ mark: (marker: Omit<EffectMarker, "kind" | "slot" | "effect">) => void,
733
+ settle: (output: CommandOutput) => void,
734
+ ): Promise<void> {
735
+ let read: ExecRead | undefined = first;
736
+ for (;;) {
737
+ if (read === undefined) {
738
+ settle({ outcome: "unknown", reason: "the Harness lost it while it ran (it restarted); not run again" });
739
+ return;
740
+ }
741
+ if (read.stream) mark({ stage: "output", text: read.stream });
742
+ if (read.state === "settled" && read.output) {
743
+ settle(read.output);
744
+ void port.forget(id).catch(() => {});
745
+ return;
746
+ }
747
+ const offset: number = read.next;
748
+ read = await retrying(() => port.read(id, offset, signal), signal);
749
+ if (signal.aborted) return;
750
+ }
751
+ }
752
+
753
+ /**
754
+ * The state that invoked this Effect exited (D1) — or the host is parking the run. Parking leaves
755
+ * the exec running for the restore to re-attach. An exit kills it and records the abort, "outcome
756
+ * unknown", on the ledger and the feed; the Machine never hears it.
757
+ */
758
+ function stopped(self: AnyActorRef, system: AnyActorSystem, effectId: string): void {
759
+ const run = running.get(self);
760
+ running.delete(self);
761
+ run?.controller.abort();
762
+ const binding = boundRun(system);
763
+ if (binding?.hostStopping) return;
764
+ const row = binding?.effectRow?.(effectId);
765
+ if (row?.state !== "intent" || row.kind !== "command") return;
766
+ const port = run?.port;
767
+ if (port) {
768
+ void killUntilKept(port, effectId, run.timeoutMs).then((kept) => {
769
+ if (kept) return;
770
+ binding?.marker?.({
771
+ kind: "effect",
772
+ slot: row.slot,
773
+ effect: effectId,
774
+ stage: "aborted",
775
+ text: "the kill did not reach its Harness; it may run until its timeout",
776
+ });
777
+ });
778
+ }
779
+ // Written now, saved with the transition that stopped it — never inside it: a save mid-transition
780
+ // reads the Machine before its route. A cancel's final save carries it.
781
+ const aborted: EffectRow = { state: "aborted", kind: row.kind, slot: row.slot, at: new Date().toISOString() };
782
+ binding?.recordEffect?.(effectId, aborted, "with-transition");
783
+ binding?.marker?.({
784
+ kind: "effect",
785
+ slot: row.slot,
786
+ effect: effectId,
787
+ stage: "aborted",
788
+ text: "its state exited while it ran: stopped; outcome unknown",
789
+ });
790
+ }
791
+
792
+ /**
793
+ * Send the kill until the Harness keeps it. The start may still be on the wire, so the kill can
794
+ * reach the Harness first — it keeps an unknown id as killed, and that start then runs nothing — or
795
+ * be lost to a transport fault, so it is sent again with backoff. Bounded by the command's timeout:
796
+ * past it the Harness's own timer has ended the group. False when it never reached the Harness.
797
+ */
798
+ async function killUntilKept(port: ExecPort, id: string, withinMs: number): Promise<boolean> {
799
+ const deadline = Date.now() + withinMs;
800
+ for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
801
+ try {
802
+ await port.kill(id);
803
+ return true;
804
+ } catch (err) {
805
+ if (err instanceof ExecRefused || Date.now() + wait > deadline) return false;
806
+ await new Promise((resolve) => setTimeout(resolve, wait / 2 + Math.random() * (wait / 2)).unref());
807
+ }
808
+ }
809
+ }
810
+
811
+ /**
812
+ * The unbound logic of a command with Open parts (ADR-0054) — its argv, an Open secret in its env,
813
+ * or both. `rebuild` calls this constructor again, whole, with the bound parts; a binding that
814
+ * names one of two Open parts gives a command still Open in the other.
815
+ */
816
+ function openCommandOf(
817
+ parts: CommandParts | OpenCommandParts,
818
+ rebuild: (binding: EffectBinding) => unknown,
819
+ ): EffectLogic<any, CommandOutput> {
820
+ const opened = openPartsOf(parts);
821
+ return openEffectLogic(parts, opened, (binding) => {
822
+ if (binding.url !== undefined || (binding.argv !== undefined && !opened.includes("argv"))) {
823
+ throw new Error(
824
+ `a command's Open parts here are its ${opened.join(" and ")} — bind it with ${effectBindingShape(opened)}`,
825
+ );
826
+ }
827
+ return rebuild(binding) as EffectLogic<unknown, unknown>;
828
+ });
829
+ }
830
+
831
+ /** A command's options with the binding's secret named in its env, where `secret(open)` stood. */
832
+ function bindCommand(binding: EffectBinding, options: CommandOptions): CommandOptions {
833
+ if (binding.secret === undefined || options.env === undefined) return options;
834
+ return { ...options, env: bindOpenSecretIn(options.env, binding.secret) };
835
+ }
836
+
837
+ /** argv with each slot filled from the input — one argument per entry. */
838
+ function argvOf(slot: string, parts: CommandParts, input: unknown): string[] {
839
+ const values = (input ?? {}) as Record<string, unknown>;
840
+ return parts.argv.map((a) => {
841
+ if (typeof a === "string") return a;
842
+ const value = values[a.arg];
843
+ if (typeof value !== "string") {
844
+ throw new Error(
845
+ `command "${slot}": argv slot <${a.arg}> has no string value in its input ` +
846
+ `(got ${value === undefined ? "nothing" : typeof value}) — map it in the invoke's input`,
847
+ );
848
+ }
849
+ return value;
850
+ });
851
+ }
852
+
853
+ /**
854
+ * The cwd: a Repo Slot's Worktree (ADR-0051). Named by `slot`, or the one slot where the Workspace
855
+ * carries one; a Workspace with none keeps the Harness's own (`/work`). Several and none named is
856
+ * refused, as a Turn's Frame is (ADR-0057): the kit gives no slot a meaning.
857
+ */
858
+ function cwdOf(slot: string, parts: CommandParts, ambient: AmbientHandles): { cwd?: string } {
859
+ const slots = Object.keys(ambient.repos);
860
+ if (parts.slot !== undefined) {
861
+ const cwd = ambient.repos[parts.slot];
862
+ if (cwd === undefined) {
863
+ throw new Error(
864
+ `command "${slot}" names Repo Slot "${parts.slot}", which its Workspace does not carry ` +
865
+ `(${slots.join(", ") || "none"})`,
866
+ );
867
+ }
868
+ return { cwd };
869
+ }
870
+ if (slots.length === 0) return {};
871
+ if (slots.length === 1) return { cwd: ambient.repos[slots[0]!]! };
872
+ throw new Error(
873
+ `command "${slot}" runs under a Workspace carrying more than one Repo Slot (${slots.join(", ")}) and names ` +
874
+ `none — say which Worktree it runs in: command(argv, { slot: "<slot>" }) (ADR-0051)`,
875
+ );
876
+ }
877
+
878
+ /** Ask again on a transport failure, with backoff, until it answers or the Effect stops. For a
879
+ * read only: a read runs nothing. A start goes through `startOnce`. */
880
+ async function retrying<T>(call: () => Promise<T>, signal: AbortSignal): Promise<T> {
881
+ for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
882
+ try {
883
+ return await call();
884
+ } catch (err) {
885
+ if (signal.aborted) throw err;
886
+ if (err instanceof ExecRefused) throw err;
887
+ await new Promise((resolve) => setTimeout(resolve, wait / 2 + Math.random() * (wait / 2)));
888
+ if (signal.aborted) throw err;
889
+ }
890
+ }
891
+ }
892
+
893
+ /** The Harness refused the request itself (a 4xx other than 404): a jr2 defect, never retried. */
894
+ export class ExecRefused extends Error {}
895
+
896
+ /** The request certainly never reached the Harness (the connection was refused, the name did not
897
+ * resolve): a start that failed so is safe to send again. Any other transport failure may have
898
+ * reached it. */
899
+ export class ExecNotSent extends Error {}
900
+
901
+ function parseDuration(d: Duration): number {
902
+ const n = Number.parseFloat(d);
903
+ if (!(n > 0)) throw new Error(`command: timeout "${d}" is not a positive duration (like "90s", "10m", "1h")`);
904
+ return d.endsWith("h") ? n * 3_600_000 : d.endsWith("m") ? n * 60_000 : n * 1000;
905
+ }