@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/parts.ts CHANGED
@@ -16,8 +16,9 @@
16
16
  // Instance Harness converges when any of them declares `workspace: "none"`, every `file:`
17
17
  // image context a Machine ships is built and content-tagged, every BOUND Repo is known before
18
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
19
+ // (ADR-0054) — is refused before anything is built, every Size a `workspace()` states is split
20
+ // and held to the Harness floor (ADR-0060), and whether any registered Machine composes a
21
+ // Sandbox at all is the data-plane switch. None of these can read
21
22
  // an invoke's `input` (it is a function — dials are not statically recoverable), and none
22
23
  // needs to: identity lives in the definition, the image and the slots are options, and all are
23
24
  // values ON the Machine. A per-run slot is a function too, and the walk reports nothing for
@@ -33,6 +34,13 @@
33
34
  // xstate rewrites to a generated key and keeps only on the raw config node. Nothing jr2 owns
34
35
  // arrives this way any more; the walk keeps it because an AUTHOR may still write one.
35
36
  //
37
+ // A PART written inline is refused, not walked (`refuseInlineParts`): an `agent()`, an Effect
38
+ // (ADR-0067) or a `workspace()` on an `invoke.src` has no author-written name. xstate's generated
39
+ // key (`xstate.invoke.<n>.<state id>`) moves when a state is renamed, so the ledger, the Console doc
40
+ // and `jr2 up`'s report would name it by a number, and `customize()` — xstate's `provide`, keyed on
41
+ // `actors` — could not reach it at all. A slot key is the one name every reader shares (ADR-0049),
42
+ // so a Part is a key of `actors`, and the refusal says how to write it.
43
+ //
36
44
  // Deliberately NOT a `provide()`-aware read: the walk runs on the registered Machine, which is the
37
45
  // object the run will start, so a `customize()` clone is walked as itself and a test seam's fake
38
46
  // (unbranded) contributes nothing.
@@ -41,9 +49,13 @@ import { basename } from "node:path";
41
49
  import { fileURLToPath } from "node:url";
42
50
  import type { AnyActorRef, AnyStateMachine, StateNode, UnknownActorLogic } from "xstate";
43
51
  import { isAgent, isOpenAgent, type AgentDeclaration } from "./agent.ts";
52
+ import { argvText, isEffect, placementOf, type EffectParts, type Placement } from "./effect.ts";
53
+ import { effectBindingLine, openPartsOf, type OpenEffectPart } from "./open-effect.ts";
44
54
  import { isImageContext } from "./images.ts";
55
+ import { isOpenBody, openBodyDeclarationOf, type OpenBodyDeclaration } from "./open-body.ts";
45
56
  import { open } from "./open.ts";
46
57
  import { repoIdentity } from "./repo-identity.ts";
58
+ import { userSeatOf, type Size, type UserContainer } from "./size.ts";
47
59
 
48
60
  // --- Repo Slots (ADR-0051) ---------------------------------------------------------------------
49
61
  // A `workspace()` names each Repo it attaches under a SLOT — the Machine's own word for it, the
@@ -51,7 +63,7 @@ import { repoIdentity } from "./repo-identity.ts";
51
63
  // is one of three states, and the walk tells them apart without evaluating anything. A Machine
52
64
  // whose body names no slot — it enumerates whatever is attached — declares the whole MAP Open
53
65
  // 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).
66
+ // meaning to. Open at the map is the same word as Open at a slot, one level up (ADR-0051).
55
67
 
56
68
  // The sentinel itself lives in open.ts, a module with nothing else in it: since ADR-0054 it marks
57
69
  // an Agent's model too, and agent.ts must read it without importing this file (the walk imports
@@ -59,7 +71,7 @@ import { repoIdentity } from "./repo-identity.ts";
59
71
  // meets it first, and `import { open } from "@jr2/orchestrator"` is the only spelling anyone writes.
60
72
  export { open } from "./open.ts";
61
73
 
62
- /** What a Repo Slot resolves to (CONTEXT.md "Binding"): the url, and the base the branch
74
+ /** What a Repo Slot resolves to (GLOSSARY.md "Binding"): the url, and the base the branch
63
75
  * Worktree is cut from — absent, the Repo's own default branch. */
64
76
  export type Binding = { url: string; ref?: string };
65
77
 
@@ -146,11 +158,20 @@ export function assertRepoSlot(where: string, slot: string, value: unknown): ass
146
158
  export type SandboxParts = {
147
159
  /** The Sandbox Image. Absent → the Instance's `images/default`, then the stock Harness. */
148
160
  image?: string;
149
- /** The User Container's image (ADR-0005). Absent → the pod has no third container. */
150
- user?: string;
161
+ /** The User Container (ADR-0005): its image, or `{ image, resources }` when the Machine states
162
+ * its split of the Size (ADR-0060). Absent → the pod has no third container. */
163
+ user?: string | UserContainer;
151
164
  /** The Repo Slots, in declaration order — which the handles keep (ADR-0051) — or
152
165
  * {@link open} for a map the composer fills whole. */
153
166
  repos: Record<string, RepoSlot> | typeof open;
167
+ /** The Size the Machine states (ADR-0060) — the `workspace()`'s, with any `customize()` layered
168
+ * over it. Absent → the Instance's `sandbox.resources`, then the kit default. */
169
+ resources?: Size;
170
+ /** The Held secrets this Workspace's pod gets beside its Agents' model keys (ADR-0059): names of
171
+ * the `secrets` list. Naming is placement — no other pod gets a name for being listed here. */
172
+ secrets?: string[];
173
+ /** The Sandbox Effector's image (ADR-0068). Absent → the Sandbox Image. */
174
+ effector?: { image: string };
154
175
  };
155
176
 
156
177
  // The stamps below are written ON `machine.config` under `Symbol.for` keys, never held in a
@@ -180,9 +201,10 @@ export function sandboxPartsOf(machine: AnyStateMachine | undefined): SandboxPar
180
201
  * `sandboxPartsOf(m)` naming no image: a wrapper that names neither image still owns the two
181
202
  * seats, which is what `customize({ image })` retunes (ADR-0049) — and it always carries at least
182
203
  * 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. */
204
+ * Sandboxes exactly when a registered Machine composes one. A logic with no `config` (an Effect's,
205
+ * ADR-0067) composes none. */
184
206
  export function composesSandbox(machine: AnyStateMachine): boolean {
185
- return (machine.config as Stamped)[SANDBOX_PARTS] !== undefined;
207
+ return (machine.config as Stamped | undefined)?.[SANDBOX_PARTS] !== undefined;
186
208
  }
187
209
 
188
210
  // The slot a jr2 WRAPPER is transparent to (ADR-0049): `workspace()`'s `body`, `pool()`'s
@@ -295,8 +317,43 @@ export type CarriedRepo = { url: string; ref?: string; identity: string; key: st
295
317
  */
296
318
  export type OpenSlot = { slot: string | undefined; path: readonly string[] | undefined };
297
319
 
298
- /** Which kind of Open part a fix line binds — the two `customize()` keys (ADR-0051, ADR-0054). */
299
- export type OpenPart = "repo" | "agent";
320
+ /**
321
+ * One hop of a placement route (ADR-0059, ADR-0068): the `actors` slot key a child is invoked by,
322
+ * or — for a plain child Machine written INLINE on a state's invoke — xstate's generated invoke
323
+ * src for that hop (`xstate.invoke.<index>.<state id>`), which the static walk computes from the
324
+ * state node and the run reads off the live child. An inline hop is an object, so it can never
325
+ * equal a slot key.
326
+ */
327
+ export type RouteHop = string | { readonly inline: string };
328
+
329
+ /**
330
+ * Where a placement sits: the chain of hops from the registered root down to it, a wrapper's
331
+ * transparent body skipped as {@link OpenSlot}'s `path` skips it. Unlike `path`, a route goes on
332
+ * past an inline Machine, so every placement — a Workspace's pod, an Effect — has a route of its
333
+ * own, and the pod keys of two Workspaces never collide. Where `path` is defined the route is that
334
+ * same list of slot keys, so a pod key reached by slot keys alone is what it was.
335
+ */
336
+ export type PlacementRoute = readonly RouteHop[];
337
+
338
+ /** The hop xstate gives the `index`-th invoke of state node `stateId` when its `src` is inline. */
339
+ export const inlineHop = (index: number, stateId: string): RouteHop => ({
340
+ inline: `xstate.invoke.${index}.${stateId}`,
341
+ });
342
+
343
+ /** A route as a human reads it, for a label: ` → "review"`, ` → invoked inline in state "w.a"`. */
344
+ export function routeText(route: PlacementRoute): string {
345
+ return route
346
+ .map((hop) =>
347
+ typeof hop === "string"
348
+ ? ` → "${hop}"`
349
+ : ` → invoked inline in state "${hop.inline.replace(/^xstate\.invoke\.\d+\./, "")}"`,
350
+ )
351
+ .join("");
352
+ }
353
+
354
+ /** Which kind of Open part a fix line binds — a Repo Slot (`repos`, ADR-0051), an Agent's model
355
+ * (`agents`, ADR-0054), or an Effect's url, argv or secret (`effects`, ADR-0054). */
356
+ export type OpenPart = "repo" | "agent" | OpenEffectPart;
300
357
 
301
358
  /**
302
359
  * The `customize` line that binds an Open part, given the identifier the composer holds the
@@ -305,22 +362,26 @@ export type OpenPart = "repo" | "agent";
305
362
  * `customize(top, { actors: { review: { repos: { target: "<url>" } } } })`. The same nesting
306
363
  * `customize()` accepts, so the line pastes.
307
364
  *
308
- * An Agent binds through `agents` instead, and the placeholder is the model spelling ADR-0018
365
+ * An Agent binds through `agents` instead, and the sample value is the model spelling ADR-0018
309
366
  * 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>`
367
+ * endpoint. An Effect binds through `effects`: its url, its argv, its secret, or one object with two
368
+ * of them for an Effect with two Open parts (open-effect.ts). `repo` is the
369
+ * default because a Repo Slot was the first Open part and reads as the
370
+ * unmarked case. An Open MAP has no slot to print (`slot` undefined): the sample `<slot>`
312
371
  * stands where the composer's own word goes, since choosing it is the composer's half of the line.
313
372
  */
314
373
  export function customizeLine(
315
374
  machine: string,
316
375
  path: readonly string[],
317
376
  slot: string | undefined,
318
- part: OpenPart = "repo",
377
+ part: OpenPart | readonly OpenEffectPart[] = "repo",
319
378
  ): string {
320
379
  const binds =
321
380
  part === "agent"
322
381
  ? `{ agents: { ${slot}: { model: "<provider>/<model>" } } }`
323
- : `{ repos: { ${slot ?? "<slot>"}: "<url>" } }`;
382
+ : part === "repo"
383
+ ? `{ repos: { ${slot ?? "<slot>"}: "<url>" } }`
384
+ : effectBindingLine(slot ?? "<slot>", part);
324
385
  const inner = path.reduceRight((parts, key) => `{ actors: { ${key}: ${parts} } }`, binds);
325
386
  return `customize(${machine}, ${inner})`;
326
387
  }
@@ -329,8 +390,10 @@ export function customizeLine(
329
390
  * The runtime twin of the walk's `path`: the actor-slot chain from a run's root actor down to
330
391
  * `actor` (its own `src` included), read off the live actor tree — each `src` is the slot key it
331
392
  * 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.
393
+ * ({@link wrapperBodyOf}) the walk skips it by. `undefined` past an inline-invoked actor, as the
394
+ * walk's `path` is: such an actor names no slot. xstate gives an inline `invoke.src` a generated
395
+ * string (`xstate.invoke.<n>.<state id>`) that is no key of its parent's `actors`, so the test is
396
+ * that registry, not the type of `src`; inline logic passed to `spawn` keeps the logic as `src`.
334
397
  */
335
398
  export function actorSlotPath(actor: AnyActorRef | undefined): string[] | undefined {
336
399
  const path: string[] = [];
@@ -338,14 +401,105 @@ export function actorSlotPath(actor: AnyActorRef | undefined): string[] | undefi
338
401
  const src = (node as { src?: unknown }).src;
339
402
  if (typeof src !== "string") return undefined;
340
403
  const parent = asMachine((node._parent as { logic?: unknown }).logic);
404
+ if (parent && !Object.hasOwn(parent.implementations.actors, src)) return undefined;
341
405
  if (!parent || wrapperBodyOf(parent) !== src) path.unshift(src);
342
406
  }
343
407
  return path;
344
408
  }
345
409
 
410
+ /**
411
+ * The runtime twin of the walk's `route` (ADR-0059): the placement route from a run's root actor
412
+ * down to `actor`, read off the live actor tree. A child invoked by a slot key adds that key, as
413
+ * {@link actorSlotPath} does; a plain Machine invoked inline adds xstate's generated src as an
414
+ * inline hop, which the walk computes from the state node — so `jr2 up` and the provision derive
415
+ * one pod key. Never undefined: an actor the walk cannot see — a Machine object handed to
416
+ * `spawnChild`, whose `src` is the logic itself — is refused with the slot form.
417
+ */
418
+ export function actorRoute(actor: AnyActorRef | undefined): RouteHop[] {
419
+ const route: RouteHop[] = [];
420
+ for (let node = actor; node?._parent; node = node._parent) {
421
+ const src = (node as { src?: unknown }).src;
422
+ const parent = asMachine((node._parent as { logic?: unknown }).logic);
423
+ if (typeof src === "string" && (!parent || Object.hasOwn(parent.implementations.actors, src))) {
424
+ if (!parent || wrapperBodyOf(parent) !== src) route.unshift(src);
425
+ } else if (typeof src === "string" && /^xstate\.invoke\.\d+\./.test(src)) {
426
+ route.unshift({ inline: src });
427
+ } else {
428
+ throw new Error(
429
+ `actor "${node.id}" was spawned from a logic object, which no walk can see, so jr2 up placed nothing ` +
430
+ "for what it composes — a Part is reached through keys of actors, so it has a name that the ledger, " +
431
+ 'the report and customize() share: actors: { <name>: <machine> }, then spawnChild("<name>") (ADR-0059)',
432
+ );
433
+ }
434
+ }
435
+ return route;
436
+ }
437
+
438
+ /**
439
+ * One `workspace()` a Machine composes, and the Size it states (ADR-0060) — what `jr2 up` resolves
440
+ * against the Instance default and splits, so a split that leaves the Harness below its floor is
441
+ * refused at the converge rather than at the first provision, and a Size no Sandbox node could hold
442
+ * is warned about. Located like an {@link OpenSlot}: `path` is the `customize()` route to it.
443
+ */
444
+ export type CarriedSize = {
445
+ path: readonly string[] | undefined;
446
+ /** The placement route to it (ADR-0059). */
447
+ route: PlacementRoute;
448
+ /** The Machine's Size, as stated; absent when it states none. */
449
+ resources?: Size;
450
+ /** The User Container's split, as stated; absent when it states none. */
451
+ user?: Size;
452
+ };
453
+
454
+ /** One Effect slot a Machine carries (ADR-0067), by its slot key and the `customize()` route to its
455
+ * Machine, with the plain data `jr2 up` prints: the kind, where it runs, a command's argv (an argv
456
+ * slot reads `<name>`), its timeout, and the Repo Slot whose Worktree is its cwd, when it names one.
457
+ * `open` names the parts a package left Open (ADR-0054) — an http url, which then has no target
458
+ * yet, or a command's argv, which reads `<open>`, and a secret its auth, a header or its env names
459
+ * as `secret(open)`, which names no secret yet — and the walk reports them for `jr2 up` to refuse;
460
+ * once `customize()` binds them, the slot carries the bound Effect and `open` is absent. */
461
+ export type CarriedEffect = {
462
+ name: string;
463
+ path: readonly string[] | undefined;
464
+ /** The placement route to its Machine (ADR-0068): what its placement key reads, never `path`. */
465
+ route: PlacementRoute;
466
+ placement: Placement;
467
+ timeoutMs: number;
468
+ } &
469
+ /** A command; one whose env names a secret runs in the Sandbox Effector (ADR-0067). */
470
+ (| {
471
+ kind: "command";
472
+ argv: string[];
473
+ slot?: string;
474
+ secrets?: string[];
475
+ pin?: true;
476
+ open?: Array<"argv" | "secret">;
477
+ }
478
+ /** An http Effect (ADR-0068): its method, its fixed target, and the secrets it names — what
479
+ * places each secret, and what its Effector's config allows. `file`: its body is a Worktree
480
+ * file (ADR-0067), so it runs in its Workspace's Sandbox Effector, on Repo Slot `slot` when named.
481
+ * An Open url has no target, and names only the secrets its headers and auth name. */
482
+ | {
483
+ kind: "http";
484
+ method: string;
485
+ target?: { scheme: "http" | "https"; host: string; port: number };
486
+ secrets: string[];
487
+ idempotent: boolean;
488
+ file?: true;
489
+ slot?: string;
490
+ open?: Array<"url" | "secret">;
491
+ }
492
+ /** A push (ADR-0067, ADR-0051): the Workspace's branch to its Repo Slot's Binding. It names no
493
+ * secret: `jr2 up` resolves its credential through `git.credentials`. */
494
+ | { kind: "push"; slot?: string; sign: boolean; squash: boolean; force: boolean; pin: boolean; secrets: string[] }
495
+ );
496
+
346
497
  /** Everything the registered Machines carry that a converge or a boot must act on. */
347
498
  export type CarriedParts = {
348
499
  agents: CarriedAgent[];
500
+ /** Every Effect slot, in walk order — one per Machine and slot key, at the first route the walk
501
+ * reached its Machine by. The placement reads {@link placedEffectsOf}: one per route. */
502
+ effects: CarriedEffect[];
349
503
  images: CarriedImage[];
350
504
  /** Every bound Repo, deduped by identity, in walk order. */
351
505
  repos: CarriedRepo[];
@@ -355,8 +509,32 @@ export type CarriedParts = {
355
509
  /** Every Agent whose model is still Open (ADR-0054), in walk order — the same refusal, by the
356
510
  * same route, and the reason these are two lists rather than one: the fix lines differ. */
357
511
  openAgents: OpenSlot[];
512
+ /** Every Effect whose url, argv or secret is still Open (ADR-0054), in walk order — the same
513
+ * refusal, by the same route; `parts` says which line binds it: one object for both of two. */
514
+ openEffects: Array<OpenSlot & { parts: OpenEffectPart[] }>;
515
+ /** Every `workspace()` reached, with the Size it states, in walk order (ADR-0060). */
516
+ sizes: CarriedSize[];
358
517
  /** Whether any Machine reached, at any depth, composes a Sandbox — the data-plane switch. */
359
518
  composesSandbox: boolean;
519
+ /**
520
+ * Every Open body slot (ADR-0070), in walk order, with its declaration: what it lends, what a Chart
521
+ * may define, its ceilings. The one Open part a run binds, not a composer, so it is never in an
522
+ * Open list: an Open body whose lent names did not resolve never reaches the walk (createMachine
523
+ * refused it).
524
+ */
525
+ openBodies: Array<{ slot: string; declaration: OpenBodyDeclaration; path: OpenSlot["path"] }>;
526
+ /**
527
+ * Every model a key is placed and a provider preflighted for: each bound Agent's, and each model
528
+ * an Open body allows a Chart's own Agents (ADR-0070: a minted model is a door refusal, never a
529
+ * 401 mid-run). Deduped, sorted.
530
+ */
531
+ models: string[];
532
+ /**
533
+ * The Instance Harness is needed (ADR-0031): an Agent declares `workspace: "none"`, or an Open
534
+ * body allows it. `models` are the models its Turns may run: those Agents', and those Open bodies'
535
+ * allowed ones. Absent when nothing needs it.
536
+ */
537
+ instanceHarness?: { models: string[] };
360
538
  };
361
539
 
362
540
  /** A machine actor, told apart from a promise/callback/observable one by having a state tree.
@@ -366,16 +544,48 @@ export function asMachine(logic: unknown): AnyStateMachine | undefined {
366
544
  return (logic as AnyStateMachine | undefined)?.root ? (logic as AnyStateMachine) : undefined;
367
545
  }
368
546
 
369
- /** Every machine object written INLINE on one state's invokes. A named `src` resolves through
547
+ /** Every machine object written INLINE on one state's invokes, with the hop xstate gives it (its
548
+ * generated src, from the invoke's index on the state). A named `src` resolves through
370
549
  * `implementations.actors` instead and is walked there. */
371
- function inlineMachines(node: StateNode<any, any>): AnyStateMachine[] {
550
+ function inlineMachines(node: StateNode<any, any>): Array<{ machine: AnyStateMachine; hop: RouteHop }> {
372
551
  const config = [node.config.invoke ?? []].flat() as Array<{ src?: unknown }>;
373
- return config.flatMap((inv) => {
552
+ return config.flatMap((inv, index) => {
374
553
  const machine = asMachine(inv?.src);
375
- return machine ? [machine] : [];
554
+ return machine ? [{ machine, hop: inlineHop(index, node.id) }] : [];
376
555
  });
377
556
  }
378
557
 
558
+ /**
559
+ * Refuse a Part written inline on an `invoke.src` — an `agent()`, an Effect or a `workspace()` that
560
+ * is no key of the Machine's `actors` (see the module comment). Thrown at build by
561
+ * `jr2Setup.createMachine` and at load by {@link partsOf}, so a Machine built with xstate's own
562
+ * `createMachine` is refused where `jr2 up` and the Orchestrator read it. A plain child Machine
563
+ * written inline is not a Part: it stays walked, with no `customize()` route (its `path` is
564
+ * `undefined`).
565
+ */
566
+ export function refuseInlineParts(machine: AnyStateMachine): void {
567
+ const partOf = (src: unknown): { part: string; written: string } | undefined => {
568
+ if (isAgent(src)) return { part: "an Agent", written: "agent(...)" };
569
+ if (isEffect(src)) return { part: "an Effect", written: `${src.effect.kind}(...)` };
570
+ const child = asMachine(src);
571
+ if (child && composesSandbox(child)) return { part: "a Workspace", written: "workspace(...)" };
572
+ return undefined;
573
+ };
574
+ const visit = (node: StateNode<any, any>): void => {
575
+ for (const inv of [node.config.invoke ?? []].flat() as Array<{ src?: unknown }>) {
576
+ const found = partOf(inv?.src);
577
+ if (found === undefined) continue;
578
+ throw new Error(
579
+ `machine "${machine.id}": state "${node.id}" invokes ${found.part} inline — a Part is a key of actors, so ` +
580
+ `it has a name that the ledger, the report and customize() share: actors: { <name>: ${found.written} }, ` +
581
+ `then invoke: { src: "<name>" }`,
582
+ );
583
+ }
584
+ for (const child of Object.values(node.states as Record<string, StateNode<any, any>>)) visit(child);
585
+ };
586
+ visit(machine.root);
587
+ }
588
+
379
589
  /** `JSON.stringify` with object keys sorted at every depth — a value's identity, not its spelling.
380
590
  * Symbols are spelled out rather than dropped: `JSON.stringify` silently omits a symbol-valued
381
591
  * key, which would make an Open Agent (ADR-0054) collapse into a bound one that differs in nothing
@@ -405,10 +615,16 @@ function canonical(value: unknown): string {
405
615
  */
406
616
  export function partsOf(machines: Iterable<AnyStateMachine>): CarriedParts {
407
617
  const agents: CarriedAgent[] = [];
618
+ const effects: CarriedEffect[] = [];
408
619
  const images: CarriedImage[] = [];
409
620
  const repos: CarriedRepo[] = [];
410
621
  const openSlots: OpenSlot[] = [];
411
622
  const openAgents: OpenSlot[] = [];
623
+ const openEffects: CarriedParts["openEffects"] = [];
624
+ const sizes: CarriedSize[] = [];
625
+ const openBodies: CarriedParts["openBodies"] = [];
626
+ // The Machine that declares each Open body: where its lent Agents are read (not carried data).
627
+ const bodyWrappers = new Map<CarriedParts["openBodies"][number], AnyStateMachine>();
412
628
  let sandboxed = false;
413
629
  const seen = new Set<string>();
414
630
  // Cycle guard AND work saver: a Machine reached twice carries the same parts both times, and a
@@ -466,35 +682,316 @@ export function partsOf(machines: Iterable<AnyStateMachine>): CarriedParts {
466
682
  }
467
683
  };
468
684
 
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
685
  // `path` is the `customize()` route from the registered root to `machine` (see `OpenSlot`):
475
686
  // a named child slot extends it, a wrapper's transparent body does not, and an inline invoke
476
687
  // 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 => {
688
+ // `route` is the placement route, which an inline invoke extends by its hop (`PlacementRoute`).
689
+ const walk = (machine: AnyStateMachine, path: OpenSlot["path"], route: PlacementRoute): void => {
478
690
  if (walked.has(machine)) return;
479
691
  walked.add(machine);
692
+ refuseInlineParts(machine);
480
693
  const parts = sandboxPartsOf(machine);
694
+ const user = userSeatOf(parts.user);
481
695
  collectImage(parts.image);
482
- collectImage(parts.user);
696
+ collectImage(user.image);
697
+ collectImage(parts.effector?.image);
483
698
  if (composesSandbox(machine)) {
484
699
  sandboxed = true;
485
700
  collectRepos(path, parts.repos);
701
+ sizes.push({
702
+ path,
703
+ route,
704
+ ...(parts.resources !== undefined ? { resources: parts.resources } : {}),
705
+ ...(user.resources !== undefined ? { user: user.resources } : {}),
706
+ });
486
707
  }
487
708
  const body = wrapperBodyOf(machine);
488
709
  for (const [name, logic] of Object.entries(machine.implementations.actors as Record<string, unknown>)) {
489
710
  if (isAgent(logic)) collectAgent(path, name, logic.definition);
490
- else {
711
+ else if (isOpenBody(logic)) {
712
+ const body = { slot: name, declaration: openBodyDeclarationOf(logic), path };
713
+ openBodies.push(body);
714
+ bodyWrappers.set(body, machine);
715
+ } else if (isEffect(logic)) {
716
+ const effect = carriedEffectOf(name, path, route, logic.effect);
717
+ effects.push(effect);
718
+ if (effect.kind !== "push" && effect.open !== undefined)
719
+ openEffects.push({ slot: name, path, parts: [...effect.open] });
720
+ } else {
491
721
  const child = asMachine(logic);
492
- if (child) walk(child, path === undefined ? undefined : name === body ? path : [...path, name]);
722
+ if (child) walk(child, slotPath(path, name, body), name === body ? route : [...route, name]);
493
723
  }
494
724
  }
495
- walkStates(machine.root, (inline) => walk(inline, undefined));
725
+ walkStates(machine.root, (inline, hop) => walk(inline, undefined, [...route, hop]));
726
+ };
727
+
728
+ for (const machine of machines) walk(machine, [], []);
729
+ const bound = (m: unknown): m is string => typeof m === "string";
730
+ const models = new Set<string>(agents.map((a) => a.definition.model).filter(bound));
731
+ for (const body of openBodies) for (const m of body.declaration.allows.models) models.add(m);
732
+ const menuOnly = agents.filter((a) => a.definition.workspace === "none");
733
+ const noneBodies = openBodies.filter((b) => bodyTurnsRunIn(bodyWrappers.get(b)!, b.declaration, "instance"));
734
+ const harnessModels = new Set<string>(menuOnly.map((a) => a.definition.model).filter(bound));
735
+ for (const body of noneBodies) for (const m of body.declaration.allows.models) harnessModels.add(m);
736
+ return {
737
+ agents,
738
+ effects,
739
+ images,
740
+ repos,
741
+ openSlots,
742
+ openAgents,
743
+ openEffects,
744
+ sizes,
745
+ composesSandbox: sandboxed,
746
+ openBodies,
747
+ models: [...models].sort(),
748
+ ...(menuOnly.length || noneBodies.length ? { instanceHarness: { models: [...harnessModels].sort() } } : {}),
496
749
  };
750
+ }
751
+
752
+ /**
753
+ * Can a Turn under an Open body of `wrapper` run where `where` says, with a model the body allows
754
+ * (ADR-0070: a minted model needs a key the Assembly placed, a door refusal and never a 401)? A
755
+ * Chart's own Agent runs where its Workspace access says, so a body that allows "write" or "read"
756
+ * runs Turns in a pod, and one that allows "none" at the Instance Harness. A lent Agent runs where
757
+ * its own access says, and a Chart may set its `model` dial to an allowed model: a lent Agent that
758
+ * is not "none" is a pod Turn, and a "none" one an Instance Harness Turn.
759
+ */
760
+ function bodyTurnsRunIn(wrapper: AnyStateMachine, decl: OpenBodyDeclaration, where: "pod" | "instance"): boolean {
761
+ const atInstance = (w: string | undefined) => w === "none";
762
+ if (decl.allows.workspace.some((w) => (where === "instance" ? atInstance(w) : !atInstance(w)))) return true;
763
+ const actors = wrapper.implementations.actors as Record<string, unknown>;
764
+ return (decl.lends.agents ?? []).some((slot) => {
765
+ const logic = actors[slot];
766
+ if (!isAgent(logic)) return false;
767
+ const access = (logic.definition as { workspace?: string }).workspace;
768
+ return where === "instance" ? atInstance(access) : !atInstance(access);
769
+ });
770
+ }
771
+
772
+ /** The `customize()` route to a child of slot `name`: a wrapper's body does not extend it. */
773
+ function slotPath(path: OpenSlot["path"], name: string, body: string | undefined): OpenSlot["path"] {
774
+ return path === undefined ? undefined : name === body ? path : [...path, name];
775
+ }
776
+
777
+ /**
778
+ * Every Effect the registered Machine `root` places, at every route it is reached by — one entry
779
+ * per route, as {@link workspacePodsOf} lists pods: a Machine invoked under two slot keys places
780
+ * its Effects twice, and each is checked where it is. What the placement walk reads (ADR-0068),
781
+ * never {@link partsOf}'s list, which holds one entry per Machine. A cycle (a recursive pool
782
+ * worker) stops at the Machine on its own route.
783
+ */
784
+ export function placedEffectsOf(root: AnyStateMachine): CarriedEffect[] {
785
+ const effects: CarriedEffect[] = [];
786
+ const walk = (
787
+ machine: AnyStateMachine,
788
+ path: OpenSlot["path"],
789
+ route: PlacementRoute,
790
+ on: Set<AnyStateMachine>,
791
+ ): void => {
792
+ if (on.has(machine)) return;
793
+ on.add(machine);
794
+ const body = wrapperBodyOf(machine);
795
+ for (const [name, logic] of Object.entries(machine.implementations.actors as Record<string, unknown>)) {
796
+ if (isAgent(logic)) continue;
797
+ if (isEffect(logic)) {
798
+ effects.push(carriedEffectOf(name, path, route, logic.effect));
799
+ continue;
800
+ }
801
+ const child = asMachine(logic);
802
+ if (child) walk(child, slotPath(path, name, body), name === body ? route : [...route, name], on);
803
+ }
804
+ walkStates(machine.root, (inline, hop) => walk(inline, undefined, [...route, hop], on));
805
+ on.delete(machine);
806
+ };
807
+ walk(root, [], [], new Set());
808
+ return effects;
809
+ }
810
+
811
+ /** One Effect slot's plain data, as `jr2 up` and the placement read it. */
812
+ export function carriedEffectOf(
813
+ name: string,
814
+ path: OpenSlot["path"],
815
+ route: PlacementRoute,
816
+ parts: EffectParts,
817
+ ): CarriedEffect {
818
+ const placement = placementOf(parts);
819
+ const openParts = openPartsOf(parts);
820
+ if (parts.kind === "http") {
821
+ return {
822
+ name,
823
+ path,
824
+ route,
825
+ kind: "http",
826
+ placement,
827
+ method: parts.method,
828
+ ...(parts.target === open ? {} : { target: { ...parts.target } }),
829
+ ...(openParts.length ? { open: openParts as Array<"url" | "secret"> } : {}),
830
+ secrets: [...parts.secrets],
831
+ idempotent: parts.idempotent,
832
+ timeoutMs: parts.timeoutMs,
833
+ ...(parts.file ? { file: true as const } : {}),
834
+ ...(parts.file?.slot !== undefined ? { slot: parts.file.slot } : {}),
835
+ };
836
+ }
837
+ if (parts.kind === "push") {
838
+ const { sign, squash, force, pin, timeoutMs, slot } = parts;
839
+ return {
840
+ name,
841
+ path,
842
+ route,
843
+ kind: "push",
844
+ placement,
845
+ sign,
846
+ squash,
847
+ force,
848
+ pin,
849
+ secrets: [],
850
+ timeoutMs,
851
+ ...(slot !== undefined ? { slot } : {}),
852
+ };
853
+ }
854
+ const { argv, timeoutMs, slot, secrets, pin } = parts;
855
+ return {
856
+ name,
857
+ path,
858
+ route,
859
+ kind: "command",
860
+ placement,
861
+ argv: argvText(argv),
862
+ timeoutMs,
863
+ ...(slot !== undefined ? { slot } : {}),
864
+ ...(secrets?.length ? { secrets: [...secrets] } : {}),
865
+ ...(pin ? { pin } : {}),
866
+ ...(openParts.length ? { open: openParts as Array<"argv" | "secret"> } : {}),
867
+ };
868
+ }
869
+
870
+ /**
871
+ * One Workspace a Machine composes, as its pod's Held secrets are placed (ADR-0059): what the pod
872
+ * names. Located like a {@link CarriedSize} — `path` is the `customize()` route to its
873
+ * `workspace()`, and `route` its placement route, the same route the run host reads off the live
874
+ * actor at provision (`actorRoute`), so `jr2 up` and the provision name one pod alike.
875
+ */
876
+ export type WorkspacePod = {
877
+ path: readonly string[] | undefined;
878
+ /** The pod's placement route: what its pod key reads (`workspaceHeldPod`). */
879
+ route: PlacementRoute;
880
+ /** The `workspace()`'s own `secrets: [...]`, as written. */
881
+ secrets: string[];
882
+ /** The model of every bound Agent whose Turn runs in this pod: each Agent below the
883
+ * `workspace()` that is not `workspace: "none"` (those run at the Instance Harness, ADR-0031),
884
+ * down to the next `workspace()`, which is a pod of its own, and every model an Open body below
885
+ * it allows a Chart's own Agents when it allows "write" or "read" (ADR-0070). Sorted, deduped. */
886
+ models: string[];
887
+ /** The Effects placed in this pod's Sandbox Effector (ADR-0068): every `push`, file-body `http` and
888
+ * credentialed `command` slot below the `workspace()`, down to the next `workspace()` — read off
889
+ * their plain data, statically. Empty → the pod gets no Sandbox Effector and no Secret. */
890
+ effects: CarriedEffect[];
891
+ /** The `workspace()`'s `effector: { image }`, as written. Absent → the Sandbox Image. */
892
+ effector?: { image: string };
893
+ /** The `workspace()`'s own `image`, as written — its Sandbox Image. Absent → `images/default`,
894
+ * then the stock Harness (ADR-0037). */
895
+ image?: string;
896
+ /** Each Repo Slot's Binding url as the Machine binds it, by slot, in order; undefined for an open
897
+ * or per-run slot, whose url `jr2 up` cannot read. A push resolves its credential through it. */
898
+ bindings: Record<string, string | undefined>;
899
+ };
900
+
901
+ /**
902
+ * Every Workspace a Machine composes, at every route it is reached by — one entry per route, so a
903
+ * `workspace()` composed under two slot keys is two pods. Not deduped like {@link partsOf}: a pod
904
+ * is a route, not a Machine. A cycle (a recursive pool worker) stops at the Machine on its own
905
+ * route.
906
+ */
907
+ export function workspacePodsOf(root: AnyStateMachine): WorkspacePod[] {
908
+ const pods: WorkspacePod[] = [];
909
+ /** The bound models of the Agents in `machine` and the Machines it composes, short of a
910
+ * `workspace()`. */
911
+ const modelsIn = (
912
+ machine: AnyStateMachine,
913
+ models: Set<string>,
914
+ on: Set<AnyStateMachine>,
915
+ effects: Map<string, CarriedEffect>,
916
+ path: OpenSlot["path"],
917
+ route: PlacementRoute,
918
+ ): void => {
919
+ if (on.has(machine)) return;
920
+ on.add(machine);
921
+ const body = wrapperBodyOf(machine);
922
+ const visit = (child: AnyStateMachine, at: OpenSlot["path"], to: PlacementRoute) => {
923
+ if (!composesSandbox(child)) modelsIn(child, models, on, effects, at, to);
924
+ };
925
+ for (const [name, logic] of Object.entries(machine.implementations.actors as Record<string, unknown>)) {
926
+ if (isAgent(logic)) {
927
+ const { model, workspace } = logic.definition;
928
+ if (workspace !== "none" && typeof model === "string") models.add(model);
929
+ continue;
930
+ }
931
+ // A Turn under an Open body may run in this pod with any model the body allows (ADR-0070):
932
+ // a Chart's own Agent, when the body allows a Workspace access other than "none", and a lent
933
+ // Agent that is not "none", whose `model` dial a Chart may set to an allowed model.
934
+ if (isOpenBody(logic)) {
935
+ const decl = openBodyDeclarationOf(logic);
936
+ if (bodyTurnsRunIn(machine, decl, "pod")) for (const m of decl.allows.models) models.add(m);
937
+ continue;
938
+ }
939
+ if (isEffect(logic)) {
940
+ const effect = carriedEffectOf(name, path, route, logic.effect);
941
+ if (effect.placement === "sandbox") effects.set(JSON.stringify([route, name]), effect);
942
+ continue;
943
+ }
944
+ const child = asMachine(logic);
945
+ if (child) visit(child, slotPath(path, name, body), name === body ? route : [...route, name]);
946
+ }
947
+ walkStates(machine.root, (inline, hop) => visit(inline, undefined, [...route, hop]));
948
+ on.delete(machine);
949
+ };
950
+ const walk = (
951
+ machine: AnyStateMachine,
952
+ path: OpenSlot["path"],
953
+ route: PlacementRoute,
954
+ on: Set<AnyStateMachine>,
955
+ ): void => {
956
+ if (on.has(machine)) return;
957
+ on.add(machine);
958
+ if (composesSandbox(machine)) {
959
+ const models = new Set<string>();
960
+ const effects = new Map<string, CarriedEffect>();
961
+ modelsIn(machine, models, new Set(), effects, path, route);
962
+ const parts = sandboxPartsOf(machine);
963
+ const bindings: Record<string, string | undefined> = {};
964
+ if (parts.repos !== open) {
965
+ for (const [slot, value] of Object.entries(parts.repos)) {
966
+ const state = repoSlotState(value);
967
+ bindings[slot] = state.kind === "bound" ? state.binding.url : undefined;
968
+ }
969
+ }
970
+ pods.push({
971
+ path,
972
+ route,
973
+ secrets: [...(parts.secrets ?? [])],
974
+ models: [...models].sort(),
975
+ effects: [...effects.values()],
976
+ ...(parts.effector ? { effector: { ...parts.effector } } : {}),
977
+ ...(parts.image !== undefined ? { image: parts.image } : {}),
978
+ bindings,
979
+ });
980
+ }
981
+ const body = wrapperBodyOf(machine);
982
+ for (const [name, logic] of Object.entries(machine.implementations.actors as Record<string, unknown>)) {
983
+ const child = isAgent(logic) || isEffect(logic) ? undefined : asMachine(logic);
984
+ if (child) walk(child, slotPath(path, name, body), name === body ? route : [...route, name], on);
985
+ }
986
+ walkStates(machine.root, (inline, hop) => walk(inline, undefined, [...route, hop], on));
987
+ on.delete(machine);
988
+ };
989
+ walk(root, [], [], new Set());
990
+ return pods;
991
+ }
497
992
 
498
- for (const machine of machines) walk(machine, []);
499
- return { agents, images, repos, openSlots, openAgents, composesSandbox: sandboxed };
993
+ /** Every machine object written inline below `node`, at any depth of its state tree, with its hop. */
994
+ function walkStates(node: StateNode<any, any>, visit: (machine: AnyStateMachine, hop: RouteHop) => void): void {
995
+ for (const { machine, hop } of inlineMachines(node)) visit(machine, hop);
996
+ for (const child of Object.values(node.states as Record<string, StateNode<any, any>>)) walkStates(child, visit);
500
997
  }