@jr2/orchestrator 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bin/server.ts +23 -0
  4. package/console/canvas.ts +843 -0
  5. package/console/components/app.ts +79 -0
  6. package/console/components/drawer.ts +131 -0
  7. package/console/components/fleet.ts +117 -0
  8. package/console/components/machine-pane.ts +85 -0
  9. package/console/components/nav.ts +81 -0
  10. package/console/components/schema-form.ts +137 -0
  11. package/console/main.ts +383 -0
  12. package/console/page.html +28 -0
  13. package/console/store.ts +336 -0
  14. package/console/style.css +700 -0
  15. package/console/tsconfig.json +18 -0
  16. package/package.json +61 -0
  17. package/src/actor.ts +562 -0
  18. package/src/agent.ts +124 -0
  19. package/src/ambient.ts +50 -0
  20. package/src/config.ts +297 -0
  21. package/src/customize.ts +348 -0
  22. package/src/durability.ts +135 -0
  23. package/src/fingerprint.ts +92 -0
  24. package/src/gate.ts +76 -0
  25. package/src/harness-client.ts +503 -0
  26. package/src/http.ts +753 -0
  27. package/src/images.ts +303 -0
  28. package/src/index.ts +40 -0
  29. package/src/instance.ts +294 -0
  30. package/src/machine-doc.ts +334 -0
  31. package/src/names.ts +78 -0
  32. package/src/open.ts +17 -0
  33. package/src/parts.ts +500 -0
  34. package/src/pool.ts +284 -0
  35. package/src/registration.ts +340 -0
  36. package/src/repo-fetch.ts +259 -0
  37. package/src/repo-identity.ts +145 -0
  38. package/src/repos.ts +330 -0
  39. package/src/run-host.ts +1095 -0
  40. package/src/sandbox-kubectl.ts +1136 -0
  41. package/src/server.ts +220 -0
  42. package/src/setup.ts +360 -0
  43. package/src/snapshot-store.ts +150 -0
  44. package/src/stub-harness.ts +217 -0
  45. package/src/tokens.ts +126 -0
  46. package/src/vocabulary.ts +99 -0
  47. package/src/wire.ts +103 -0
  48. package/src/workspace.ts +874 -0
  49. package/tsconfig.instance.json +26 -0
@@ -0,0 +1,874 @@
1
+ // `workspace(body, { input, image, user, repos, spec })` (ADR-0012, ADR-0049, ADR-0051): the
2
+ // jr2-owned wrapper Machine that owns ONLY Sandbox lifecycle — provision the Sandbox (out of the
3
+ // STATIC `image`/`user`/`repos` options it carries) + attach one worktree per Repo Slot, run the
4
+ // author's body Machine inside it as the named slot `body`, with `{ workspace: { repos, branch } }`
5
+ // appended to its input (the mechanism-facing endpoint/sandbox are published ambiently
6
+ // — ADR-0016, ambient.ts), and destroy the Sandbox when the body reaches a final state. Teardown lives INSIDE the
7
+ // wrapper's own states because an xstate stop is synchronous — multi-step async cleanup must be
8
+ // states the machine transitions through itself, which forces the thing that provisions to also
9
+ // observe the body's completion (the ADR's load-bearing argument). There is no retain policy: a
10
+ // body that parks in a non-final state keeps its Sandbox alive by construction; the operator's
11
+ // idle-timeout GC is the backstop for the paths no machine can cover (kill -9, body error).
12
+ //
13
+ // The SandboxPort is HOST infrastructure reached via the run binding (like the registration
14
+ // table): one cluster per orchestrator instance, so the port rides `RunHostOptions.sandbox`,
15
+ // never workflow code — `workspace` stays a plain static import (ADR-0011 doctrine) and unit
16
+ // tests bind a fake port. Every port operation is invoked from a state that RE-RUNS on restore
17
+ // (invoked actors re-execute from persisted input), so all four operations must be idempotent.
18
+ //
19
+ // Restore-reconcile (ADR-0012): the `running` state co-invokes a reconcile probe beside the
20
+ // body. Invoked callback actors restart on every (re)entry — including snapshot restore — so
21
+ // after an orchestrator restart the probe re-checks the Sandbox CR mechanically: present →
22
+ // nothing (agent admissions re-attach — ADR-0016); absent → the pod-local clone and any unpushed
23
+ // commits are gone, so it delivers `workspace.lost` INTO the restored body (same channel as
24
+ // `agent.fault`) and the body's policy decides. Never silently re-provision.
25
+
26
+ import {
27
+ assign,
28
+ fromCallback,
29
+ fromPromise,
30
+ sendTo,
31
+ setup,
32
+ type AnyStateMachine,
33
+ type InputFrom,
34
+ type OutputFrom,
35
+ type StateMachine,
36
+ } from "xstate";
37
+ import type { z } from "zod";
38
+ import { registerAmbientHandles, type AmbientHandles } from "./ambient.ts";
39
+ import {
40
+ actorSlotPath,
41
+ assertRepoSlot,
42
+ attachSandboxParts,
43
+ attachWrapperBody,
44
+ customizeLine,
45
+ open,
46
+ repoSlotState,
47
+ sandboxPartsOf,
48
+ type Binding,
49
+ type JR2Repos,
50
+ type JR2Wrapper,
51
+ type RepoSlot,
52
+ type SandboxParts,
53
+ type WrapperActors,
54
+ } from "./parts.ts";
55
+ import { runBindingOf, type AnyActorSystem } from "./registration.ts";
56
+ import { attachInputSchema, inputSchemaOf, invokingMachine, type HostInjectedInput } from "./vocabulary.ts";
57
+
58
+ /** Lease cadence when the backend names none. Well inside the 30m default idle timeout, so a
59
+ * few missed renewals in a row are survivable; also the worst-case detection latency for a
60
+ * workspace that went away (ADR-0021). */
61
+ const DEFAULT_LEASE_INTERVAL_MS = 5 * 60_000;
62
+
63
+ /** What to attach, in workspace vocabulary only (ADR-0012 boundary): the one branch the body
64
+ * works on, the pod's work group, and the review sha. Derived PER RUN from the wrapper's input,
65
+ * which is what keeps it out here rather than in the options — and which is exactly why the two
66
+ * IMAGES and the REPOS are NOT here (ADR-0049, ADR-0051): `jr2 up` must find them by walking the
67
+ * Machine, and no walk can evaluate a function of run input. They are static `workspace()`
68
+ * options instead; a Repo that IS a function of run input is a per-run slot, a mapper the walk
69
+ * can see the shape of even though it cannot see the url. */
70
+ export type WorkspaceSpec = {
71
+ branch: string;
72
+ /** The pod's work group (ADR-0005): `fsGroup`, default 2000. The two writing seats may run
73
+ * different uids — each image's own `USER` decides — and POSIX would then make the other seat's
74
+ * files read-only; fsGroup (group ownership) plus the default ACL the attach stamps on each
75
+ * repo root (group writability, umask-proof) closes that, both inert when the uids already
76
+ * match. The override exists for the image whose sessions already hold a gid of their own —
77
+ * a root sshd's logins rebuild groups from `/etc/group`, so pointing the work group at one
78
+ * they have costs no rebuild. Never a config key — pod composition is the spec's business. */
79
+ workGroup?: number;
80
+ /** Attach the detached review worktree at this sha (ADR-0028): `<branchDir>-review`, a sibling
81
+ * of the branch worktree, forced to exactly this sha on every attach. Creation-time seat only;
82
+ * the per-round refresh verb (the sha moves between review rounds) is a later, workflow-driven
83
+ * change. */
84
+ reviewSha?: string;
85
+ };
86
+
87
+ /**
88
+ * What the workspace hands the BODY (ADR-0012, ADR-0016): worktree geography only, keyed by Repo
89
+ * Slot (ADR-0051) — so a path a prompt names is right in every Instance that consumes the Machine.
90
+ * `endpoint` and `sandbox` are mechanism-internal — the Agent actor resolves them ambiently from
91
+ * the enclosing wrapper (ambient.ts), so a workflow can no longer forget to thread them (the
92
+ * baba71f incident: `sandbox` omitted, every tool call 403'd, fail-closed but silent).
93
+ *
94
+ * `TSlots` has NO default on purpose (ADR-0050): a body names the slots it reads, and
95
+ * `workspace()` holds those names to the ones the wrapper declares. A default of `string` would
96
+ * type `repos` as `Record<string, string>`, under which a misspelled slot reads as a path — the
97
+ * silent widening the slot key exists to refuse. `string` WRITTEN is a different statement: the
98
+ * body names no slot at all — it enumerates them, in the order the composer wrote — which is the body
99
+ * under a wrapper that declares its map Open (`repos: open`, ADR-0051), where the slots are the
100
+ * composer's words and no body could name them.
101
+ */
102
+ export type WorkspaceHandles<TSlots extends string> = {
103
+ /** Every slot's branch-worktree path: `/work/<slot>/<branch>`, keyed in DECLARATION ORDER. The
104
+ * kit gives no slot a privileged meaning — there is no `workdir` — because which tree an Agent
105
+ * works in is a fact about that Agent's Turn, not about the Workspace: a body that names its
106
+ * slots frames each Agent with its own, and a body that names none may give the order a
107
+ * meaning of its own (`task`: the first is the one the coder edits). The order is the kit's
108
+ * promise; the meaning is the Machine's (ADR-0051). */
109
+ repos: Record<TSlots, string>;
110
+ branch: string;
111
+ /** Detached review-worktree paths by slot (ADR-0028) — present only when the spec carried
112
+ * `reviewSha`. The reviewer's seat: hand one of these as its cwd/prompt frame. */
113
+ review?: Partial<Record<TSlots, string>>;
114
+ };
115
+
116
+ /**
117
+ * A body's input under a Workspace: the run input the wrapper passes through, PLUS the handles it
118
+ * injects. The composition is the whole reason the door is declared on the wrapper and not on the
119
+ * body (ADR-0033) — `Workspaced<RunInput, Slot>` is what the body receives, `RunInput` is what a caller
120
+ * may send, and no caller can send `workspace` (the handles do not exist until a Sandbox is
121
+ * provisioned and attached). Naming it here keeps the body from hand-copying
122
+ * {@link WorkspaceHandles}, which drifts. The second argument is the body's word for each Repo
123
+ * Slot it reads (`Workspaced<RunInput, "target">`) — required, never defaulted, so
124
+ * `workspace.repos.<slot>` is typed by the same keys the wrapper declares (ADR-0050, ADR-0051).
125
+ *
126
+ * The wrapper passes its input through UNTOUCHED, so a ROOT-placed wrapper's body also receives
127
+ * what the host injected beside the door — `HostInjectedInput` today (the run's `instanceId`).
128
+ * That is outside this type on purpose: it depends on where the wrapper sits, and `Workspaced` is
129
+ * the composition the WRAPPER makes.
130
+ */
131
+ export type Workspaced<TInput, TSlots extends string> = TInput & { workspace: WorkspaceHandles<TSlots> };
132
+
133
+ /**
134
+ * What a renewal learned about the workspace it just stamped (ADR-0021).
135
+ *
136
+ * `identity` is the backing pod's identity, NOT its address. Addresses are deterministic — the
137
+ * CR name, the Service DNS, the worktree paths are all derived from the run — so every name a
138
+ * live run holds still resolves after an eviction or a node loss, while the pod behind them is
139
+ * a replacement with an empty `work` volume: clones, worktrees, and unpushed commits gone.
140
+ * Presence cannot see that; identity can. A backend with no such notion may omit it, and its
141
+ * workspaces are then reconciled on presence alone.
142
+ */
143
+ export type Continuity = { present: false } | { present: true; identity?: string };
144
+
145
+ /** One Repo Slot as the port receives it at provision (ADR-0051): resolved to a Binding, and
146
+ * flagged when the run — not the Machine — chose the url, because that is what the credentials
147
+ * fence keys on. */
148
+ export type ProvisionedRepo = { slot: string; url: string; ref?: string; perRun: boolean };
149
+
150
+ /**
151
+ * The Sandbox backend a host supplies (`RunHostOptions.sandbox`) — the seam between the
152
+ * workspace Machine and the cluster. All four operations MUST be idempotent: the invoking
153
+ * states re-run on snapshot restore (create-if-absent, attach-if-absent, delete-if-present).
154
+ */
155
+ export interface SandboxPort {
156
+ /** Ensure the Sandbox CR exists (labeled with its run for `jr2 ls`) and await `phase: Ready`;
157
+ * resolve with the Harness endpoint the orchestrator can reach, and the identity the lease
158
+ * will hold this workspace to. `image`/`user` are the wrapper's static image options
159
+ * (ADR-0037/0005/0049) — a `file:` context or a registry ref, the port resolves both, and a
160
+ * context the last converge did not build fails here rather than converge-time. `repos` are the
161
+ * wrapper's slots, resolved, in declaration order (ADR-0051): the port names each Repo on the
162
+ * CR so the cluster mounts its cache, and refuses a per-run url no `git.credentials` entry
163
+ * admits. `workGroup` is the pod's `fsGroup`; the port owns the default. */
164
+ provision(req: {
165
+ name: string;
166
+ runId: string;
167
+ workflow: string;
168
+ image?: string;
169
+ user?: string;
170
+ workGroup?: number;
171
+ repos: ProvisionedRepo[];
172
+ }): Promise<{ endpoint: string; identity?: string }>;
173
+ /** Post-Ready attach (ADR-0004): per slot, `git clone --shared` off the node's read-only
174
+ * cache (the `default/` checkout), then a branch worktree sibling — and, with `spec.reviewSha`, the detached
175
+ * review worktree (ADR-0028). Resolves with the worktree paths by slot; `stale` names the slots
176
+ * whose cache could not be fetched before this attach, with git's own error (ADR-0051: freshness
177
+ * degrades, absence does not). */
178
+ attach(req: {
179
+ name: string;
180
+ spec: WorkspaceSpec;
181
+ repos: Array<{ slot: string; url: string; ref?: string }>;
182
+ }): Promise<{
183
+ repos: Record<string, string>;
184
+ review?: Record<string, string>;
185
+ stale?: Record<string, string>;
186
+ }>;
187
+ /**
188
+ * Renew this workspace's keepalive lease AND report what the renewal found — one exchange,
189
+ * because it is one question: is the thing I am keeping alive still the thing I attached to?
190
+ * Nothing in the cluster represents a run (ADR-0001), so the lease is how the Orchestrator
191
+ * asserts liveness; the answer is how it learns the truth. Idempotent, called on a timer.
192
+ *
193
+ * A renewal that FAILS must reject, not resolve `{present: false}` — an unreachable API server
194
+ * is "unknown", and fabricating loss would settle a live run holding real work.
195
+ */
196
+ renew(name: string): Promise<Continuity>;
197
+ /** Delete the Sandbox CR. Absent is success. */
198
+ destroy(name: string): Promise<void>;
199
+ /** How often to renew. Must be well inside the backend's idle-timeout, since a lapsed lease is
200
+ * what lets the operator reap. Also the detection latency for a lost workspace. */
201
+ readonly leaseIntervalMs?: number;
202
+ }
203
+
204
+ /** Resolve the host's Sandbox backend, failing with a pointed message on a host without one. */
205
+ export function sandboxOf(system: AnyActorSystem): SandboxPort {
206
+ const port = runBindingOf(system).sandbox;
207
+ if (!port) {
208
+ throw new Error(
209
+ "this orchestrator has no Sandbox backend — a Workspace is always a real Sandbox (ADR-0012); " +
210
+ "this process is not deployed in a cluster (JR2_NAMESPACE unset). `jr2 up` the instance and run there.",
211
+ );
212
+ }
213
+ return port;
214
+ }
215
+
216
+ /**
217
+ * The Sandbox CR name for one workspace invocation: DNS-1123, deterministic from the run and
218
+ * the wrapper's actor id (both stable across restore — that is what lets the reconcile probe
219
+ * and a re-run provision find the SAME CR), collision-proofed by a content suffix.
220
+ */
221
+ export function workspaceName(runId: string, wsId: string): string {
222
+ const slug = (s: string) =>
223
+ s
224
+ .toLowerCase()
225
+ .replace(/[^a-z0-9]+/g, "-")
226
+ .replace(/^-+|-+$/g, "");
227
+ let h = 0;
228
+ for (const ch of `${runId}/${wsId}`) h = (h * 31 + ch.charCodeAt(0)) | 0;
229
+ const base = `ws-${slug(runId).slice(0, 8)}-${slug(wsId)}`.replace(/-+/g, "-").replace(/-+$/, "").slice(0, 55);
230
+ return `${base}-${(h >>> 0).toString(36)}`;
231
+ }
232
+
233
+ /** The full mechanism-facing handles: what the registrar publishes for ambient resolution
234
+ * (ambient.ts). The body sees only the {@link WorkspaceHandles} subset. */
235
+ type MechanismHandles = AmbientHandles;
236
+
237
+ /** One slot as the run resolved it (ADR-0051): the Binding, and whether the run's input chose
238
+ * it. Persisted, so the attach after a restore reuses exactly what was provisioned — a per-run
239
+ * mapper is not re-evaluated against an input that may since have been re-parsed. */
240
+ export type ResolvedBinding = { url: string; ref?: string; perRun: boolean };
241
+
242
+ type WsContext = {
243
+ /** The wrapper's own input, passed through to the body untouched (plus `workspace`). */
244
+ runInput: Record<string, unknown>;
245
+ /** This invocation's stable identity (the actor id the parent invoked/spawned us under). */
246
+ wsId: string;
247
+ /** The resolved spec — computed once from input and persisted, like any other context data. */
248
+ spec: WorkspaceSpec;
249
+ /** Every slot's resolved Binding, by slot, in declaration order — assigned at provision. */
250
+ bindings?: Record<string, ResolvedBinding>;
251
+ endpoint?: string;
252
+ /** The pod identity this workspace attached to, captured at provision and persisted so the
253
+ * lease can hold the workspace to it across a restart (ADR-0021). Plain serializable data,
254
+ * exactly like `endpoint` — ADR-0007's rule about what may ride context. */
255
+ identity?: string;
256
+ /** Persisted in context so the registrar can re-publish them on restore. */
257
+ handles?: MechanismHandles;
258
+ output?: unknown;
259
+ };
260
+
261
+ /**
262
+ * What `workspace()` returns: a Machine erased to the parameters that carry meaning across the
263
+ * seam — the door a run of it starts with, the body's output, which the wrapper forwards verbatim
264
+ * (ADR-0012), and the wrapper's own actor slots. Everything else is the wrapper's own business,
265
+ * so it stays `any`: a generic `setup()` over the body does not infer (report-xstate.md §3),
266
+ * which is why the implementation is loosely typed and only the public signature is precise.
267
+ *
268
+ * The slots are stated because the wrapper is TRANSPARENT to its body (ADR-0049): `body` holding
269
+ * the body's own type is what lets `customize(machine, { agents })` offer the BODY's Agents
270
+ * through the wrapper, without the composer ever spelling `body`. {@link JR2Wrapper} is what SAYS
271
+ * it is a wrapper — the type twin of the `attachWrapperBody` stamp `workspace()` writes below — so
272
+ * `customize()` reaches the body because this Machine IS one, never because a slot is spelled
273
+ * `body`: that name is an author's to choose too (parts.ts).
274
+ *
275
+ * No parameter defaults (ADR-0050, as {@link Workspaced}): an annotation names the body and the
276
+ * Repo Slots it carries, because a default would widen exactly where the phantom exists to refuse
277
+ * — `JR2Repos<string>` offers `customize()` every key, and `AnyStateMachine` as the body offers no
278
+ * Agent at all. `PoolMachine` names its worker the same way.
279
+ */
280
+ export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSlots extends string> = StateMachine<
281
+ any,
282
+ any,
283
+ any,
284
+ WrapperActors<"body", TBody, "provision" | "attach" | "registrar" | "lease" | "destroy">,
285
+ any,
286
+ any,
287
+ any,
288
+ any,
289
+ any,
290
+ TInput,
291
+ TOutput,
292
+ any,
293
+ any,
294
+ any
295
+ > &
296
+ JR2Wrapper<TBody> &
297
+ JR2Repos<TSlots>;
298
+
299
+ /**
300
+ * The door CONSTRAINS the body (ADR-0033), in one direction only: the body may not demand more
301
+ * than the wrapper will hand it, which is the door plus the injected handles ({@link Workspaced}).
302
+ * A body that demands LESS is safe — it is fed a superset — so this is an assignability test, not
303
+ * an equality one.
304
+ *
305
+ * {@link HostInjectedInput} is added to the PROVIDED side, not subtracted from the demanded one.
306
+ * `RunHost.start` hands the ROOT machine `{ ...runInput, instanceId }` and the wrapper passes its
307
+ * input through untouched, so a root-placed wrapper feeds its body that field too — while a nested
308
+ * one does not, and no type can see which. Of the two answers a type can give, this takes the
309
+ * permissive one: holding the body to the door alone rejects one that declares the field honestly
310
+ * (`features/kind-instance/workflows/*.ts` do) with a diagnostic telling the author to widen the
311
+ * door — the wrong fix, since the field is host-supplied, never sent, and never served as JSON
312
+ * Schema (ADR-0033).
313
+ *
314
+ * Widening the provided side is what keeps the carve-out from becoming a hole. SUBTRACTING the
315
+ * keys instead (`Omit<InputFrom<TBody>, keyof HostInjectedInput>`) drops them from the comparison
316
+ * entirely, which loses two cases claim 1 owns: a body declaring `instanceId: number` passes,
317
+ * because the key it got wrong is the key that was removed; and a body whose input is a UNION is
318
+ * checked against the union's SHARED keys only, so every member could demand a field the door
319
+ * never carries and still compile. Stated on the provided side, both are rejected, and the admit
320
+ * set is otherwise identical.
321
+ *
322
+ * The failure is spelled as an object type whose single key is the sentence to read: TypeScript
323
+ * prints the key of the property it could not satisfy, so the diagnostic on a rejected body names
324
+ * the fix instead of a structural diff. Pinning the body's TInput slot instead would NOT work —
325
+ * `StateMachine`'s members include methods, and method parameters are bivariant, so a body
326
+ * demanding MORE than the door provides compiles. Both directions are pinned by
327
+ * `test/door-types.test.ts`, which the typecheck gate runs.
328
+ */
329
+ // The handles are keyed by the wrapper's DECLARED slots (ADR-0051), so a body demanding a slot the
330
+ // wrapper never declared is refused here too; demanding fewer is fine, as with any other field.
331
+ type BodyAcceptsDoor<TBody extends AnyStateMachine, TDoor, TSlots extends string> =
332
+ Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
333
+ ? BodyNamesNoSlotUnderOpenMap<TBody, TSlots>
334
+ : { "the body's declared input must accept the door plus the injected handles": Workspaced<TDoor, TSlots> };
335
+
336
+ /**
337
+ * The one case assignability cannot see (ADR-0051): a wrapper that declares its map Open
338
+ * (`repos: open`) hands the body `Record<string, string>`, and TypeScript relates that to a body's
339
+ * `Record<"target", string>` — an index signature satisfies a mapped type's named keys — so the
340
+ * check above would pass a body that names a slot the composer may never write. Under an Open
341
+ * map the body must name NO slot: its handles' keys are `string`, which is how a body says "I
342
+ * enumerate whatever is attached". A body that names one is refused here,
343
+ * where the wrapper is written.
344
+ */
345
+ type BodyNamesNoSlotUnderOpenMap<TBody extends AnyStateMachine, TSlots extends string> = string extends TSlots
346
+ ? InputFrom<TBody> extends { workspace: { repos: infer THandles } }
347
+ ? string extends keyof THandles
348
+ ? unknown
349
+ : {
350
+ "a body under an Open slot map (repos: open) names no slot — its handles are Workspaced<…, string>": keyof THandles;
351
+ }
352
+ : unknown
353
+ : unknown;
354
+
355
+ /**
356
+ * The handles half of {@link BodyAcceptsDoor} alone, for the wrapper with NO declared door. The
357
+ * door is unchecked there because it is unknown (ADR-0033) — but the Repo Slots are declared on
358
+ * this path exactly as on the other, so a body naming a slot the wrapper never declared is refused
359
+ * here too (ADR-0051): it would read `workspace.repos.<slot>`, a path that never exists. The test
360
+ * is the same one-direction assignability, on the `workspace` field alone: the handles the wrapper
361
+ * will inject must satisfy what the body declares for them, so a body that names fewer slots, or
362
+ * no handles at all, is fed a superset and passes, as ever.
363
+ */
364
+ type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
365
+ InputFrom<TBody> extends { workspace: infer THandles }
366
+ ? WorkspaceHandles<TSlots> extends THandles
367
+ ? BodyNamesNoSlotUnderOpenMap<TBody, TSlots>
368
+ : { "the body's declared handles must accept the Repo Slots the wrapper declares": WorkspaceHandles<TSlots> }
369
+ : unknown;
370
+
371
+ /**
372
+ * What the Sandbox is MADE OF (ADR-0037, ADR-0005) and which Repos it attaches (ADR-0051), as
373
+ * STATIC options on the wrapper rather than fields of the per-run spec (ADR-0049). Static is the
374
+ * whole point: `jr2 up` walks the registered Machines to find every `file:` context and build it,
375
+ * every bound Repo and warm it, every open slot and refuse it (parts.ts) — and a spec is a function
376
+ * of run input that no walk can evaluate. They are also never persisted — the provisioning state
377
+ * re-reads them off the Machine it was invoked as, so a restore, a `provide()` and a `customize()`
378
+ * all get the image and the slots the Machine carries NOW.
379
+ *
380
+ * Each image is one string in ADR-0037's two shapes: a `file:` URL to a docker context the
381
+ * Machine's module ships (`import.meta.resolve("./image")`), or a registry ref its owner baked and
382
+ * hosts.
383
+ *
384
+ * `TSlots` has no default, here and on the two option types below (ADR-0050): a value annotated
385
+ * with a `string` slot set types `repos` as `Record<string, RepoSlot>`, under which a body naming
386
+ * any slot passes the wrapper's check — the widening {@link WorkspaceHandles} refuses for the
387
+ * same reason. The `workspace()` overloads infer it; an annotation names it.
388
+ */
389
+ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
390
+ /** The Sandbox Image. Absent → the Instance's `images/default`, then the stock Harness. */
391
+ image?: string;
392
+ /** The User Container's image (ADR-0005). Absent → the pod has no third container: there is no
393
+ * default, because the seat's whole identity is "what jr2 does not own" and jr2 has nothing to put
394
+ * there. One string is the entire authoring surface — env, ports, and resources are deliberately
395
+ * not forwarded. */
396
+ user?: string;
397
+ /**
398
+ * The Repo Slots (ADR-0051), keyed by the Machine's own word for each — the key of the body's
399
+ * `workspace.repos` handles and the directory under `/work`. The handles keep this map's order,
400
+ * and the kit reads nothing into it. Required, at least one: a Workspace exists to work on a repository. Each slot is bound (a url,
401
+ * or `{ url, ref? }` — the package's own), open (`open` — the consumer binds it with
402
+ * `customize`), or per-run (a mapper over the door: `({ input }) => input.repo`).
403
+ *
404
+ * Or the whole map Open (`repos: open`): the Machine names no slot, the composer names every
405
+ * one with `customize`, in an order the Machine may give a meaning to. The shape a packaged
406
+ * Machine takes when its body enumerates its checkouts rather than naming them (`@jr2/machines`'s
407
+ * `task`, whose first slot is the one the coder edits). Under it `TSlots` is `string`, and the body's handles must say so.
408
+ */
409
+ repos: Record<TSlots, RepoSlot<TInput>> | typeof open;
410
+ };
411
+
412
+ /**
413
+ * How a Workspace with a declared door is configured (ADR-0012, ADR-0033) — the wrapper's own
414
+ * run-input schema, what the pod is made of, and the mapping from what comes through the door to
415
+ * workspace vocabulary.
416
+ */
417
+ export type WorkspaceOptions<TSchema extends z.ZodObject, TSlots extends string> = SandboxOptions<
418
+ TSlots,
419
+ z.infer<TSchema>
420
+ > & {
421
+ /** The wrapper's OWN declared run input (ADR-0033) — what a caller sends to start a run of it,
422
+ * what types `spec`'s `input`, and what the body is checked against. Deliberately NOT the body's
423
+ * schema: the body is fed the run input PLUS the injected `workspace` handles
424
+ * ({@link Workspaced}), which no caller can send, so the body's contract is the door plus
425
+ * something that does not exist yet. Symmetric with `PoolSpec.input`. */
426
+ input: TSchema;
427
+ /** Map what came through the door to the workspace-domain spec: what to attach, on what ref,
428
+ * on which branch (ADR-0012's boundary — workflow configuration never enters it). */
429
+ spec: (args: { input: z.infer<TSchema> }) => WorkspaceSpec;
430
+ };
431
+
432
+ /**
433
+ * How a Workspace with NO declared door is configured: absence is permissive (ADR-0033), so jr2
434
+ * has nothing to infer from and says `unknown` rather than `any` — an honest "jr2 does not know",
435
+ * which the mapper must narrow before it reads a field. A wrapper that is fed by something other
436
+ * than a caller — a pool worker, a nested invoke — may state what it is fed by annotating the
437
+ * parameter (`spec: ({ input }: { input: Item }) => …`), which types the wrapper's input too. For
438
+ * anything a caller starts, the honest fix is to declare `input`.
439
+ */
440
+ export type PermissiveWorkspaceOptions<TSlots extends string, TInput = unknown> = SandboxOptions<TSlots, TInput> & {
441
+ /** Never present on this path. Spelled out so a declared schema can never fall through to the
442
+ * permissive overload, where the body's door would go unchecked. */
443
+ input?: never;
444
+ spec: (args: { input: TInput }) => WorkspaceSpec;
445
+ };
446
+
447
+ /**
448
+ * Wrap a body Machine in Sandbox lifecycle (ADR-0012). `spec` maps the wrapper's input to the
449
+ * workspace-domain spec; the body receives the wrapper's input plus `workspace` (the handles) —
450
+ * `Workspaced<TInput, TSlots>`, which is also what the declared door checks the body against. The
451
+ * wrapper's output is the body's output. A body ERROR is deliberately unhandled: it faults the run
452
+ * loudly (RunStatus.fault) and leaves the Sandbox to the operator's idle-timeout GC — the trail
453
+ * stays inspectable, and silent cleanup would destroy the evidence.
454
+ */
455
+ export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMachine, TSlots extends string>(
456
+ body: TBody & BodyAcceptsDoor<TBody, z.infer<TSchema>, TSlots>,
457
+ options: WorkspaceOptions<TSchema, TSlots>,
458
+ ): WorkspaceMachine<z.infer<TSchema>, OutputFrom<TBody>, TBody, TSlots>;
459
+ export function workspace<TBody extends AnyStateMachine, TSlots extends string, TInput = unknown>(
460
+ body: TBody & BodyAcceptsSlots<TBody, TSlots>,
461
+ options: PermissiveWorkspaceOptions<TSlots, TInput>,
462
+ ): WorkspaceMachine<TInput, OutputFrom<TBody>, TBody, TSlots>;
463
+ export function workspace(
464
+ body: AnyStateMachine,
465
+ options: SandboxOptions<string, any> & { input?: z.ZodObject; spec: (args: { input: any }) => WorkspaceSpec },
466
+ ): AnyStateMachine {
467
+ // A body that still declares its own run input is a dead declaration under the door design: the
468
+ // wrapper never serves it, never validates against it, and feeds the body something it does not
469
+ // describe. Silence would leave an author believing a contract that nothing enforces (ADR-0033).
470
+ if (inputSchemaOf(body)) {
471
+ throw new Error(
472
+ `workspace(): the body "${body.id}" declares its own run input, which nothing will ever ` +
473
+ "serve or enforce — the wrapper feeds the body the run input PLUS the injected `workspace` " +
474
+ "handles, so the body's schema is not the door. Move it to the wrapper: " +
475
+ "workspace(body, { input, spec }) (ADR-0033).",
476
+ );
477
+ }
478
+ // Static, so checkable NOW rather than at the first provision — an empty or non-string image is
479
+ // the same derives-from-a-typo bug `assertSpec` catches for the spec, one build earlier.
480
+ for (const seat of ["image", "user"] as const) {
481
+ const value = options[seat];
482
+ if (value !== undefined && (typeof value !== "string" || !value)) {
483
+ throw new Error(
484
+ `workspace(): \`${seat}\` must be a non-empty string (got ${JSON.stringify(value)}) — either a \`file:\` ` +
485
+ 'URL to a docker context this module ships (`import.meta.resolve("./image")`) or a registry ref ' +
486
+ "(ADR-0037).",
487
+ );
488
+ }
489
+ }
490
+ // The slots, checked NOW for the same reason (ADR-0051): every value is one of the three forms,
491
+ // every key is a directory name, and there is at least one — a Workspace exists to work on a
492
+ // repository, and a wrapper with no slot would attach nothing and hand the body no checkout.
493
+ // An Open map defers all of that to the `customize` that fills it, which runs the same checks.
494
+ const repos = options.repos;
495
+ if (repos !== open) {
496
+ if (typeof repos !== "object" || repos === null || Array.isArray(repos) || Object.keys(repos).length === 0) {
497
+ throw new Error(
498
+ 'workspace(): `repos` must name at least one Repo Slot — `repos: { app: "https://…" }`, or ' +
499
+ "`open` for a slot the consumer binds, or a mapper over the door for one the run chooses — or be " +
500
+ "`open` whole, for a map the consumer names (ADR-0051).",
501
+ );
502
+ }
503
+ for (const [slot, value] of Object.entries(repos)) assertRepoSlot("workspace()", slot, value);
504
+ }
505
+ const wrapper = buildWorkspaceMachine(body, options.spec);
506
+ // The wrapper is TRANSPARENT to its body (ADR-0049): `customize(machine, { agents })` on a
507
+ // Workspace means the Machine inside, so the composer never spells `body` and never has to know
508
+ // that jr2 wrapped anything.
509
+ attachWrapperBody(wrapper, "body");
510
+ // What the pod is MADE of and which Repos it attaches ride the Machine (ADR-0049, ADR-0051),
511
+ // keyed on `machine.config` like the vocabulary — so a `provide()` clone keeps them, and the
512
+ // provisioning state reads them back off the Machine it was invoked as instead of closing over
513
+ // these values. That is also what lets `jr2 up` find every `file:` context, every bound Repo and
514
+ // every open slot by walking the registered Machines (parts.ts).
515
+ attachSandboxParts(wrapper, {
516
+ ...(options.image !== undefined ? { image: options.image } : {}),
517
+ ...(options.user !== undefined ? { user: options.user } : {}),
518
+ repos: repos === open ? open : { ...repos },
519
+ });
520
+ // The body's vocabulary stays the BODY's (ADR-0011, ADR-0049): the wrapper declares no events
521
+ // of its own and merges none, because the actors that use the body's names resolve against the
522
+ // Machine that invoked them — the body — at any nesting depth. Propagating them up was what
523
+ // made a nested Machine's events its parent's problem to re-declare.
524
+ //
525
+ // The door does NOT propagate from the body either (ADR-0033): the wrapper hands the body the run
526
+ // input plus the injected `workspace` field, so the body's declared input would be the door
527
+ // plus a field no caller can send — declaring it there 400s every valid start. The wrapper
528
+ // declares its own, exactly as a pool does.
529
+ if (options.input) attachInputSchema(wrapper, options.input);
530
+ return wrapper;
531
+ }
532
+
533
+ /**
534
+ * Fail a malformed spec BEFORE any pod exists. The spec derives from run input via the workflow's
535
+ * mapping fn, so a `jr2 run --input` missing a field the mapping reads arrives here as `undefined` —
536
+ * unchecked, it survives until the attach script's string ops and dies as "Cannot read properties
537
+ * of undefined", with a Sandbox already provisioned and nothing pointing back at the input.
538
+ */
539
+ function assertSpec(spec: WorkspaceSpec): void {
540
+ const bad: string[] = [];
541
+ if (typeof spec?.branch !== "string" || !spec.branch) bad.push(`branch (got ${JSON.stringify(spec?.branch)})`);
542
+ // The branch Worktree is a SIBLING of the pod-local clone at `<slot>/default/` (ADR-0004), so
543
+ // the one branch name that is not a worktree directory is `default`.
544
+ else if (spec.branch === "default")
545
+ bad.push(
546
+ 'branch "default" (the pod-local clone\'s own directory — a branch Worktree sits beside `default/`, ADR-0004)',
547
+ );
548
+ if (spec?.reviewSha !== undefined && (typeof spec.reviewSha !== "string" || !spec.reviewSha))
549
+ bad.push(`reviewSha (got ${JSON.stringify(spec?.reviewSha)})`);
550
+ // A gid, so an integer — a float or a negative becomes a pod the API server rejects at
551
+ // admission, which surfaces as "never reached Ready" with nothing pointing back at the spec.
552
+ if (
553
+ spec?.workGroup !== undefined &&
554
+ (typeof spec.workGroup !== "number" || !Number.isInteger(spec.workGroup) || spec.workGroup < 0)
555
+ )
556
+ bad.push(`workGroup (got ${JSON.stringify(spec?.workGroup)}; want a gid)`);
557
+ if (bad.length) {
558
+ throw new Error(
559
+ `workspace spec invalid: ${bad.join("; ")} — the spec derives from run input; does ` +
560
+ "`jr2 run --input` carry every field this workflow's workspace() mapping reads?",
561
+ );
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Resolve every Repo Slot to a Binding, in declaration order (ADR-0051). A bound slot is its
567
+ * Binding; a per-run slot is its mapper called over the run input, validated like the static forms
568
+ * because it derives from `jr2 run --input` exactly as the spec does; an open slot nobody bound is
569
+ * a fault BEFORE the port, naming the `customize` line that fixes it — the run-time twin of the
570
+ * refusal `jr2 up`'s walk makes for a registered Machine, reached here only by a Machine that was
571
+ * never registered as itself (a test seam, a nested invoke of an unbound import). The Machine is
572
+ * named as the walk names it: the Workflow it runs under, and the slot chain (`path`) from that
573
+ * root to this wrapper — which is the `actors` nesting of the line, so it pastes.
574
+ */
575
+ function resolveBindings(
576
+ where: { workflow: string; path: string[] | undefined },
577
+ slots: SandboxParts["repos"],
578
+ runInput: unknown,
579
+ ): Record<string, ResolvedBinding> {
580
+ const unbound = (slot: string | undefined): Error => {
581
+ const fix =
582
+ where.path === undefined
583
+ ? "no customize() reaches a Machine invoked inline; declare it under setup({ actors }) and bind the slots there"
584
+ : `bind them where the Machine is registered: export const machine = ${customizeLine("<import>", where.path, slot)}`;
585
+ const what =
586
+ slot === undefined ? "Repo Slots are open — nobody named any" : `Repo Slot "${slot}" is open — nobody bound it`;
587
+ return new Error(`workflow "${where.workflow}": ${what}; ${fix} (ADR-0051)`);
588
+ };
589
+ if (slots === open) throw unbound(undefined);
590
+ const bindings: Record<string, ResolvedBinding> = {};
591
+ for (const [slot, value] of Object.entries(slots)) {
592
+ const state = repoSlotState(value);
593
+ if (state.kind === "open") throw unbound(slot);
594
+ if (state.kind === "bound") {
595
+ bindings[slot] = { ...state.binding, perRun: false };
596
+ continue;
597
+ }
598
+ const mapped = state.mapper({ input: runInput });
599
+ const binding: Binding | undefined =
600
+ typeof mapped === "string" ? { url: mapped } : typeof mapped === "object" && mapped !== null ? mapped : undefined;
601
+ const bad: string[] = [];
602
+ if (typeof binding?.url !== "string" || !binding.url)
603
+ bad.push(`url (got ${JSON.stringify(binding === undefined ? mapped : binding.url)})`);
604
+ if (binding?.ref !== undefined && (typeof binding.ref !== "string" || !binding.ref))
605
+ bad.push(`ref (got ${JSON.stringify(binding.ref)})`);
606
+ if (bad.length) {
607
+ throw new Error(
608
+ `workspace spec invalid: repos.${slot} mapper returned ${bad.join("; ")} — the mapper derives from run ` +
609
+ "input; does `jr2 run --input` carry every field this workflow's workspace() slot reads?",
610
+ );
611
+ }
612
+ bindings[slot] = { ...binding!, perRun: true };
613
+ }
614
+ return bindings;
615
+ }
616
+
617
+ /** The port-facing view of the persisted bindings, in declaration order. */
618
+ function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot: string; url: string; ref?: string }> {
619
+ return Object.entries(bindings).map(([slot, b]) => ({
620
+ slot,
621
+ url: b.url,
622
+ ...(b.ref !== undefined ? { ref: b.ref } : {}),
623
+ }));
624
+ }
625
+
626
+ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any }) => WorkspaceSpec): AnyStateMachine {
627
+ const provision = fromPromise<
628
+ { endpoint: string; identity?: string; bindings: Record<string, ResolvedBinding> },
629
+ { wsId: string; spec: WorkspaceSpec; runInput: unknown }
630
+ >(async ({ input, self, system }) => {
631
+ assertSpec(input.spec); // before the port: a bad spec must never cost a pod
632
+ const binding = runBindingOf(system);
633
+ // The images and the slots come off the WRAPPER, at invoke time, not out of context and not
634
+ // out of a build-time closure (ADR-0049, ADR-0051). This state re-runs on every restore, so
635
+ // the re-read is the whole mechanism: a redeployed instance provisions what the Machine
636
+ // carries NOW, and no snapshot ever holds an image name — let alone a resolved
637
+ // content-addressed tag, which would outlive the image it names. The RESOLVED bindings are
638
+ // persisted, because a per-run mapper's answer is this run's fact.
639
+ const parts = sandboxPartsOf(invokingMachine(self));
640
+ const bindings = resolveBindings(
641
+ { workflow: binding.workflow, path: actorSlotPath(self._parent) },
642
+ parts.repos,
643
+ input.runInput,
644
+ );
645
+ const provisioned = await sandboxOf(system).provision({
646
+ name: workspaceName(binding.runId, input.wsId),
647
+ runId: binding.runId,
648
+ workflow: binding.workflow,
649
+ // The image strings straight through (ADR-0037/0005) — the port owns resolution, and the
650
+ // work group's default (ADR-0005 puts it in pod composition, where the pod is built).
651
+ ...(parts.image !== undefined ? { image: parts.image } : {}),
652
+ ...(parts.user !== undefined ? { user: parts.user } : {}),
653
+ ...(input.spec.workGroup !== undefined ? { workGroup: input.spec.workGroup } : {}),
654
+ repos: Object.entries(bindings).map(([slot, b]) => ({ slot, ...b })),
655
+ });
656
+ return { ...provisioned, bindings };
657
+ });
658
+
659
+ const attach = fromPromise<
660
+ { repos: Record<string, string>; review?: Record<string, string>; stale?: Record<string, string> },
661
+ { wsId: string; spec: WorkspaceSpec; bindings: Record<string, ResolvedBinding> }
662
+ >(async ({ input, system }) => {
663
+ const name = workspaceName(runBindingOf(system).runId, input.wsId);
664
+ const out = await sandboxOf(system).attach({ name, spec: input.spec, repos: attachedRepos(input.bindings) });
665
+ // Announced, never persisted (ADR-0051): a stale cache is a degraded attach the run proceeds
666
+ // through on the objects the node holds. The pod cannot heal it — the worktree's `origin`
667
+ // fetches from the cache, not the remote (ADR-0005: the pod holds no credential) — so stale
668
+ // lasts until the cache agent's next successful fetch lands in place, which the Agent's own
669
+ // `git fetch` then picks up. It is a notice, not a fact of the run.
670
+ for (const [slot, error] of Object.entries(out.stale ?? {})) {
671
+ console.error(`workspace ${name}: Repo Slot "${slot}" attached from a stale cache — ${error}`);
672
+ }
673
+ return out;
674
+ });
675
+
676
+ // The ambient registrar (ADR-0016): publishes this wrapper's handles for the parent-chain
677
+ // walk the Agent actor does. An INVOKED actor, co-invoked in `running` beside the body — invoked
678
+ // actors restart on snapshot restore (entry actions do not), so the publication is
679
+ // restore-safe by construction; and it is listed FIRST, so the handles are readable before
680
+ // the body's first Agent turn starts.
681
+ //
682
+ // The run-narrative echo (ADR-0023) rides the same seat: attaching here IS "at workspace
683
+ // attach" — the host replays the run's feed-so-far to this Workspace's Harness (the log opens
684
+ // with its preamble) and tees live thereafter — and the invoked-actor lifetime makes the tee
685
+ // restore-safe and self-detaching for free. Per-run binding, so the tee carries the OWNING
686
+ // run's lineage only, never a sibling run's.
687
+ const registrar = fromCallback<{ type: string }, { handles: MechanismHandles }>(({ input, self, system }) => {
688
+ const wrapperRef = self._parent;
689
+ if (!wrapperRef) return;
690
+ const disposeHandles = registerAmbientHandles(wrapperRef, input.handles);
691
+ const detachEcho = runBindingOf(system as AnyActorSystem).echo?.(input.handles.endpoint);
692
+ return () => {
693
+ detachEcho?.();
694
+ disposeHandles();
695
+ };
696
+ });
697
+
698
+ /**
699
+ * The lease (ADR-0021). One actor owns the whole exchange with the cluster for one workspace:
700
+ * it asserts liveness (nothing in the cluster represents a run, so the Orchestrator must keep
701
+ * saying "still mine" or the operator's idle GC reaps — ADR-0001) and, in the same call, reads
702
+ * back whether what it just stamped is still what the body attached to.
703
+ *
704
+ * Being an INVOKED actor is the whole design. Its lifetime IS `running`'s lifetime, which
705
+ * xstate already manages: it re-invokes on snapshot restore (so a restart reconciles for free,
706
+ * with no restore-specific code path), and it stops on every exit — body final, run stopped,
707
+ * run faulted. That last one is why there is no `release()`: a faulted run stops its actors,
708
+ * the lease stops with them, and the abandoned pod ages out of the idle timeout on its own.
709
+ *
710
+ * Level-triggered on purpose. The one-shot probe this replaces could only fire on entry, so a
711
+ * run parked on a gate for hours — the state most likely to outlive its Sandbox — never
712
+ * rechecked anything until the next restart.
713
+ */
714
+ const lease = fromCallback<{ type: string }, { wsId: string; identity?: string }>(({ input, system, sendBack }) => {
715
+ const port = sandboxOf(system);
716
+ const name = workspaceName(runBindingOf(system).runId, input.wsId);
717
+ let stopped = false;
718
+
719
+ const renew = async (): Promise<void> => {
720
+ let seen: Continuity;
721
+ try {
722
+ seen = await port.renew(name);
723
+ } catch {
724
+ return; // unknown, never lost: an API blip must not settle a run holding real work
725
+ }
726
+ if (stopped) return;
727
+ // Two ways to lose a workspace, one event. Gone: reaped, deleted, namespace cleared.
728
+ // Replaced: the CR survived an eviction or node loss but the pod behind it did not, so
729
+ // every name still resolves over an empty `work` volume. Re-provisioning either silently
730
+ // would resume into an inconsistent world — the body decides (ADR-0012).
731
+ const replaced =
732
+ seen.present && input.identity !== undefined && seen.identity !== undefined
733
+ ? seen.identity !== input.identity
734
+ : false;
735
+ if (!seen.present || replaced) sendBack({ type: "workspace.lost" });
736
+ };
737
+
738
+ void renew(); // immediately on entry: this is the restore-reconcile, no longer a special case
739
+ const timer = setInterval(() => void renew(), port.leaseIntervalMs ?? DEFAULT_LEASE_INTERVAL_MS);
740
+ timer.unref?.(); // a lease never holds the process open; it matters only while the run runs
741
+ return () => {
742
+ stopped = true;
743
+ clearInterval(timer);
744
+ };
745
+ });
746
+
747
+ const destroy = fromPromise<void, { wsId: string }>(async ({ input, system }) =>
748
+ sandboxOf(system).destroy(workspaceName(runBindingOf(system).runId, input.wsId)),
749
+ );
750
+
751
+ // Every actor this wrapper runs is a NAMED SLOT (ADR-0049), the body first among them: a Machine
752
+ // composes by invoking a declared `src`, and `body` is what `provide()`, `customize()`, Stately,
753
+ // the Console's join key and the `jr2 up` parts walk all reach it by. The mechanism's own four —
754
+ // provision, attach, registrar, lease, destroy — are named for the same price, and the Console
755
+ // now shows what each state is doing instead of "inline".
756
+ return setup({
757
+ actors: { body, provision, attach, registrar, lease, destroy },
758
+ }).createMachine({
759
+ id: "workspace",
760
+ context: ({ input, self }: { input: unknown; self: { id: string } }): WsContext => ({
761
+ runInput: (input ?? {}) as Record<string, unknown>,
762
+ wsId: self.id,
763
+ spec: spec({ input }),
764
+ }),
765
+ initial: "provisioning",
766
+ states: {
767
+ provisioning: {
768
+ invoke: {
769
+ src: "provision",
770
+ input: ({ context }) => ({
771
+ wsId: (context as unknown as WsContext).wsId,
772
+ spec: (context as unknown as WsContext).spec,
773
+ runInput: (context as unknown as WsContext).runInput,
774
+ }),
775
+ onDone: {
776
+ target: "attaching",
777
+ actions: assign({
778
+ endpoint: ({ event }) => (event as unknown as { output: { endpoint: string } }).output.endpoint,
779
+ identity: ({ event }) => (event as unknown as { output: { identity?: string } }).output.identity,
780
+ bindings: ({ event }) =>
781
+ (event as unknown as { output: { bindings: Record<string, ResolvedBinding> } }).output.bindings,
782
+ }),
783
+ },
784
+ },
785
+ },
786
+ attaching: {
787
+ invoke: {
788
+ src: "attach",
789
+ input: ({ context }) => ({
790
+ wsId: (context as unknown as WsContext).wsId,
791
+ spec: (context as unknown as WsContext).spec,
792
+ bindings: (context as unknown as WsContext).bindings!,
793
+ }),
794
+ onDone: {
795
+ target: "running",
796
+ actions: assign({
797
+ handles: ({ context, event, system }): MechanismHandles => {
798
+ const ctx = context as WsContext;
799
+ const out = (
800
+ event as unknown as {
801
+ output: { repos: Record<string, string>; review?: Record<string, string> };
802
+ }
803
+ ).output;
804
+ return {
805
+ endpoint: ctx.endpoint!,
806
+ // Derived, not remembered: the same function every port operation names the CR
807
+ // with, so the Sandbox the registrar publishes — and the Agent actor records on its
808
+ // registration — is the one the Adapter's token is scoped to, by construction
809
+ // (ADR-0013).
810
+ sandbox: workspaceName(runBindingOf(system as AnyActorSystem).runId, ctx.wsId),
811
+ repos: out.repos,
812
+ branch: ctx.spec.branch,
813
+ ...(out.review ? { review: out.review } : {}),
814
+ };
815
+ },
816
+ }),
817
+ },
818
+ },
819
+ },
820
+ running: {
821
+ invoke: [
822
+ // Registrar FIRST: the ambient handles must be readable before the body starts.
823
+ {
824
+ id: "registrar",
825
+ src: "registrar",
826
+ input: ({ context }) => ({ handles: (context as unknown as WsContext).handles! }),
827
+ },
828
+ {
829
+ id: "body",
830
+ // Annotated because the body is `AnyStateMachine`: its declared input type is opaque,
831
+ // so `setup()` has nothing to contextually type this callback's parameter from.
832
+ src: "body",
833
+ input: ({ context }: { context: WsContext }) => {
834
+ const ctx = context;
835
+ const { repos, branch, review } = ctx.handles!;
836
+ // Body-facing subset only (ADR-0016): endpoint/sandbox are mechanism-internal.
837
+ return {
838
+ ...ctx.runInput,
839
+ workspace: { repos, branch, ...(review ? { review } : {}) } satisfies WorkspaceHandles<string>,
840
+ };
841
+ },
842
+ onDone: {
843
+ target: "teardown",
844
+ actions: assign({ output: ({ event }) => (event as unknown as { output: unknown }).output }),
845
+ },
846
+ },
847
+ {
848
+ id: "lease",
849
+ src: "lease",
850
+ input: ({ context }) => ({
851
+ wsId: (context as unknown as WsContext).wsId,
852
+ identity: (context as unknown as WsContext).identity,
853
+ }),
854
+ },
855
+ ],
856
+ // The wrapper emits, the body decides (ADR-0012): forward loss into the body's policy.
857
+ on: { "workspace.lost": { actions: sendTo("body", { type: "workspace.lost" }) } },
858
+ },
859
+ teardown: {
860
+ invoke: {
861
+ src: "destroy",
862
+ input: ({ context }) => ({ wsId: (context as unknown as WsContext).wsId }),
863
+ onDone: "done",
864
+ // A failed delete is the operator GC's problem (ADR-0012 backstop), not the run's.
865
+ onError: "done",
866
+ },
867
+ },
868
+ done: { type: "final" },
869
+ },
870
+ // Machine output must be declared at the ROOT in xstate v5 (a final state's own `output`
871
+ // only rides the done event); the workspace's output is the body's, verbatim (ADR-0012).
872
+ output: ({ context }) => (context as unknown as WsContext).output,
873
+ });
874
+ }