@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
package/src/parts.ts ADDED
@@ -0,0 +1,500 @@
1
+ // The PARTS a Machine carries (ADR-0049, ADR-0051): its Agents, and — on a `workspace()` wrapper
2
+ // — the Sandbox Image the pod runs and the Repo Slots it attaches. A Machine carries everything it
3
+ // depends on and composes by invoke, so what a deployment must know about a workflow is neither a
4
+ // folder nor a config block: it is an attachment on the machine object plus a walk over it.
5
+ //
6
+ // Two halves, one file, because they are one idea:
7
+ //
8
+ // - The ATTACHMENT. `workspace()` stamps its static options here, keyed on `machine.config` —
9
+ // the raw object xstate's `.provide()` passes through unchanged, exactly as the vocabulary is
10
+ // keyed (vocabulary.ts). That is ADR-0049's one rule: parts resolve at invoke time through the
11
+ // LIVE actor's logic, never a build-time closure, so a `provide()` clone and a `customize()`
12
+ // retune both find the part the actor was actually invoked as. The image is therefore never
13
+ // persisted — the provisioning state re-reads it off the Machine on restore.
14
+ // - The WALK. Its callers are the converge and the boot (ADR-0018/0019/0031/0037/0051): the
15
+ // custom-provider preflight probes the models the registered Machines actually name, the
16
+ // Instance Harness converges when any of them declares `workspace: "none"`, every `file:`
17
+ // image context a Machine ships is built and content-tagged, every BOUND Repo is known before
18
+ // a run can ask for it, an OPEN part — a Repo Slot with no url, an Agent with no model
19
+ // (ADR-0054) — is refused before anything is built, and whether any registered Machine
20
+ // composes a Sandbox at all is the data-plane switch. None of these can read
21
+ // an invoke's `input` (it is a function — dials are not statically recoverable), and none
22
+ // needs to: identity lives in the definition, the image and the slots are options, and all are
23
+ // values ON the Machine. A per-run slot is a function too, and the walk reports nothing for
24
+ // it: which Repo it binds is the run's business, and the fence at attach is its check.
25
+ //
26
+ // The walk is STRUCTURAL, and it descends by the same two mechanisms composition uses:
27
+ //
28
+ // - `implementations.actors` — every slot a `setup()`/`jr2Setup()` machine declares, whether or
29
+ // not a state invokes it. An Agent slot is recognized by its brand (`isAgent`), a child
30
+ // Machine by having a state tree. That covers an imported Machine invoked as a child,
31
+ // `workspace()`'s `body`, and `pool()`'s `worker` — all named slots since ADR-0049.
32
+ // - inline invoked machines — a machine object written straight onto an `invoke.src`, which
33
+ // xstate rewrites to a generated key and keeps only on the raw config node. Nothing jr2 owns
34
+ // arrives this way any more; the walk keeps it because an AUTHOR may still write one.
35
+ //
36
+ // Deliberately NOT a `provide()`-aware read: the walk runs on the registered Machine, which is the
37
+ // object the run will start, so a `customize()` clone is walked as itself and a test seam's fake
38
+ // (unbranded) contributes nothing.
39
+
40
+ import { basename } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+ import type { AnyActorRef, AnyStateMachine, StateNode, UnknownActorLogic } from "xstate";
43
+ import { isAgent, isOpenAgent, type AgentDeclaration } from "./agent.ts";
44
+ import { isImageContext } from "./images.ts";
45
+ import { open } from "./open.ts";
46
+ import { repoIdentity } from "./repo-identity.ts";
47
+
48
+ // --- Repo Slots (ADR-0051) ---------------------------------------------------------------------
49
+ // A `workspace()` names each Repo it attaches under a SLOT — the Machine's own word for it, the
50
+ // key of the body's `workspace.repos` handles, and the directory under `/work`. The slot's VALUE
51
+ // is one of three states, and the walk tells them apart without evaluating anything. A Machine
52
+ // whose body names no slot — it enumerates whatever is attached — declares the whole MAP Open
53
+ // instead (`repos: open`): the composer names every slot, in an order the Machine may give a
54
+ // meaning to. Open at the map is the same word as Open at a slot, one level up (CONTEXT.md).
55
+
56
+ // The sentinel itself lives in open.ts, a module with nothing else in it: since ADR-0054 it marks
57
+ // an Agent's model too, and agent.ts must read it without importing this file (the walk imports
58
+ // agent.ts, not the other way round). Re-exported here because a Repo Slot is where a composer
59
+ // meets it first, and `import { open } from "@jr2/orchestrator"` is the only spelling anyone writes.
60
+ export { open } from "./open.ts";
61
+
62
+ /** What a Repo Slot resolves to (CONTEXT.md "Binding"): the url, and the base the branch
63
+ * Worktree is cut from — absent, the Repo's own default branch. */
64
+ export type Binding = { url: string; ref?: string };
65
+
66
+ /**
67
+ * One Repo Slot's value: BOUND (a url, or `{ url, ref? }`), OPEN (the {@link open} sentinel —
68
+ * someone downstream binds it), or PER-RUN (a mapper over the wrapper's door, so a run input — a
69
+ * ticket field — decides). Bound and open are what `jr2 up` can see; per-run is the run's business,
70
+ * fenced at attach by `git.credentials` (ADR-0051).
71
+ */
72
+ export type RepoSlot<TInput = unknown> =
73
+ | typeof open
74
+ | string
75
+ | Binding
76
+ | ((args: { input: TInput }) => string | Binding);
77
+
78
+ /** The three states, read off a slot's value. */
79
+ export type RepoSlotState =
80
+ | { kind: "open" }
81
+ | { kind: "bound"; binding: Binding }
82
+ | { kind: "per-run"; mapper: (args: { input: unknown }) => string | Binding };
83
+
84
+ /** Classify one slot's value. Assumes the value passed {@link assertRepoSlot}. */
85
+ export function repoSlotState(value: RepoSlot<any>): RepoSlotState {
86
+ if (value === open) return { kind: "open" };
87
+ if (typeof value === "function") return { kind: "per-run", mapper: value };
88
+ return { kind: "bound", binding: typeof value === "string" ? { url: value } : value };
89
+ }
90
+
91
+ /** A slot key becomes a directory name under `/work`, so it is held to what a path segment can
92
+ * carry — and to what a prompt can name without quoting. It starts with a LETTER: the handles
93
+ * keep declaration order, which a Machine may give a meaning to (`task`: the first slot is the
94
+ * one the coder edits), and JS puts an integer-like key such as `"1"` ahead of every other key in
95
+ * `Object.keys`, wherever the author wrote it. */
96
+ export const REPO_SLOT_KEY = /^[A-Za-z][A-Za-z0-9._-]*$/;
97
+
98
+ /**
99
+ * Refuse a malformed slot value BY NAME, at build time — the same derives-from-a-typo class the
100
+ * spec guard catches for the branch, one build earlier. A bound url is also parsed here: an
101
+ * identity the walk cannot derive (a relative path, an unknown scheme) is refused where the
102
+ * author wrote it rather than at the converge that walks it. `where` names the caller
103
+ * (`workspace()`, `customize()`).
104
+ */
105
+ export function assertRepoSlot(where: string, slot: string, value: unknown): asserts value is RepoSlot<any> {
106
+ if (!REPO_SLOT_KEY.test(slot)) {
107
+ throw new Error(
108
+ `${where}: Repo Slot key ${JSON.stringify(slot)} is not a directory name — a slot becomes ` +
109
+ "`/work/<slot>` and its order matters, so it starts with a letter and matches " +
110
+ "/^[A-Za-z][A-Za-z0-9._-]*$/ (ADR-0051).",
111
+ );
112
+ }
113
+ if (value === open || typeof value === "function") return;
114
+ const binding = typeof value === "string" ? { url: value } : (value as Partial<Binding> | null | undefined);
115
+ const bad = (what: string) =>
116
+ new Error(
117
+ `${where}: Repo Slot "${slot}" is ${what} — a slot is \`open\`, a url, \`{ url, ref? }\`, or a mapper ` +
118
+ "`({ input }) => url | { url, ref? }` over the door (ADR-0051).",
119
+ );
120
+ if (typeof binding !== "object" || binding === null) throw bad(`not a binding (got ${JSON.stringify(value)})`);
121
+ if (typeof binding.url !== "string" || !binding.url)
122
+ throw bad(`bound to an empty url (got ${JSON.stringify(value)})`);
123
+ if (binding.ref !== undefined && (typeof binding.ref !== "string" || !binding.ref))
124
+ throw bad(`bound with an empty ref (got ${JSON.stringify(value)})`);
125
+ try {
126
+ repoIdentity(binding.url);
127
+ } catch (err) {
128
+ throw new Error(
129
+ `${where}: Repo Slot "${slot}" binds ${JSON.stringify(binding.url)}, which names no Repo — ` +
130
+ `${err instanceof Error ? err.message : err} (ADR-0051).`,
131
+ );
132
+ }
133
+ }
134
+
135
+ /**
136
+ * The static parts a `workspace()` wrapper carries (ADR-0049, ADR-0037, ADR-0051): what the
137
+ * Sandbox is MADE of and which Repos it attaches. The images are NAMES in ADR-0037's two shapes — a
138
+ * `file:` URL to a docker context the Machine's module ships, or a registry ref — and both resolve
139
+ * to a concrete ref on the port's side, which is what keeps the Machine cluster-agnostic. The
140
+ * Repos are Slots, each bound, open, or per-run.
141
+ *
142
+ * They are options rather than `WorkspaceSpec` fields because they are STATIC: `jr2 up` must find
143
+ * them by walking the Machine, and a per-run spec is a function of run input that no walk can
144
+ * evaluate.
145
+ */
146
+ export type SandboxParts = {
147
+ /** The Sandbox Image. Absent → the Instance's `images/default`, then the stock Harness. */
148
+ image?: string;
149
+ /** The User Container's image (ADR-0005). Absent → the pod has no third container. */
150
+ user?: string;
151
+ /** The Repo Slots, in declaration order — which the handles keep (ADR-0051) — or
152
+ * {@link open} for a map the composer fills whole. */
153
+ repos: Record<string, RepoSlot> | typeof open;
154
+ };
155
+
156
+ // The stamps below are written ON `machine.config` under `Symbol.for` keys, never held in a
157
+ // module-local WeakMap, for the reason `open` and `asMachine` give: an Instance resolves its OWN
158
+ // `@jr2/orchestrator`, and the installed CLI walks with ITS copy (ADR-0043) — a WeakMap the
159
+ // Instance's `workspace()` filled is one the CLI's `partsOf` never sees, and the walk then reports
160
+ // a Machine that composes no Sandbox: no Sandbox Image built, no cache agent converged, and a
161
+ // provision that parks on a mount nothing serves. `machine.config` is the key either way, because
162
+ // a `.provide()` clone shares it (ADR-0011) and a `customize()` clone re-stamps. A symbol property
163
+ // is invisible to `JSON.stringify` and `Object.keys`, so xstate and the fingerprint read past it.
164
+ const SANDBOX_PARTS: unique symbol = Symbol.for("jr2.sandbox.parts");
165
+ const WRAPPER_BODY: unique symbol = Symbol.for("jr2.wrapper.body");
166
+ type Stamped = { [SANDBOX_PARTS]?: SandboxParts; [WRAPPER_BODY]?: string };
167
+
168
+ /** Attach a wrapper's static Sandbox parts. jr2-internal: `workspace()` calls it. */
169
+ export function attachSandboxParts(machine: AnyStateMachine, parts: SandboxParts): void {
170
+ (machine.config as Stamped)[SANDBOX_PARTS] = parts;
171
+ }
172
+
173
+ /** The Sandbox parts a Machine carries — no images and no slots for any Machine that is not a
174
+ * `workspace()` wrapper, which is the honest answer: it composes no Sandbox. */
175
+ export function sandboxPartsOf(machine: AnyStateMachine | undefined): SandboxParts {
176
+ return (machine?.config as Stamped | undefined)?.[SANDBOX_PARTS] ?? { repos: {} };
177
+ }
178
+
179
+ /** Does this Machine COMPOSE a Sandbox — i.e. is it a `workspace()` wrapper? Distinct from
180
+ * `sandboxPartsOf(m)` naming no image: a wrapper that names neither image still owns the two
181
+ * seats, which is what `customize({ image })` retunes (ADR-0049) — and it always carries at least
182
+ * one Repo Slot (ADR-0051). Also the data-plane switch, read off the walk: an Instance needs
183
+ * Sandboxes exactly when a registered Machine composes one. */
184
+ export function composesSandbox(machine: AnyStateMachine): boolean {
185
+ return (machine.config as Stamped)[SANDBOX_PARTS] !== undefined;
186
+ }
187
+
188
+ // The slot a jr2 WRAPPER is transparent to (ADR-0049): `workspace()`'s `body`, `pool()`'s
189
+ // `worker`. A wrapper owns Sandbox lifecycle or Source scheduling and carries no Agents of its
190
+ // own, so a composer who writes `customize(research, { agents: { researcher } })` means the
191
+ // Machine inside — and never has to know that jr2 wrapped it, or spell `body`.
192
+ //
193
+ // Recorded, not inferred: a slot named `body` is a name any author may choose, and routing a
194
+ // customize through it because of its spelling would retune a different Machine than the
195
+ // composer named. The wrappers stamp this the same way they stamp everything else a Machine
196
+ // carries — on `machine.config`, so a `provide()` clone and a `customize()` retune keep it, and
197
+ // under a `Symbol.for` key, so a second module copy reads it (see `SANDBOX_PARTS`).
198
+
199
+ /** Mark this Machine a jr2 wrapper over `slot`. jr2-internal: `workspace()`/`pool()` call it. */
200
+ export function attachWrapperBody(machine: AnyStateMachine, slot: string): void {
201
+ (machine.config as Stamped)[WRAPPER_BODY] = slot;
202
+ }
203
+
204
+ /** The slot a jr2 wrapper is transparent to — undefined for every Machine an author wrote, which
205
+ * is where a `customize()` stops descending and starts resolving. */
206
+ export function wrapperBodyOf(machine: AnyStateMachine): string | undefined {
207
+ return (machine.config as Stamped)[WRAPPER_BODY];
208
+ }
209
+
210
+ declare const wrapperBody: unique symbol;
211
+
212
+ /**
213
+ * The TYPE half of {@link attachWrapperBody}'s stamp: a jr2 wrapper's machine type SAYS which
214
+ * Machine it is transparent to, so `customize()`'s types read the same record its runtime walk
215
+ * reads (`wrapperBodyOf`) and the compiler's answer and the runtime's are one answer.
216
+ *
217
+ * Recorded here too, for the reason the runtime records it: a slot named `body` or `worker` is a
218
+ * name any author may choose, so a type that routed a `customize()` through a slot's SPELLING
219
+ * would offer the composer the Agents of a Machine they never named — and would deny the Agents
220
+ * of the one they did, since the reach stops at a Machine that is not a wrapper. Only a wrapper
221
+ * carries this marker, and only `workspace()` and `pool()` write it.
222
+ *
223
+ * Phantom: the property exists in the type alone. A wrapper's implementation returns a plain
224
+ * machine and its overloads state this, exactly as the runtime stamp lives beside the object
225
+ * rather than on it.
226
+ */
227
+ export type JR2Wrapper<TBody extends AnyStateMachine> = { readonly [wrapperBody]: TBody };
228
+
229
+ declare const repoSlots: unique symbol;
230
+
231
+ /**
232
+ * The TYPE half of a wrapper's Repo Slots (ADR-0051): a `workspace()`'s machine type SAYS which
233
+ * slots it declared, so `customize()`'s `repos` offers exactly those keys and a slot the Machine
234
+ * never declared is a compile error — read through `pool()`'s `worker` and `workspace()`'s
235
+ * `body` the way the image seats are. Phantom, like {@link JR2Wrapper}: the property exists in the
236
+ * type alone, and the runtime reads the same record off `sandboxPartsOf`.
237
+ */
238
+ export type JR2Repos<TSlots extends string> = { readonly [repoSlots]: TSlots };
239
+
240
+ /**
241
+ * The actor-slot union a jr2 wrapper declares, in xstate's own `ProvidedActor` shape. `workspace()`
242
+ * and `pool()` name it in their return types beside {@link JR2Wrapper}, so the body's own slots are
243
+ * readable THROUGH the wrapper and `customize()` can offer the composer the Agents of the Machine
244
+ * inside (customize.ts) — the compile-time twin of the walk above.
245
+ *
246
+ * The mechanism actors ride along as `UnknownActorLogic`: they are named slots (Stately shows
247
+ * `provision`, not `inline`) but nothing outside the wrapper substitutes them, so their logic
248
+ * types buy nothing and would drag the port contracts into every consumer's inference.
249
+ */
250
+ export type WrapperActors<TSlot extends string, TBody extends AnyStateMachine, TMechanism extends string> =
251
+ | { src: TSlot; logic: TBody; id: string | undefined }
252
+ | { src: TMechanism; logic: UnknownActorLogic; id: string | undefined };
253
+
254
+ /** One Agent a Machine carries: the SLOT KEY it is declared under (its name everywhere — the
255
+ * Harness route, the minted iid, the markers) and the declaration that slot runs — Open model and
256
+ * all (ADR-0054), because a converge that could not see an unbound Agent could not refuse it. */
257
+ export type CarriedAgent = { name: string; definition: AgentDeclaration };
258
+
259
+ /**
260
+ * One docker context a Machine ships (ADR-0037's built origin) — a `file:` URL a module named with
261
+ * `import.meta.resolve`, which is the only way an ES module can name a folder it owns.
262
+ *
263
+ * `dir` is that URL as a path, which is what `docker build` takes and what the content digest
264
+ * covers. `name` is the directory's basename and is DECORATION — it rides the image tag so a human
265
+ * reading `docker images` sees something better than a hash. The identity is the digest.
266
+ */
267
+ export type CarriedImage = { url: string; dir: string; name: string };
268
+
269
+ /**
270
+ * One Repo a Machine BINDS (ADR-0051) — the Binding as written, plus the identity every spelling
271
+ * of one repository normalizes to and the key the cluster addresses its cache by (repo-identity.ts).
272
+ * Deduped by identity: two Machines spelling one repository two ways are one Repo, and the first
273
+ * spelling in walk order is the one the CR is created with.
274
+ */
275
+ export type CarriedRepo = { url: string; ref?: string; identity: string; key: string };
276
+
277
+ /**
278
+ * A part left OPEN on a registered Machine — a Repo Slot with no url (ADR-0051), an Agent with no
279
+ * model (ADR-0054) — and what `jr2 up` refuses, before anything is built, naming the Machine, the
280
+ * slot, and the `customize` line that binds it. One shape for both, because a composer fixes both
281
+ * the same way and the walk locates both the same way.
282
+ *
283
+ * The Machine is named by WHERE it sits, not by its xstate id: `path` is the chain of actor-slot
284
+ * keys a `customize()` of the registered root walks to reach the `workspace()` that declares the
285
+ * slot — `[]` when the root is that wrapper, `["review"]` for a Machine composed under `actors:
286
+ * { review }`. It is exactly the `actors:` nesting of the fix line ({@link customizeLine}), so
287
+ * jr2's transparent wrappers (`workspace()`'s `body`, `pool()`'s `worker`) are omitted from it as
288
+ * `customize()` omits them. `undefined` when the wrapper was reached through a Machine invoked
289
+ * INLINE (a machine object written straight onto `invoke.src`): an actor with no slot key is one
290
+ * no `customize()` can name, so no line binds that slot — declaring it under `setup({ actors })`
291
+ * does.
292
+ *
293
+ * `slot` is `undefined` for a `workspace()` that declared its whole map Open (`repos: open`,
294
+ * ADR-0051): there is no slot to name, because naming the slots is what the composer does.
295
+ */
296
+ export type OpenSlot = { slot: string | undefined; path: readonly string[] | undefined };
297
+
298
+ /** Which kind of Open part a fix line binds — the two `customize()` keys (ADR-0051, ADR-0054). */
299
+ export type OpenPart = "repo" | "agent";
300
+
301
+ /**
302
+ * The `customize` line that binds an Open part, given the identifier the composer holds the
303
+ * registered Machine by: `customize(codeReview, { repos: { target: "<url>" } })` for a part on the
304
+ * root, nested through `actors` for one on a composed Machine —
305
+ * `customize(top, { actors: { review: { repos: { target: "<url>" } } } })`. The same nesting
306
+ * `customize()` accepts, so the line pastes.
307
+ *
308
+ * An Agent binds through `agents` instead, and the placeholder is the model spelling ADR-0018
309
+ * demands — `<provider>/<model>`, not a bare model id, since the prefix is what picks the
310
+ * endpoint. `repo` is the default because a Repo Slot was the first Open part and reads as the
311
+ * unmarked case. An Open MAP has no slot to print (`slot` undefined): the placeholder `<slot>`
312
+ * stands where the composer's own word goes, since choosing it is the composer's half of the line.
313
+ */
314
+ export function customizeLine(
315
+ machine: string,
316
+ path: readonly string[],
317
+ slot: string | undefined,
318
+ part: OpenPart = "repo",
319
+ ): string {
320
+ const binds =
321
+ part === "agent"
322
+ ? `{ agents: { ${slot}: { model: "<provider>/<model>" } } }`
323
+ : `{ repos: { ${slot ?? "<slot>"}: "<url>" } }`;
324
+ const inner = path.reduceRight((parts, key) => `{ actors: { ${key}: ${parts} } }`, binds);
325
+ return `customize(${machine}, ${inner})`;
326
+ }
327
+
328
+ /**
329
+ * The runtime twin of the walk's `path`: the actor-slot chain from a run's root actor down to
330
+ * `actor` (its own `src` included), read off the live actor tree — each `src` is the slot key it
331
+ * was invoked or spawned as, and a wrapper's transparent body is skipped by the same record
332
+ * ({@link wrapperBodyOf}) the walk skips it by. `undefined` past an inline-invoked actor, whose
333
+ * `src` is the logic itself and names no slot.
334
+ */
335
+ export function actorSlotPath(actor: AnyActorRef | undefined): string[] | undefined {
336
+ const path: string[] = [];
337
+ for (let node = actor; node?._parent; node = node._parent) {
338
+ const src = (node as { src?: unknown }).src;
339
+ if (typeof src !== "string") return undefined;
340
+ const parent = asMachine((node._parent as { logic?: unknown }).logic);
341
+ if (!parent || wrapperBodyOf(parent) !== src) path.unshift(src);
342
+ }
343
+ return path;
344
+ }
345
+
346
+ /** Everything the registered Machines carry that a converge or a boot must act on. */
347
+ export type CarriedParts = {
348
+ agents: CarriedAgent[];
349
+ images: CarriedImage[];
350
+ /** Every bound Repo, deduped by identity, in walk order. */
351
+ repos: CarriedRepo[];
352
+ /** Every open Repo Slot, in walk order — non-empty is a converge refusal. An Open MAP
353
+ * (`repos: open`) is one entry with no `slot`. */
354
+ openSlots: OpenSlot[];
355
+ /** Every Agent whose model is still Open (ADR-0054), in walk order — the same refusal, by the
356
+ * same route, and the reason these are two lists rather than one: the fix lines differ. */
357
+ openAgents: OpenSlot[];
358
+ /** Whether any Machine reached, at any depth, composes a Sandbox — the data-plane switch. */
359
+ composesSandbox: boolean;
360
+ };
361
+
362
+ /** A machine actor, told apart from a promise/callback/observable one by having a state tree.
363
+ * Structural on purpose: an Instance resolves its OWN `@jr2/orchestrator`, so the CLI's walk and a
364
+ * workflow's machines may come from two module instances and no `instanceof` can hold. */
365
+ export function asMachine(logic: unknown): AnyStateMachine | undefined {
366
+ return (logic as AnyStateMachine | undefined)?.root ? (logic as AnyStateMachine) : undefined;
367
+ }
368
+
369
+ /** Every machine object written INLINE on one state's invokes. A named `src` resolves through
370
+ * `implementations.actors` instead and is walked there. */
371
+ function inlineMachines(node: StateNode<any, any>): AnyStateMachine[] {
372
+ const config = [node.config.invoke ?? []].flat() as Array<{ src?: unknown }>;
373
+ return config.flatMap((inv) => {
374
+ const machine = asMachine(inv?.src);
375
+ return machine ? [machine] : [];
376
+ });
377
+ }
378
+
379
+ /** `JSON.stringify` with object keys sorted at every depth — a value's identity, not its spelling.
380
+ * Symbols are spelled out rather than dropped: `JSON.stringify` silently omits a symbol-valued
381
+ * key, which would make an Open Agent (ADR-0054) collapse into a bound one that differs in nothing
382
+ * but its model. */
383
+ function canonical(value: unknown): string {
384
+ return JSON.stringify(value, (_key, v) =>
385
+ typeof v === "symbol"
386
+ ? v.toString()
387
+ : v !== null && typeof v === "object" && !Array.isArray(v)
388
+ ? Object.fromEntries(
389
+ Object.keys(v as Record<string, unknown>)
390
+ .sort()
391
+ .map((k) => [k, (v as Record<string, unknown>)[k]]),
392
+ )
393
+ : v,
394
+ );
395
+ }
396
+
397
+ /**
398
+ * Everything the given Machines carry, themselves and through the Machines they compose — deduped,
399
+ * in walk order.
400
+ *
401
+ * Two Machines in one instance may each carry a `coder`, and if their definitions differ BOTH are
402
+ * reported: they are two Agents that share a slot key, which is exactly what ADR-0049 made legal
403
+ * and what a flat roster could not hold. Identical ones collapse, so a definition value shared by
404
+ * three Machines is preflighted once — and so is one docker context two Workspaces name.
405
+ */
406
+ export function partsOf(machines: Iterable<AnyStateMachine>): CarriedParts {
407
+ const agents: CarriedAgent[] = [];
408
+ const images: CarriedImage[] = [];
409
+ const repos: CarriedRepo[] = [];
410
+ const openSlots: OpenSlot[] = [];
411
+ const openAgents: OpenSlot[] = [];
412
+ let sandboxed = false;
413
+ const seen = new Set<string>();
414
+ // Cycle guard AND work saver: a Machine reached twice carries the same parts both times, and a
415
+ // Machine that composes itself is legal (a recursive pool worker) but not walkable twice.
416
+ const walked = new Set<AnyStateMachine>();
417
+
418
+ const collectAgent = (path: OpenSlot["path"], name: string, definition: AgentDeclaration): void => {
419
+ // Keyed on the PAIR, serialized whole: a separator character inside a template literal is
420
+ // either ambiguous (a slot key may contain it) or, if chosen for being impossible, a control
421
+ // byte that makes this module binary to git — invisible to diff, blame and grep, forever.
422
+ // Serialized CANONICALLY (keys sorted at every depth): two structurally equal definitions
423
+ // written in a different key order are one Agent, and must collapse to one preflight.
424
+ const key = canonical(["agent", name, definition]);
425
+ if (seen.has(key)) return;
426
+ seen.add(key);
427
+ agents.push({ name, definition });
428
+ // Reported, not withheld: the Agent is still carried (a `workspace: "none"` one still
429
+ // converges the Instance Harness), and it is the converge's job to refuse it by name —
430
+ // `jr2 up`'s preflight simply has no model to probe for it (ADR-0054).
431
+ if (isOpenAgent(definition)) openAgents.push({ slot: name, path });
432
+ };
433
+
434
+ // Only the BUILT origin is collected: a registry ref is deployed-never-built, so there is
435
+ // nothing for a converge to do with it (ADR-0037/0039 — what jr2 did not stamp, jr2 does not
436
+ // touch). Both image seats ride the same rule, because ADR-0005 gives the User Container the
437
+ // same two origins and the same resolution.
438
+ const collectImage = (url: string | undefined): void => {
439
+ if (url === undefined || !isImageContext(url)) return;
440
+ const key = canonical(["image", url]);
441
+ if (seen.has(key)) return;
442
+ seen.add(key);
443
+ const dir = fileURLToPath(url);
444
+ images.push({ url, dir, name: basename(dir) });
445
+ };
446
+
447
+ // Keyed on the IDENTITY, not the spelling (ADR-0051): `git@github.com:acme/app.git` and
448
+ // `https://github.com/acme/app` are one Repo and one cache, so they collapse to one entry — the
449
+ // first spelling wins, and it is the url the CR is created with. A per-run slot contributes
450
+ // nothing, and an open one is reported for the converge to refuse — as is an Open map, which
451
+ // has no slot to report and is refused by the same route.
452
+ const collectRepos = (path: OpenSlot["path"], slots: SandboxParts["repos"]): void => {
453
+ if (slots === open) {
454
+ openSlots.push({ slot: undefined, path });
455
+ return;
456
+ }
457
+ for (const [slot, value] of Object.entries(slots)) {
458
+ const state = repoSlotState(value);
459
+ if (state.kind === "open") openSlots.push({ slot, path });
460
+ if (state.kind !== "bound") continue;
461
+ const { identity, key } = repoIdentity(state.binding.url);
462
+ const dedupe = canonical(["repo", identity]);
463
+ if (seen.has(dedupe)) continue;
464
+ seen.add(dedupe);
465
+ repos.push({ ...state.binding, identity, key });
466
+ }
467
+ };
468
+
469
+ const walkStates = (node: StateNode<any, any>, visit: (machine: AnyStateMachine) => void): void => {
470
+ for (const machine of inlineMachines(node)) visit(machine);
471
+ for (const child of Object.values(node.states as Record<string, StateNode<any, any>>)) walkStates(child, visit);
472
+ };
473
+
474
+ // `path` is the `customize()` route from the registered root to `machine` (see `OpenSlot`):
475
+ // a named child slot extends it, a wrapper's transparent body does not, and an inline invoke
476
+ // ends it — nothing downstream of an actor without a slot key can be named by a composer.
477
+ const walk = (machine: AnyStateMachine, path: OpenSlot["path"]): void => {
478
+ if (walked.has(machine)) return;
479
+ walked.add(machine);
480
+ const parts = sandboxPartsOf(machine);
481
+ collectImage(parts.image);
482
+ collectImage(parts.user);
483
+ if (composesSandbox(machine)) {
484
+ sandboxed = true;
485
+ collectRepos(path, parts.repos);
486
+ }
487
+ const body = wrapperBodyOf(machine);
488
+ for (const [name, logic] of Object.entries(machine.implementations.actors as Record<string, unknown>)) {
489
+ if (isAgent(logic)) collectAgent(path, name, logic.definition);
490
+ else {
491
+ const child = asMachine(logic);
492
+ if (child) walk(child, path === undefined ? undefined : name === body ? path : [...path, name]);
493
+ }
494
+ }
495
+ walkStates(machine.root, (inline) => walk(inline, undefined));
496
+ };
497
+
498
+ for (const machine of machines) walk(machine, []);
499
+ return { agents, images, repos, openSlots, openAgents, composesSandbox: sandboxed };
500
+ }