@jr2/orchestrator 0.3.0 → 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 (73) 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 -4
  13. package/src/actor.ts +502 -49
  14. package/src/ambient.ts +2 -0
  15. package/src/assembly-checks.ts +165 -0
  16. package/src/assembly-tar.ts +494 -0
  17. package/src/assembly.ts +721 -0
  18. package/src/chart.ts +2050 -0
  19. package/src/charts.ts +720 -0
  20. package/src/config.ts +152 -46
  21. package/src/content-hash.ts +105 -0
  22. package/src/custodian.lua +6 -5
  23. package/src/custodian.ts +71 -19
  24. package/src/customize.ts +216 -25
  25. package/src/effect-checks.ts +557 -0
  26. package/src/effect.ts +905 -0
  27. package/src/effector-wire.ts +525 -0
  28. package/src/fingerprint.ts +4 -3
  29. package/src/gate.ts +8 -0
  30. package/src/git-clone.ts +65 -0
  31. package/src/git-push.ts +336 -0
  32. package/src/harness-client.ts +245 -23
  33. package/src/held-secrets.ts +82 -26
  34. package/src/holds.ts +156 -0
  35. package/src/http-effect.ts +981 -0
  36. package/src/http.ts +496 -104
  37. package/src/images.ts +47 -21
  38. package/src/index.ts +43 -2
  39. package/src/instance.ts +444 -58
  40. package/src/kube-client.ts +333 -0
  41. package/src/machine-doc.ts +88 -6
  42. package/src/mount-digest.ts +53 -0
  43. package/src/names.ts +66 -8
  44. package/src/notices.ts +88 -0
  45. package/src/open-body.ts +1444 -0
  46. package/src/open-effect.ts +144 -0
  47. package/src/open.ts +4 -4
  48. package/src/parts.ts +530 -33
  49. package/src/party.ts +468 -0
  50. package/src/pick.ts +309 -0
  51. package/src/pool.ts +1 -1
  52. package/src/registration.ts +301 -17
  53. package/src/repo-fetch.ts +60 -85
  54. package/src/repos.ts +63 -74
  55. package/src/rest.ts +133 -0
  56. package/src/run-host.ts +1832 -98
  57. package/src/sandbox-effect.ts +891 -0
  58. package/src/sandbox-effector.ts +111 -0
  59. package/src/sandbox-kube.ts +1719 -0
  60. package/src/sandbox-watch.ts +260 -0
  61. package/src/secret-placement.ts +494 -0
  62. package/src/server.ts +281 -84
  63. package/src/setup.ts +589 -70
  64. package/src/size.ts +342 -0
  65. package/src/snapshot-store.ts +64 -13
  66. package/src/stub-harness.ts +5 -0
  67. package/src/sync-client.ts +54 -0
  68. package/src/timers.ts +178 -0
  69. package/src/tokens.ts +35 -4
  70. package/src/vocabulary.ts +66 -1
  71. package/src/wire.ts +156 -2
  72. package/src/workspace.ts +529 -194
  73. package/src/sandbox-kubectl.ts +0 -1108
package/src/setup.ts CHANGED
@@ -42,22 +42,43 @@ import {
42
42
  type UnknownActorLogic,
43
43
  } from "xstate";
44
44
  import type { z } from "zod";
45
- import { eventMap, type EventDef, type EventFrom } from "@jr2/agent-protocol";
45
+ import { eventMap, type EventDef, type EventFrom, type Sender } from "@jr2/agent-protocol";
46
46
  import type { AgentRunInput, AgentTurnInput, FaultTelemetry } from "./actor.ts";
47
47
  import { isAgent } from "./agent.ts";
48
+ import { isEffect } from "./effect.ts";
49
+ import { effectChecks } from "./effect-checks.ts";
48
50
  import { gate } from "./gate.ts";
51
+ import { attachLent, isOpenBody, openBodyDeclarationOf, type LentParts } from "./open-body.ts";
52
+ import { refuseInlineParts } from "./parts.ts";
53
+ import { partyWarnings, refuseUnknownSentBy, SENT_BY, sentByGuard } from "./party.ts";
49
54
  import { boundEpoch, boundRunId, continuedIid } from "./registration.ts";
50
- import { attachInputSchema, attachVocabulary } from "./vocabulary.ts";
55
+ import { attachInputSchema, attachLoadWarnings, attachSeats, attachVocabulary } from "./vocabulary.ts";
51
56
 
52
57
  /**
53
58
  * The events jr2's own mechanism delivers into any workflow machine, injected into every jr2Setup
54
59
  * union. Dotted names by construction (`NAME_RE` forbids dots in workflow event names), so they
55
60
  * can never collide with a def.
56
61
  */
57
- export type MechanismEvent = FaultTelemetry | { type: "workspace.lost" };
62
+ export type MechanismEvent = FaultTelemetry | WorkspaceLost;
58
63
 
59
- /** The full event union a jr2Setup machine sees: the defs' derived types plus the mechanism's. */
60
- export type WorkflowEvent<TDefs extends readonly EventDef[]> = EventFrom<TDefs[number]> | MechanismEvent;
64
+ /**
65
+ * A Workspace's Continuity broke (ADR-0021): its Sandbox is gone, or Lost. `reason` and `message`
66
+ * say why — the pod's own reason (`Evicted`, `NodeShutdown`, ...) or the operator's (`PodDeleted`,
67
+ * `NodeLost`), or `Deleted` for a Sandbox that is gone — for a body whose policy tells them apart,
68
+ * or only reports them. Optional: the body's policy never needs them to act.
69
+ */
70
+ export type WorkspaceLost = { type: "workspace.lost"; reason?: string; message?: string };
71
+
72
+ /** The full event union a jr2Setup machine sees: the defs' derived types plus the mechanism's. A
73
+ * workflow event a party delivered carries its `sender` (ADR-0015); the Machine's own events
74
+ * (`raise`, `sendTo` itself) carry none. */
75
+ export type WorkflowEvent<TDefs extends readonly EventDef[]> =
76
+ | (EventFrom<TDefs[number]> & { readonly sender?: Sender })
77
+ | MechanismEvent;
78
+
79
+ /** The guard jr2Setup registers for every Machine — `sentBy(id)` (ADR-0015) — in the typed guard
80
+ * map, so `sentBy("coder")` and `and([sentBy("coder"), "ready"])` type-check with no cast. */
81
+ type SentByGuards = { [SENT_BY]: { id: string } };
61
82
 
62
83
  /** The jr2 actor every workflow can invoke by name without listing it (ADR-0015). `gate` is the
63
84
  * whole set: an Agent is not pre-registered, because it is not one logic — it is the slot the
@@ -136,7 +157,7 @@ export function jr2Setup<
136
157
  MergedActors<TActors>,
137
158
  {},
138
159
  TActions,
139
- TGuards,
160
+ TGuards & SentByGuards,
140
161
  TDelay,
141
162
  TTag,
142
163
  TInput,
@@ -148,7 +169,8 @@ export function jr2Setup<
148
169
  types: def.types,
149
170
  actors: { ...jr2Actors, ...(def.actors ?? {}) },
150
171
  actions: def.actions,
151
- guards: def.guards,
172
+ // `sentBy(id)` is a named guard with params, so the load walk can READ it (ADR-0015).
173
+ guards: { ...(def.guards ?? {}), [SENT_BY]: sentByGuard },
152
174
  delays: def.delays,
153
175
  } as never);
154
176
 
@@ -163,13 +185,17 @@ export function jr2Setup<
163
185
  // the same loud failure `eventMap` gave the manifest, moved to machine-build time)…
164
186
  const defs = eventMap((machineConfig as { id?: string }).id ?? "(machine)", def.events);
165
187
 
188
+ // Every Open body slot (ADR-0070), found by its brand: each name it lends resolves against THIS
189
+ // setup's own parts, or the Machine is refused naming it. The resolved set is stamped on the slot,
190
+ // for the door that admits a Chart before a run and for the walk at `jr2 up`.
191
+ stampOpenBodies((machineConfig as { id?: string }).id ?? "(machine)", def);
192
+
166
193
  // …then rewrite the config (ADR-0015): every Agent-slot/`gate` invoke's input is wrapped to
167
194
  // append its DERIVED menu and finalize the mechanism fields. Static — the walk sees the same
168
195
  // config the Console will — and the derived names still ride serializable input, so the
169
196
  // ADR-0007 restore path and invoke-time `resolveAccepts` validation are unchanged.
170
- const machine = (inner.createMachine as unknown as (c: never) => AnyStateMachine)(
171
- deriveMenus(machineConfig, defs, def.actors ?? {}) as never,
172
- );
197
+ const derived = deriveMenus(machineConfig, defs, def.actors ?? {});
198
+ const machine = (inner.createMachine as unknown as (c: never) => AnyStateMachine)(derived.config as never);
173
199
 
174
200
  // Close the nested-`on` typo hole (ADR-0015): with the manifest dead, a typo'd key would
175
201
  // silently become vocabulary — so every non-dotted key the machine handles anywhere must map
@@ -177,6 +203,7 @@ export function jr2Setup<
177
203
  // targetless/actionless transitions — exactly where a typo'd key would hide). Dotted names
178
204
  // (`agent.*`, `workspace.*`, `xstate.*`, delayed transitions) are mechanically jr2's/xstate's;
179
205
  // `*` is the wildcard descriptor.
206
+ const handled = new Set<string>();
180
207
  const walk = (node: AnyStateMachine["root"]): void => {
181
208
  for (const descriptor of node.transitions.keys()) {
182
209
  if (descriptor === "*" || descriptor.includes(".")) continue;
@@ -186,12 +213,45 @@ export function jr2Setup<
186
213
  `declares (declared: ${[...defs.keys()].join(", ") || "none"}) — a typo, or a missing defineEvent`,
187
214
  );
188
215
  }
216
+ handled.add(descriptor);
189
217
  }
190
218
  for (const child of Object.values(node.states)) walk(child);
191
219
  };
192
220
  walk(machine.root);
193
221
 
222
+ // The other direction (ADR-0015): a def no state handles is in no derived set, so it is on no
223
+ // Menu and no Gate — a pick nothing can send and nothing would take.
224
+ const dead = [...defs.keys()].filter((name) => !handled.has(name));
225
+ if (dead.length) {
226
+ throw new Error(
227
+ `machine "${machine.id}" declares ${dead.map((name) => `"${name}"`).join(", ")}, which no state handles — ` +
228
+ `a pick no state takes is on no Menu and no Gate: handle it, or drop it from jr2Setup({ events })`,
229
+ );
230
+ }
231
+
232
+ // A Part written inline has no name (ADR-0049, ADR-0067): refused, with the slot form.
233
+ refuseInlineParts(machine);
234
+ refuseParallelBelowAgents(machine, def.actors ?? {});
235
+ refuseUnknownSentBy(machine);
236
+ // Before the Effect checks: they ask who may send a writing (`mayTake`), which reads its audience.
194
237
  attachVocabulary(machine, defs);
238
+ // The Effect checks (ADR-0067): a pick param nothing reads, a `fill` that overwrites one, a
239
+ // `fromPick` of another pick — refused; a mapper the probe cannot run — warned.
240
+ const effects = effectChecks(machine, def.actors ?? {}, defs);
241
+ if (effects.errors.length) throw new Error(`machine "${machine.id}": ${effects.errors.join("; ")}`);
242
+
243
+ const idOf = (path: readonly string[]): string => stateAt(machine, path).id;
244
+ attachSeats(
245
+ machine,
246
+ derived.seats.map(({ path, ...seat }) => ({ state: idOf(path), ...seat })),
247
+ );
248
+ attachLoadWarnings(machine, [
249
+ ...unenteredModes(machine, def.actors ?? {}),
250
+ ...restWarnings(machine, def.actors ?? {}),
251
+ ...effects.warnings,
252
+ ...derived.warnings.map((warning) => warning(idOf)),
253
+ ...partyWarnings(machine, derived.derivedGateIds),
254
+ ]);
195
255
  if (inputSchema) attachInputSchema(machine, inputSchema);
196
256
  return machine;
197
257
  };
@@ -204,7 +264,7 @@ export function jr2Setup<
204
264
  MergedActors<TActors>,
205
265
  {},
206
266
  TActions,
207
- TGuards,
267
+ TGuards & SentByGuards,
208
268
  TDelay,
209
269
  TTag,
210
270
  TInput,
@@ -214,12 +274,17 @@ export function jr2Setup<
214
274
  >;
215
275
  }
216
276
 
217
- // --- Menu derivation (ADR-0015) ------------------------------------------------------------------
218
- // A state that invokes an AGENT SLOT gets, as its Agent's tool menu, the workflow events its
219
- // transitions handle — own + bubbled ancestors, per statechart semantics — filtered to audience
220
- // ∈ {agent, any}; a `gate` gets the same set filtered to {external, any}. The invoking actor
221
- // kind is the primary router; `audience` on the def exists to RESTRICT (tag the security-
222
- // sensitive events). Explicit `tools:`/`accepts:` on the invoke input remain as escape hatches.
277
+ // --- Menu derivation (ADR-0015, ADR-0029) --------------------------------------------------------
278
+ // A state that invokes an AGENT SLOT derives the workflow events the Turn may pick: its own
279
+ // transitions, its ancestors' (bubbled, per statechart semantics), and those written on the states
280
+ // below it — its MODES (`modePicks`) — filtered to the names that Agent may send (the audience
281
+ // rule, on `deriveMenus`). That is its derived set, before its guards narrow it to the Allowed
282
+ // picks; the Allowed picks are read off the live snapshot, so they follow the active mode. The
283
+ // Agent's MENU — its tools block — is the union of those sets over every state that invokes it,
284
+ // fixed for the conversation. A `gate` derives its own and its ancestors' transitions, and those
285
+ // written on its plain children (the same descent, stopping at a child that invokes a party),
286
+ // filtered to the names its caller may send. An explicit `accepts:` on a gate's input remains an
287
+ // escape hatch.
223
288
  //
224
289
  // An agent invoke is identified by its LOGIC, not by a reserved src name: the walk looks the
225
290
  // invoke's `src` up in the setup's own `actors` map and asks `isAgent` (ADR-0049). That is also
@@ -240,18 +305,81 @@ type LooseState = {
240
305
  };
241
306
  type InputArgs = { context: unknown; event: unknown; self: AnyActorRef };
242
307
 
243
- /** Rewrite a machine config, wrapping every Agent-slot/`gate` invoke input (immutably). */
244
- function deriveMenus(config: unknown, defs: Map<string, EventDef>, actors: Record<string, UnknownActorLogic>): unknown {
245
- const pick = (names: Set<string>, kind: "agent" | "external"): string[] =>
246
- [...names].filter((name) => {
247
- const d = defs.get(name);
248
- return !!d && (d.audience === kind || d.audience === "any");
249
- });
250
-
251
- const walk = (node: LooseState, inherited: Set<string>, path: readonly string[]): LooseState => {
252
- const names = new Set(inherited);
253
- for (const key of Object.keys(node.on ?? {})) {
254
- if (!key.includes(".") && key !== "*") names.add(key);
308
+ /**
309
+ * One party's seat on one state: who may send what there (ADR-0015). `path` is the state's key
310
+ * path below the Machine's root; `names` is the derived set — for an Agent, what its Turn may pick
311
+ * before the guards narrow it; for a Gate, its accepted set unless its input names `accepts:`.
312
+ */
313
+ export type Seat = { path: readonly string[]; party: "agent" | "gate"; agent?: string; names: string[] };
314
+
315
+ /** Each name in reach, with the OWNER mark of every writing of it in reach (see below). */
316
+ type Reach = Map<string, Set<string>>;
317
+ /** Each name written on the path from the root: where, and the owner mark of each writing. */
318
+ type Writings = Map<string, Array<{ path: readonly string[]; owner: string }>>;
319
+ /** A load warning, rendered once the built Machine can turn a key path into a state id. */
320
+ type Warning = (idOf: (path: readonly string[]) => string) => string;
321
+
322
+ /** The owner mark of a writing no party answers for — at or above it, no state invokes an Agent
323
+ * slot or a `gate` — so both kinds may send it. */
324
+ const NOBODY = "";
325
+ /** The owner mark of the state at `path`: the party it invokes answers for the writings below it.
326
+ * `#` keeps the root's mark apart from {@link NOBODY}. */
327
+ const ownerMark = (path: readonly string[]): string => `#${path.join(".")}`;
328
+
329
+ /** Rewrite a machine config, wrapping every Agent-slot/`gate` invoke input (immutably).
330
+ *
331
+ * The same walk derives each Agent name's MENU (ADR-0029): the union of the derived sets of every
332
+ * state that invokes an Agent of that name in this Machine — the scope of its continued
333
+ * conversation's id (ADR-0057) — sorted, so it is byte-stable for every Turn of the conversation
334
+ * and for every fresh one. The wrapped inputs read it at invoke time, when the walk is long done.
335
+ *
336
+ * WHO may send a name (the audience rule, ADR-0015). A writing — an `on` key on a state —
337
+ * belongs to the party that its state, or the nearest state above it, invokes: an Agent slot, a
338
+ * `gate`, or both when one state invokes both. With no such state above, it belongs to both. An
339
+ * event whose def leaves `audience` unset follows that rule; an explicit `audience` overrides it
340
+ * BY KIND over the whole reach (`agent`: every Agent whose reach holds a writing of it; `external`:
341
+ * every Gate's; `any`: both). The REACH never changes — own + ancestors, plus the states below
342
+ * with no party-invoking state between (an Agent's modes, a Gate's plain children) — and the rule
343
+ * only filters inside it, so it cannot widen who may send a name. */
344
+ function deriveMenus(
345
+ config: unknown,
346
+ defs: Map<string, EventDef>,
347
+ actors: Record<string, UnknownActorLogic>,
348
+ ): { config: unknown; seats: Seat[]; warnings: Warning[]; derivedGateIds: Set<string> } {
349
+ const pick = (reach: Reach, kind: "agent" | "external", self: string): string[] =>
350
+ [...reach]
351
+ .filter(([name, owners]) => {
352
+ const d = defs.get(name);
353
+ if (!d) return false;
354
+ if (d.audience !== undefined) return d.audience === kind || d.audience === "any";
355
+ return owners.has(self) || owners.has(NOBODY);
356
+ })
357
+ .map(([name]) => name);
358
+ const menus = new Map<string, Set<string>>();
359
+ const menuOf = (agentName: string): string[] => [...(menus.get(agentName) ?? [])].sort();
360
+ const seats: Seat[] = [];
361
+ // The load warnings this walk can see (ADR-0015), each rendered once the states have ids. Keyed,
362
+ // so a writing seen from two paths is one warning.
363
+ const warnings = new Map<string, Warning>();
364
+ // Which parties each owner mark's state invokes — what the Gate-above warning reads.
365
+ const parties = new Map<string, { agents: string[]; gate: boolean }>();
366
+ // The Gate invoke ids this walk names: a party warning says how to name one instead.
367
+ const derivedGateIds = new Set<string>();
368
+
369
+ const walk = (
370
+ node: LooseState,
371
+ inherited: Reach,
372
+ writtenAbove: Writings,
373
+ path: readonly string[],
374
+ ownerAbove: string,
375
+ ): LooseState => {
376
+ const owner = invokesParty(node, actors) ? ownerMark(path) : ownerAbove;
377
+ if (owner !== ownerAbove) parties.set(owner, partiesOf(node, actors));
378
+ const names: Reach = new Map([...inherited].map(([name, owners]) => [name, new Set(owners)]));
379
+ const written: Writings = new Map([...writtenAbove].map(([name, at]) => [name, [...at]]));
380
+ for (const name of ownPicks(node)) {
381
+ names.set(name, (names.get(name) ?? new Set<string>()).add(owner));
382
+ written.set(name, [...(written.get(name) ?? []), { path, owner }]);
255
383
  }
256
384
 
257
385
  let out = node;
@@ -264,17 +392,49 @@ function deriveMenus(config: unknown, defs: Map<string, EventDef>, actors: Recor
264
392
  const wrapOne = (inv: LooseInvoke): LooseInvoke => {
265
393
  // The slot key IS the Agent name (ADR-0049) — read off the declaration, never authored.
266
394
  if (typeof inv?.src === "string" && isAgent(actors[inv.src])) {
267
- const derived = pick(names, "agent");
268
- if (derived.length === 0) refuseBuriedPicks(node, inv.src, path, pick);
269
- return { ...inv, input: wrapAgentInput(inv.input, derived, inv.src) };
395
+ // A mode's writings are this state's by construction: the descent stops at any child
396
+ // that invokes a party, so none answers between them and this one.
397
+ const reach: Reach = new Map([...names].map(([name, owners]) => [name, new Set(owners)]));
398
+ for (const name of modePicks(node, actors))
399
+ reach.set(name, (reach.get(name) ?? new Set<string>()).add(owner));
400
+ const derived = pick(reach, "agent", owner);
401
+ // A Gate above that takes a writing from this Agent: the Gate's caller owns it, so the
402
+ // Agent below — which reaches it — cannot send it. Legal, and the one narrowing of the
403
+ // audience rule an author may not expect.
404
+ const agent = inv.src;
405
+ for (const [name, owners] of reach) {
406
+ if (derived.includes(name) || defs.get(name)?.audience !== undefined) continue;
407
+ for (const at of written.get(name) ?? []) {
408
+ if (!owners.has(at.owner) || at.owner === owner || !parties.get(at.owner)?.gate) continue;
409
+ warnings.set(
410
+ `gate:${agent}:${path.join(".")}:${name}:${at.path.join(".")}`,
411
+ (idOf) =>
412
+ `Agent "${agent}" on "${idOf(path)}" cannot send "${name}": it is written on "${idOf(at.path)}", ` +
413
+ `whose Gate's caller owns it — give the def \`audience: "any"\` for both to send it`,
414
+ );
415
+ }
416
+ }
417
+ const menu = menus.get(inv.src) ?? new Set<string>();
418
+ for (const name of derived) menu.add(name);
419
+ menus.set(inv.src, menu);
420
+ seats.push({ path, party: "agent", agent: inv.src, names: [...derived].sort() });
421
+ return { ...inv, input: wrapAgentInput(inv.input, derived, inv.src, menuOf) };
270
422
  }
271
423
  if (inv?.src === "gate") {
272
- const wrapped: LooseInvoke = { ...inv, input: wrapGateInput(inv.input, pick(names, "external")) };
424
+ // A Gate's plain children — below it, with no party-invoking state between — are its own
425
+ // by position, as an Agent's modes are its (ADR-0015).
426
+ const reach: Reach = new Map([...names].map(([name, owners]) => [name, new Set(owners)]));
427
+ for (const name of modePicks(node, actors))
428
+ reach.set(name, (reach.get(name) ?? new Set<string>()).add(owner));
429
+ const accepts = pick(reach, "external", owner);
430
+ seats.push({ path, party: "gate", names: [...accepts].sort() });
431
+ const wrapped: LooseInvoke = { ...inv, input: wrapGateInput(inv.input, accepts) };
273
432
  // A machine-root gate (empty path) is left to xstate's default id: a `""` id would be
274
433
  // worse than a noisy one, and the derived gate id still works.
275
434
  if (inv.id == null && path.length) {
276
435
  const key = path.join(".");
277
436
  wrapped.id = unnamedGates > 1 ? `${key}.${ordinal++}` : key;
437
+ derivedGateIds.add(wrapped.id as string);
278
438
  }
279
439
  return wrapped;
280
440
  }
@@ -284,53 +444,409 @@ function deriveMenus(config: unknown, defs: Map<string, EventDef>, actors: Recor
284
444
  }
285
445
  if (node.states) {
286
446
  const states: Record<string, LooseState> = {};
287
- for (const [key, child] of Object.entries(node.states)) states[key] = walk(child, names, [...path, key]);
447
+ for (const [key, child] of Object.entries(node.states))
448
+ states[key] = walk(child, names, written, [...path, key], owner);
288
449
  out = { ...(out === node ? node : out), states };
289
450
  }
290
451
  return out;
291
452
  };
292
453
 
293
- return walk(config as LooseState, new Set(), []);
454
+ const out = walk(config as LooseState, new Map(), new Map(), [], NOBODY);
455
+ return { config: out, seats, warnings: [...warnings.values()], derivedGateIds };
294
456
  }
295
457
 
296
458
  /**
297
- * Refuse the one shape that LOOKS like a Menu and is not one (ADR-0015, ADR-0057): an Agent
298
- * invoked on a state whose picks are written in its SUBSTATES.
459
+ * A mode that nothing enters (ADR-0015): a state below an Agent's state with no invoke that the
460
+ * Machine can never enter. Its picks are on the Menu and never allowed. A warning, not a refusal: the
461
+ * Machine is legal, and a mode left unwired is often work in progress. A state below an Agent's state
462
+ * that INVOKES something and that nothing enters is refused (ADR-0067): what it invokes never runs,
463
+ * which is never work in progress. Both look below a state that invokes an Effect or a child Machine;
464
+ * neither looks below one that invokes another Agent or a Gate.
299
465
  *
300
- * A Menu derives from the invoking state's own + ANCESTOR transitions, per statechart semantics,
301
- * so a pick written below that state is one the Turn is never offered — the model is handed a
302
- * Menu it cannot end its turn with, and the actor reads an empty Menu as a Turn that ended as
303
- * intended, so nothing nudges and nothing faults: the run parks. Until ADR-0057 the `tools:`
304
- * override papered over it; with the override gone the kit says it here, at build, where `jr2 up`
305
- * walks every registered Machine.
466
+ * "Can enter" is reachability from the root, to a fixed point. A state is entered when its parent is
467
+ * entered and it is the parent's initial state or a region of a parallel parent, or when a transition
468
+ * whose SOURCE can be entered targets it or a state inside it (entering a state enters its
469
+ * ancestors). So a compound mode's own `initial` does not enter it, and neither does a transition
470
+ * written on the mode into itself: neither runs unless the mode is already active.
306
471
  *
307
- * An EMPTY Menu is not itself wrong — a state moved by a Gate or a timer asks its Agent for text
308
- * and nothing else — so only the contradiction is refused: no Menu here, and picks below.
472
+ * A transition into a history state enters what that history state restores. The walk follows its
473
+ * DEFAULT target — shallow or deep, what xstate enters when nothing is recorded yet — or, with none
474
+ * written, its parent's initial state, which entering the parent already covers. A recorded history
475
+ * restores only states that were entered another way. A default target the walk cannot resolve is a
476
+ * warning of its own: the modes behind it may read as unentered when they are not, and a state that
477
+ * invokes below that history state's parent is warned about, never refused — the walk refuses only
478
+ * what it knows nothing enters. A history state is no mode.
309
479
  */
310
- function refuseBuriedPicks(
311
- node: LooseState,
312
- agentName: string,
313
- path: readonly string[],
314
- pick: (names: Set<string>, kind: "agent" | "external") => string[],
480
+ function unenteredModes(machine: AnyStateMachine, actors: Record<string, UnknownActorLogic>): string[] {
481
+ type Node = AnyStateMachine["root"];
482
+ const all: Node[] = [];
483
+ const collect = (node: Node): void => {
484
+ all.push(node);
485
+ for (const child of Object.values(node.states)) collect(child);
486
+ };
487
+ collect(machine.root);
488
+
489
+ const out: string[] = [];
490
+ const entered = new Set<Node>([machine.root]);
491
+ const enter = (node: Node): boolean => {
492
+ let added = false;
493
+ for (let n: Node | undefined = node; n !== undefined && !entered.has(n); n = n.parent) {
494
+ entered.add(n);
495
+ added = true;
496
+ }
497
+ if (node.type === "history") for (const target of historyDefault(node)) added = enter(target) || added;
498
+ return added;
499
+ };
500
+ /** A history state's default target, resolved the way xstate resolves it: a path from its parent,
501
+ * or a `#id`. Unresolvable: warned once, and nothing is followed. */
502
+ const unresolved = new Set<Node>();
503
+ const historyDefault = (node: Node): Node[] => {
504
+ const written = (node.config as { target?: unknown }).target;
505
+ if (written === undefined) return [];
506
+ const targets = (Array.isArray(written) ? written : [written]) as unknown[];
507
+ const resolved: Node[] = [];
508
+ for (const target of targets) {
509
+ const found = typeof target === "string" ? resolvePath(node.parent!, target) : undefined;
510
+ if (found !== undefined) {
511
+ resolved.push(found);
512
+ continue;
513
+ }
514
+ if (unresolved.has(node)) continue;
515
+ unresolved.add(node);
516
+ out.push(
517
+ `history state "${node.id}" has a default target the load walk cannot follow (${JSON.stringify(target)}) — ` +
518
+ `a mode entered only through it is reported as unentered, and a state that invokes is warned, not refused`,
519
+ );
520
+ }
521
+ return resolved;
522
+ };
523
+ const resolvePath = (parent: Node, target: string): Node | undefined => {
524
+ if (target.startsWith("#")) {
525
+ try {
526
+ return machine.getStateNodeById(target.slice(1)) as Node;
527
+ } catch {
528
+ return undefined;
529
+ }
530
+ }
531
+ let at: Node | undefined = parent;
532
+ for (const key of target.split(".")) at = at?.states[key];
533
+ return at;
534
+ };
535
+ for (let changed = true; changed; ) {
536
+ changed = false;
537
+ for (const node of all) {
538
+ const parent = node.parent;
539
+ if (parent === undefined || entered.has(node) || !entered.has(parent)) continue;
540
+ if (parent.type === "parallel" || (parent.type === "compound" && parent.initial.target.includes(node)))
541
+ changed = enter(node) || changed;
542
+ }
543
+ for (const node of all) {
544
+ if (!entered.has(node)) continue;
545
+ const transitions = [...node.transitions.values()].flat().concat(node.always ?? []);
546
+ for (const t of transitions) for (const target of t.target ?? []) changed = enter(target) || changed;
547
+ }
548
+ }
549
+ const invokedAgent = (node: Node): string | undefined =>
550
+ node.invoke.find((inv) => typeof inv.src === "string" && isAgent(actors[inv.src]))?.src as string | undefined;
551
+ const isBelow = (node: Node, ancestor: Node): boolean => {
552
+ for (let n = node.parent; n !== undefined; n = n.parent) if (n === ancestor) return true;
553
+ return false;
554
+ };
555
+
556
+ const refused: string[] = [];
557
+ for (const node of all) {
558
+ const agent = invokedAgent(node);
559
+ // An Agent's state the Machine never enters is a larger defect than its modes, and not this check.
560
+ if (agent === undefined || !entered.has(node)) continue;
561
+ // Every state below the Agent's state belongs to its Turn, down to a child that invokes another
562
+ // party (an Agent or a Gate): that child is checked, and below it is that party's. A state below
563
+ // an unentered one is not reported again.
564
+ const descend = (parent: Node): void => {
565
+ for (const child of Object.values(parent.states)) {
566
+ if (child.type === "history") continue;
567
+ if (child.invoke.length > 0) {
568
+ // A child that invokes something and that nothing enters (ADR-0067): what it invokes never
569
+ // runs — a refusal, where a mode nothing enters is only a warning.
570
+ if (!entered.has(child)) {
571
+ const what = child.invoke.map((inv) => (typeof inv.src === "string" ? inv.src : inv.id)).join(", ");
572
+ // Where a history default the walk cannot follow may enter it, the walk does not know: a
573
+ // warning, never a refusal of a Machine that may be legal.
574
+ const unsure = [...unresolved].find((h) => isBelow(child, h.parent!));
575
+ if (unsure !== undefined) {
576
+ out.push(
577
+ `state "${child.id}" below Agent "${agent}"'s state "${node.id}" may be entered by nothing, so what ` +
578
+ `it invokes (${what}) may never run — the walk cannot follow history state "${unsure.id}"'s ` +
579
+ `default target, which may enter it`,
580
+ );
581
+ } else {
582
+ refused.push(
583
+ `state "${child.id}" below Agent "${agent}"'s state "${node.id}" is entered by nothing, so what it ` +
584
+ `invokes (${what}) never runs — target it from a pick or a route, or delete it`,
585
+ );
586
+ }
587
+ continue;
588
+ }
589
+ const party = child.invoke.some(
590
+ (inv) => inv.src === "gate" || (typeof inv.src === "string" && isAgent(actors[inv.src])),
591
+ );
592
+ if (!party) descend(child);
593
+ continue;
594
+ }
595
+ if (!entered.has(child)) {
596
+ const picks = [...child.transitions.keys()].filter((key) => !key.includes(".") && key !== "*");
597
+ out.push(
598
+ `mode "${child.id}" of Agent "${agent}"'s state "${node.id}" — nothing enters it: it is not ` +
599
+ `initial and no state the Machine can enter has a transition into it, so its picks` +
600
+ `${picks.length ? ` (${picks.join(", ")})` : ""} are on the Menu and never allowed`,
601
+ );
602
+ continue;
603
+ }
604
+ descend(child);
605
+ }
606
+ };
607
+ descend(node);
608
+ }
609
+ if (refused.length) throw new Error(`machine "${machine.id}": ${refused.join("; ")}`);
610
+ return out;
611
+ }
612
+
613
+ /**
614
+ * Does this logic settle on its own — a promise, the one plain xstate actor that ends without being
615
+ * stopped? Asked of its own transition: a promise logic's initial snapshot, sent xstate's resolve
616
+ * event, is `done`. That event name is an xstate internal (5.32.2); a bump that renamed it would
617
+ * make every promise read long-lived, and setup.test.ts pins it against the real constructors.
618
+ */
619
+ function settlesOnItsOwn(logic: unknown): boolean {
620
+ const l = logic as {
621
+ getInitialSnapshot?: (scope: unknown, input: unknown) => unknown;
622
+ transition?: (snapshot: unknown, event: unknown, scope: unknown) => { status?: string } | undefined;
623
+ };
624
+ if (typeof l?.getInitialSnapshot !== "function" || typeof l.transition !== "function") return false;
625
+ try {
626
+ const scope = { self: {}, defer: () => {}, emit: () => {} };
627
+ const snapshot = l.getInitialSnapshot(scope, undefined);
628
+ return l.transition(snapshot, { type: "xstate.promise.resolve", data: undefined }, scope)?.status === "done";
629
+ } catch {
630
+ return false;
631
+ }
632
+ }
633
+
634
+ /**
635
+ * The load walk's Rest warnings (ADR-0024): shapes below an Agent's state that keep it from Rest, so
636
+ * every pick of the Agent is refused for longer than the author likely meant. Warnings, never
637
+ * refusals: each can be intended.
638
+ *
639
+ * - **A Gate whose answer has no target.** The Gate stays open after its answer, and the Agent's
640
+ * state is not at Rest again until the Gate's state exits.
641
+ * - **A long-lived invoke** — an actor that runs until its state exits: not a promise or an Effect (each settles),
642
+ * a Gate, an Agent or a child Machine. While it runs, the Agent's state is never at Rest.
643
+ *
644
+ * The descent below an Agent's state stops at a child that invokes another Agent: below it is that
645
+ * Agent's state, read for that Agent.
646
+ */
647
+ function restWarnings(machine: AnyStateMachine, actors: Record<string, UnknownActorLogic>): string[] {
648
+ type Node = AnyStateMachine["root"];
649
+ const implementations = machine.implementations.actors as Record<string, unknown>;
650
+ const logicOf = (src: unknown): unknown => (typeof src === "string" ? (actors[src] ?? implementations[src]) : src);
651
+ const agentOf = (node: Node): string | undefined =>
652
+ node.invoke.find((inv) => typeof inv.src === "string" && isAgent(actors[inv.src]))?.src as string | undefined;
653
+ const out: string[] = [];
654
+ const visit = (node: Node): void => {
655
+ const agent = agentOf(node);
656
+ if (agent !== undefined) {
657
+ const descend = (parent: Node): void => {
658
+ for (const child of Object.values(parent.states)) {
659
+ if (agentOf(child) !== undefined) continue;
660
+ for (const inv of child.invoke) {
661
+ const logic = logicOf(inv.src);
662
+ const name = typeof inv.src === "string" ? inv.src : inv.id;
663
+ if (inv.src === "gate") {
664
+ for (const [event, candidates] of child.transitions) {
665
+ if (event.includes(".") || event === "*") continue;
666
+ if (candidates.some((t) => (t.target ?? []).length > 0)) continue;
667
+ out.push(
668
+ `Gate "${inv.id}" at "${child.id}", below Agent "${agent}"'s state "${node.id}": its answer "${event}" ` +
669
+ `has no target, so the Gate stays open after it — the Agent's state is not at rest again until ` +
670
+ `"${child.id}" exits, and every pick of the Agent is refused until then; give the answer a target`,
671
+ );
672
+ }
673
+ continue;
674
+ }
675
+ // An Open body (ADR-0070) runs a Chart, as a child Machine runs its states.
676
+ if (
677
+ isAgent(logic) ||
678
+ isEffect(logic) ||
679
+ isOpenBody(logic) ||
680
+ (logic as { root?: unknown } | undefined)?.root !== undefined
681
+ )
682
+ continue;
683
+ if (settlesOnItsOwn(logic)) continue;
684
+ out.push(
685
+ `"${child.id}", below Agent "${agent}"'s state "${node.id}", invokes "${name}", which runs until its ` +
686
+ `state exits (not a promise, an Effect, a Gate, an Agent or a child Machine): while it runs the Agent's state ` +
687
+ `is never at rest, so it blocks every pick of the Agent — invoke it above the Agent's state`,
688
+ );
689
+ }
690
+ descend(child);
691
+ }
692
+ };
693
+ descend(node);
694
+ }
695
+ for (const child of Object.values(node.states)) visit(child);
696
+ };
697
+ visit(machine.root);
698
+ return out;
699
+ }
700
+
701
+ /**
702
+ * Resolve and stamp every Open body slot's lent set (ADR-0070). Each name an Open body lends must be
703
+ * a part of this setup, of the kind it is lent as: an `agents` entry an Agent slot (an Open one too:
704
+ * its composer binds it as today), an `effects` entry an Effect, a `machines` entry a Machine, and a
705
+ * guard, action or delay one this setup implements. An unknown name refuses the createMachine naming
706
+ * it, so an Open body whose names did not resolve never reaches the walk.
707
+ */
708
+ function stampOpenBodies(
709
+ machineId: string,
710
+ def: {
711
+ actors?: Record<string, unknown>;
712
+ guards?: Record<string, unknown>;
713
+ actions?: Record<string, unknown>;
714
+ delays?: Record<string, unknown>;
715
+ },
315
716
  ): void {
316
- const below = new Set<string>();
317
- const descend = (n: LooseState): void => {
318
- for (const key of Object.keys(n.on ?? {})) if (!key.includes(".") && key !== "*") below.add(key);
319
- for (const child of Object.values(n.states ?? {})) descend(child);
717
+ const actors = def.actors ?? {};
718
+ for (const [slot, logic] of Object.entries(actors)) {
719
+ if (!isOpenBody(logic)) continue;
720
+ const decl = openBodyDeclarationOf(logic);
721
+ const faults: string[] = [];
722
+ const lent: LentParts = { actors: {}, guards: {}, actions: {}, delays: {} };
723
+ const actorKinds = [
724
+ ["agents", "an Agent slot (agent(...))", isAgent],
725
+ ["effects", "an Effect slot", isEffect],
726
+ ["machines", "a Machine", (l: unknown) => (l as { root?: unknown } | undefined)?.root !== undefined],
727
+ ] as const;
728
+ for (const [kind, what, test] of actorKinds) {
729
+ for (const name of decl.lends[kind] ?? []) {
730
+ const found = actors[name];
731
+ if (name === slot) faults.push(`lends.${kind}: "${name}" is the Open body itself`);
732
+ else if (found === undefined) {
733
+ faults.push(
734
+ `lends.${kind}: "${name}" is no actor slot of this Machine; its actors: ${Object.keys(actors).join(", ")}`,
735
+ );
736
+ } else if (!test(found)) faults.push(`lends.${kind}: "${name}" is not ${what}`);
737
+ else lent.actors[name] = found as UnknownActorLogic;
738
+ }
739
+ }
740
+ const implKinds = [
741
+ ["guards", def.guards ?? {}, lent.guards],
742
+ ["actions", def.actions ?? {}, lent.actions],
743
+ ["delays", def.delays ?? {}, lent.delays],
744
+ ] as const;
745
+ for (const [kind, impl, into] of implKinds) {
746
+ for (const name of decl.lends[kind] ?? []) {
747
+ if (impl[name] === undefined) {
748
+ faults.push(
749
+ `lends.${kind}: "${name}" is no ${kind.slice(0, -1)} of this Machine; its ${kind}: ${Object.keys(impl).join(", ") || "none"}`,
750
+ );
751
+ } else (into as Record<string, unknown>)[name] = impl[name];
752
+ }
753
+ }
754
+ if (faults.length) throw new Error(`machine "${machineId}": Open body "${slot}": ${faults.join("; ")}`);
755
+ attachLent(logic, lent);
756
+ }
757
+ }
758
+
759
+ /**
760
+ * A parallel region below a state that invokes an Agent is refused (ADR-0015). The shape is
761
+ * reserved for picks the Agent does not wait for, which jr2 does not build; a Turn takes one pick
762
+ * at a time. The Agent's state itself being parallel counts: its regions are below it. A
763
+ * Background work goes above the Agent's state.
764
+ */
765
+ function refuseParallelBelowAgents(machine: AnyStateMachine, actors: Record<string, UnknownActorLogic>): void {
766
+ type Node = AnyStateMachine["root"];
767
+ const firstParallel = (node: Node): Node | undefined => {
768
+ if (node.type === "parallel") return node;
769
+ for (const child of Object.values(node.states)) {
770
+ const found = firstParallel(child);
771
+ if (found !== undefined) return found;
772
+ }
773
+ return undefined;
774
+ };
775
+ const visit = (node: Node): void => {
776
+ const agent = node.invoke.find((inv) => typeof inv.src === "string" && isAgent(actors[inv.src]))?.src;
777
+ const parallel = agent === undefined ? undefined : firstParallel(node);
778
+ if (parallel !== undefined) {
779
+ throw new Error(
780
+ `machine "${machine.id}": state "${parallel.id}" is parallel, at or below Agent "${String(agent)}"'s state ` +
781
+ `"${node.id}" — a parallel region below an Agent's state is reserved for picks the Agent does not wait ` +
782
+ `for, which jr2 does not build; background work goes above the Agent's state`,
783
+ );
784
+ }
785
+ for (const child of Object.values(node.states)) visit(child);
320
786
  };
321
- for (const child of Object.values(node.states ?? {})) descend(child);
787
+ visit(machine.root);
788
+ }
322
789
 
323
- const buried = pick(below, "agent");
324
- if (buried.length === 0) return;
325
- const state = path.length ? path.join(".") : "(the machine root)";
326
- throw new Error(
327
- `agent "${agentName}" is invoked on state "${state}", which derives an EMPTY Menu while the ` +
328
- `states BELOW it handle ${buried.join(", ")} — a Menu is the invoking state's own plus its ` +
329
- `ancestors' transitions (ADR-0015), so a pick written below is one the Turn can never be ` +
330
- `offered, and a Turn with no Menu ends with no pick at all. Move it onto "${state}" (guard ` +
331
- `it if it must not always be offered — ADR-0029), or, if this Turn is deliberately ` +
332
- `menu-less, the pick belongs to another invoke.`,
333
- );
790
+ /** The built state node at a key path below the root — what turns a walk's path into a state id. */
791
+ function stateAt(machine: AnyStateMachine, path: readonly string[]): AnyStateMachine["root"] {
792
+ let node = machine.root;
793
+ for (const key of path) node = node.states[key]!;
794
+ return node;
795
+ }
796
+
797
+ /** A state's own workflow-event keys: dotted names are jr2's or xstate's, `*` is the wildcard. */
798
+ function ownPicks(node: LooseState): string[] {
799
+ return Object.keys(node.on ?? {}).filter((key) => !key.includes(".") && key !== "*");
800
+ }
801
+
802
+ /** The parties a state invokes: its Agent slots by name, and whether it invokes a `gate`. */
803
+ function partiesOf(node: LooseState, actors: Record<string, UnknownActorLogic>): { agents: string[]; gate: boolean } {
804
+ const invokes = node.invoke ? (Array.isArray(node.invoke) ? node.invoke : [node.invoke]) : [];
805
+ return {
806
+ agents: invokes.flatMap((inv) => (typeof inv?.src === "string" && isAgent(actors[inv.src]) ? [inv.src] : [])),
807
+ gate: invokes.some((inv) => inv?.src === "gate"),
808
+ };
809
+ }
810
+
811
+ /** Does this state invoke a party that sends this Machine's workflow events — an Agent slot or a
812
+ * `gate`? An Effect, a child Machine, a `workspace()`/`pool()` body: none of them sends them. */
813
+ function invokesParty(node: LooseState, actors: Record<string, UnknownActorLogic>): boolean {
814
+ const { agents, gate } = partiesOf(node, actors);
815
+ return gate || agents.length > 0;
816
+ }
817
+
818
+ /**
819
+ * The picks written on the states BELOW an Agent's state — its modes (ADR-0015). xstate hands an
820
+ * event to the deepest active state that handles it, and a false guard there falls through to the
821
+ * parent, so a child's `on` keys are picks the Turn can be allowed while that child is active — and
822
+ * `can()` on the live snapshot already answers for the active child (ADR-0029).
823
+ *
824
+ * The descent STOPS at a child that invokes another party — an Agent slot (a nested Turn: its picks
825
+ * are its own) or a `gate` (its `on` keys are the Gate's accepted set) — and leaves out everything
826
+ * below it. A child that runs an Effect, a child Machine or a timer is no stop: its `on` keys are
827
+ * this Turn's.
828
+ */
829
+ function modePicks(node: LooseState, actors: Record<string, UnknownActorLogic>): Set<string> {
830
+ return new Set(modeWritings(node, [], actors).map((w) => w.name));
831
+ }
832
+
833
+ /** Each writing {@link modePicks} reads, with the key path of the mode it is written on. */
834
+ function modeWritings(
835
+ node: LooseState,
836
+ path: readonly string[],
837
+ actors: Record<string, UnknownActorLogic>,
838
+ ): Array<{ name: string; path: readonly string[] }> {
839
+ const out: Array<{ name: string; path: readonly string[] }> = [];
840
+ const descend = (n: LooseState, at: readonly string[]): void => {
841
+ for (const [key, child] of Object.entries(n.states ?? {})) {
842
+ if (invokesParty(child, actors)) continue;
843
+ const childPath = [...at, key];
844
+ for (const name of ownPicks(child)) out.push({ name, path: childPath });
845
+ descend(child, childPath);
846
+ }
847
+ };
848
+ descend(node, path);
849
+ return out;
334
850
  }
335
851
 
336
852
  const resolveInput = (orig: unknown, args: InputArgs): Record<string, unknown> =>
@@ -367,9 +883,9 @@ function refuseUnknownKeys(consumer: object, agentName: string): void {
367
883
  );
368
884
  }
369
885
 
370
- /** Wrap an Agent slot's invoke input: name the Agent (the slot key), append the derived menu, and
371
- * finalize the mechanism fields. */
372
- function wrapAgentInput(orig: unknown, derived: string[], agentName: string) {
886
+ /** Wrap an Agent slot's invoke input: name the Agent (the slot key), append the state's derived set
887
+ * and the Agent's Menu, and finalize the mechanism fields. */
888
+ function wrapAgentInput(orig: unknown, derived: string[], agentName: string, menuOf: (agentName: string) => string[]) {
373
889
  return (args: InputArgs): AgentRunInput => {
374
890
  const consumer = resolveInput(orig, args) as Partial<AgentTurnInput & AgentRunInput>;
375
891
  refuseUnknownKeys(consumer, agentName);
@@ -391,8 +907,11 @@ function wrapAgentInput(orig: unknown, derived: string[], agentName: string) {
391
907
  // ADR-0035's reroll gate: closed to `continue: true`, which names an EXISTING conversation
392
908
  // while the runaway's one recovery is a fresh one — exactly what it opted out of.
393
909
  ...(consumer.continue === true ? { continuation: true } : {}),
394
- // The Menu (ADR-0015): always the derived one, since ADR-0057 retired the override.
910
+ // The state's derived set (ADR-0015): always the derived one, since ADR-0057 retired the
911
+ // override. What delivery validates against, and what the Allowed picks narrow.
395
912
  tools: derived,
913
+ // The Menu (ADR-0029): every pick any state invoking this Agent derives — the tools block.
914
+ menu: menuOf(agentName),
396
915
  };
397
916
  };
398
917
  }