@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
@@ -0,0 +1,557 @@
1
+ // The load walk's Effect checks (ADR-0067), run inside `jr2Setup.createMachine` beside ADR-0015's.
2
+ //
3
+ // - A PICK PARAM THAT NOTHING READS. Every param of every pick that enters a state invoking an
4
+ // Effect is read by SOMETHING — the check is per state, not per invoke: by the input of any invoke
5
+ // of that state, an Effect's or not (a second invoke beside the state's work is a legal pattern)
6
+ // — `fromPick` for each param that is a key of the Effect's input (a command's argv slots) or that
7
+ // its `fill` reads, every param on an invoke that is no Effect, every param on a command whose
8
+ // argv is Open (its slots are the composer's; `customize()` runs this check again on the bound
9
+ // argv, ADR-0054), a plain mapper probed with a
10
+ // recording event — or read by the entering transition's guard, or stored by an `assign` on the entering
11
+ // transition or on the entry of any state it enters (its targets, their ancestors below the
12
+ // transition's domain, their initial descendants — xstate's entry set — and what an eventless
13
+ // `always` on an entered state enters next, with that transition's guard and actions). Else the
14
+ // Agent supplies it and nothing uses it: refused. A pick is a writing an Agent may send (ADR-0015's
15
+ // position rule); a Gate's event is a human's and is not checked. The walk does not carry a pick's
16
+ // params across a timer: where an `after` on a state the pick entered moves on to an Effect's
17
+ // state, only a guard or assign BEFORE the timer reads them (the Effect's input reads the timer's
18
+ // event), so a param nothing stores before it is refused. A pick that targets a history state enters what
19
+ // was last active, which the walk cannot know: where that may be an Effect's state, a warning. A
20
+ // `fill` that sets a key that is also a param drops the Agent's value: refused. A `fromPick` of
21
+ // another pick than the one that enters the state reads params that are not there: refused.
22
+ // Code the probe cannot or does not run — a mapper or guard that throws, any action other than
23
+ // an assign — may read a param, so where a param is left unread the walk WARNS, never refuses
24
+ // and never stays silent: its params are unchecked.
25
+ // - THE NARROW NO-onDone WARNING. Where an Agent's state answers on a mode's entry (`answer`), an
26
+ // Effect below it with no `onDone` settles in place: that mode is not entered again, and the
27
+ // Agent reads the Effect's output, not the answer. Everywhere else a settled child with no
28
+ // `onDone` is Rest, and the one-line form is correct — silent on purpose.
29
+ //
30
+ // The probe reads, it never runs work: a mapper is called with a Proxy event whose reads are
31
+ // recorded, and a context and self that survive any read. A guard and an assign are pure by
32
+ // contract, and only they are probed; any other action may do work, and is never run.
33
+
34
+ import type { AnyStateMachine, UnknownActorLogic } from "xstate";
35
+ import type { EventDef } from "@jr2/agent-protocol";
36
+ import { isAgent } from "./agent.ts";
37
+ import { inputKeysOf, isEffect } from "./effect.ts";
38
+ import { mayTake, partiesAt } from "./party.ts";
39
+ import { isAnswer, isPickMapper } from "./pick.ts";
40
+
41
+ type Node = AnyStateMachine["root"];
42
+
43
+ /** What the walk found: refusals, and warnings. */
44
+ export type EffectFindings = { errors: string[]; warnings: string[] };
45
+
46
+ export function effectChecks(
47
+ machine: AnyStateMachine,
48
+ actors: Record<string, UnknownActorLogic>,
49
+ defs: Map<string, EventDef>,
50
+ ): EffectFindings {
51
+ const errors: string[] = [];
52
+ const warnings: string[] = [];
53
+ const all: Node[] = [];
54
+ const collect = (n: Node): void => {
55
+ all.push(n);
56
+ for (const child of Object.values(n.states)) collect(child);
57
+ };
58
+ collect(machine.root);
59
+ const logicOf = (src: unknown): unknown => (typeof src === "string" ? actors[src] : src);
60
+
61
+ // Who enters each state, by event: the entering transitions' guards and actions, and the entry
62
+ // actions of every state those transitions enter. A transition enters what xstate enters (its
63
+ // entry set), so a pick that targets a compound state enters the child that invokes, and a pick
64
+ // that targets a child enters the ancestor that invokes.
65
+ // An eventless transition (`always`) on a state the event entered runs in the same macrostep, with
66
+ // the same event: what it enters, the event enters too, and its guard and actions read the event.
67
+ // History is the one target the walk cannot follow: it enters what was last active.
68
+ // A PICK is a writing an Agent may send (ADR-0015's position rule, `mayTake`): only a pick's
69
+ // params are checked — a Gate's event is a human's, and nothing says the Agent supplies it. A pick
70
+ // that enters a state whose `after` moves on to an Effect's state reaches that Effect too, but
71
+ // across the timer: the Effect's input, and every guard and action from the timer on, read the
72
+ // timer's event. Only what read the pick before the timer counts (CROSSED).
73
+ type Reads = { guards: unknown[]; actions: unknown[]; entries: unknown[] };
74
+ type Entering = Reads & { pick: boolean };
75
+ type Crossed = { reads: Reads; via: Node };
76
+ const entering = new Map<Node, Map<string, Entering>>();
77
+ const crossing = new Map<Node, Map<string, Crossed[]>>();
78
+ const agentSeats = all.flatMap((node) =>
79
+ partiesAt(node, machine)
80
+ .filter((p) => p.party === "agent")
81
+ .map((sender) => ({ sender, node })),
82
+ );
83
+ const isPick = (source: Node, event: string): boolean =>
84
+ defs.has(event) && agentSeats.some(({ sender, node }) => mayTake(machine, sender, node, source, event));
85
+ const readsOf = (path: readonly Transition[], entered: readonly Node[]): Reads => ({
86
+ guards: path.flatMap((t) => (t.guard === undefined ? [] : [t.guard])),
87
+ actions: path.flatMap((t) => t.actions),
88
+ entries: [...new Set(entered.flatMap((s) => s.entry.filter((a) => !isTimerArm(a))))],
89
+ });
90
+ const record = (event: string, entered: Set<Node>, reads: Reads, pick: boolean): void => {
91
+ for (const target of entered) {
92
+ const byEvent = entering.get(target) ?? new Map<string, Entering>();
93
+ const seen = byEvent.get(event) ?? { guards: [], actions: [], entries: [], pick: false };
94
+ byEvent.set(event, {
95
+ guards: [...seen.guards, ...reads.guards],
96
+ actions: [...seen.actions, ...reads.actions],
97
+ entries: [...new Set([...seen.entries, ...reads.entries])],
98
+ pick: seen.pick || pick,
99
+ });
100
+ entering.set(target, byEvent);
101
+ }
102
+ };
103
+ const recordCrossed = (event: string, entered: Set<Node>, crossed: Crossed): void => {
104
+ for (const target of entered) {
105
+ const byEvent = crossing.get(target) ?? new Map<string, Crossed[]>();
106
+ byEvent.set(event, [...(byEvent.get(event) ?? []), crossed]);
107
+ crossing.set(target, byEvent);
108
+ }
109
+ };
110
+ type Walk = { pick: boolean; crossed?: Crossed };
111
+ const follow = (
112
+ event: string,
113
+ t: Transition,
114
+ path: readonly Transition[],
115
+ before: readonly Node[],
116
+ seen: Set<Transition>,
117
+ walk: Walk,
118
+ ): void => {
119
+ const entered = entrySetOf(t, machine.root);
120
+ const here = [...path, t];
121
+ const sofar = [...before, ...entered];
122
+ if (walk.crossed) recordCrossed(event, entered, walk.crossed);
123
+ else record(event, entered, readsOf(here, sofar), walk.pick);
124
+ for (const s of entered) {
125
+ for (const next of s.always ?? []) {
126
+ if (seen.has(next)) continue;
127
+ follow(event, next, here, sofar, new Set([...seen, next]), walk);
128
+ }
129
+ // A pick's params do not cross a timer: the walk follows the `after` of a state the pick
130
+ // entered, and marks what it reaches as crossed.
131
+ if (!walk.pick) continue;
132
+ for (const [type, candidates] of s.transitions) {
133
+ if (!type.startsWith("xstate.after.")) continue;
134
+ for (const next of candidates) {
135
+ if (seen.has(next)) continue;
136
+ const crossed = walk.crossed ?? { reads: readsOf(here, sofar), via: s };
137
+ follow(event, next, here, sofar, new Set([...seen, next]), { pick: true, crossed });
138
+ }
139
+ }
140
+ }
141
+ if (walk.crossed) return;
142
+ // A history target: what it enters is not known at load, so an Effect it may enter is
143
+ // unchecked — a warning, never silence.
144
+ for (const h of (t.target ?? []).filter((s) => s.type === "history")) {
145
+ const def = defs.get(event);
146
+ if (!walk.pick || !def || Object.keys(def.input.shape).length === 0 || !h.parent) continue;
147
+ const effects = below(h.parent).filter((s) => s.invoke.some((inv) => isEffect(logicOf(inv.src))));
148
+ if (!effects.length) continue;
149
+ warnings.push(
150
+ `pick "${event}" targets history state "${h.id}", which enters what was last active — the walk cannot ` +
151
+ `tell which of ${effects.map((s) => `"${s.id}"`).join(", ")} it enters, so whether each param of ` +
152
+ `"${event}" is read there is unchecked`,
153
+ );
154
+ }
155
+ };
156
+ const below = (p: Node): Node[] => Object.values(p.states).flatMap((c) => [c, ...below(c)]);
157
+ for (const n of all) {
158
+ for (const [event, candidates] of n.transitions) {
159
+ for (const t of candidates) follow(event, t, [], [], new Set([t]), { pick: isPick(n, event) });
160
+ }
161
+ }
162
+ const namedGuards = (machine.implementations?.guards ?? {}) as Record<string, unknown>;
163
+
164
+ /**
165
+ * Probe what reads a pick's params before its Effect starts: the entering guards, run; the
166
+ * assigns, run; any other action, named and not run. Returns what was read, whether every probe
167
+ * ran, and the actions it did not run.
168
+ */
169
+ const probeReads = (
170
+ where: string,
171
+ event: string,
172
+ params: readonly string[],
173
+ reads: Reads,
174
+ read: Set<string>,
175
+ ): { probed: boolean; unrun: string[] } => {
176
+ let probed = true;
177
+ for (const guard of reads.guards) {
178
+ try {
179
+ probeGuard(guard, recordingEvent(event, params, read), namedGuards);
180
+ } catch (err) {
181
+ probed = false;
182
+ warnings.push(`${where}: could not probe a guard (${messageOf(err)}); its params are unchecked`);
183
+ }
184
+ }
185
+ const unrun: string[] = [];
186
+ for (const action of [...reads.actions, ...reads.entries]) {
187
+ if (!isAssign(action)) {
188
+ unrun.push(actionName(action));
189
+ continue;
190
+ }
191
+ try {
192
+ probeAssign(action, recordingEvent(event, params, read));
193
+ } catch (err) {
194
+ probed = false;
195
+ warnings.push(`${where}: could not probe an assign (${messageOf(err)}); its params are unchecked`);
196
+ }
197
+ }
198
+ return { probed, unrun };
199
+ };
200
+ const unrunWarning = (where: string, lost: readonly string[], unrun: readonly string[]): string =>
201
+ `${where}: nothing the walk can run reads ${lost.map((p) => `"${p}"`).join(", ")}; an action it does ` +
202
+ `not run (${unrun.join(", ")}) may — unchecked. If none reads it, the Agent supplies it and nothing ` +
203
+ `uses it`;
204
+
205
+ // Per STATE, not per invoke: a param counts as read when ANY invoke of the state reads it (an
206
+ // Effect's or not — a second invoke beside the state's work is a legal pattern), or the entering
207
+ // transition's guard reads it, or an assign stores it. Only a param that NOTHING reads is refused.
208
+ for (const n of all) {
209
+ const configs = [n.config.invoke ?? []].flat() as Array<{ input?: unknown } | undefined>;
210
+ const invokes = n.invoke.map((inv, i) => {
211
+ const logic = logicOf(inv.src);
212
+ const effect = isEffect(logic) ? logic.effect : undefined;
213
+ return {
214
+ effect,
215
+ // Undefined: no Effect, or an Open argv whose slots the composer binds (ADR-0054) — it gets
216
+ // every param here, and `customize()` runs this check again once the argv is bound.
217
+ keys: effect ? inputKeysOf(effect) : undefined,
218
+ slot: typeof inv.src === "string" ? inv.src : inv.id,
219
+ mapper: configs[i]?.input,
220
+ };
221
+ });
222
+ const effects = invokes.filter((inv) => inv.effect !== undefined);
223
+ if (!effects.length) continue;
224
+ const at = `state "${n.id}" (Effect${effects.length > 1 ? "s" : ""} ${effects.map((e) => `"${e.slot}"`).join(", ")})`;
225
+ for (const [event, { pick, ...reads }] of entering.get(n) ?? []) {
226
+ let misread = false;
227
+ for (const { effect, slot, mapper } of invokes) {
228
+ if (!isPickMapper(mapper) || mapper.pick.name === event) continue;
229
+ misread = true;
230
+ errors.push(
231
+ `state "${n.id}" (${effect ? "Effect" : "invoke"} "${slot}") reads the params of pick ` +
232
+ `"${mapper.pick.name}" but is entered on "${event}" — that event does not carry them`,
233
+ );
234
+ }
235
+ if (misread) continue;
236
+ const def = defs.get(event);
237
+ if (!def || !pick) continue;
238
+ const params = Object.keys(def.input.shape);
239
+ if (params.length === 0) continue;
240
+ const where = `pick "${event}" enters ${at}`;
241
+ const read = new Set<string>();
242
+ let probed = true;
243
+ for (const { effect, keys, slot, mapper } of invokes) {
244
+ const own = `pick "${event}" enters state "${n.id}" (${effect ? "Effect" : "invoke"} "${slot}")`;
245
+ if (isPickMapper(mapper)) {
246
+ // Params flow by NAME: one that is no key of the Effect's input reaches nothing, and
247
+ // TypeScript does not reject an extra key on a spread. Only `fill` can still read it. An
248
+ // invoke that is no Effect declares no keys: it gets every param.
249
+ const overwritten = new Set<string>();
250
+ if (mapper.fill) {
251
+ try {
252
+ const filled = mapper.fill({ context: anything(), params: recorder(params, read) });
253
+ for (const p of params) {
254
+ if (filled && typeof filled === "object" && p in filled) {
255
+ overwritten.add(p);
256
+ errors.push(
257
+ `${own}: \`fill\` sets "${p}", which is also a param of the pick — the Machine's value wins ` +
258
+ `and the Agent's is dropped; rename one, or drop it from the def`,
259
+ );
260
+ }
261
+ }
262
+ } catch (err) {
263
+ probed = false;
264
+ warnings.push(
265
+ `${own}: could not probe fromPick's fill (${messageOf(err)}); a param it reads or overwrites is unchecked`,
266
+ );
267
+ }
268
+ }
269
+ for (const p of params) if ((keys === undefined || keys.includes(p)) && !overwritten.has(p)) read.add(p);
270
+ } else if (typeof mapper === "function") {
271
+ try {
272
+ (mapper as (args: unknown) => unknown)({
273
+ event: recordingEvent(event, params, read),
274
+ context: anything(),
275
+ self: anything(),
276
+ });
277
+ } catch (err) {
278
+ probed = false;
279
+ warnings.push(`${own}: could not probe the input mapper (${messageOf(err)}); its params are unchecked`);
280
+ }
281
+ }
282
+ }
283
+ // A param the entering transition's guard READS, or an assign on it or on the entry of a
284
+ // state it enters STORES, reaches the Machine too. A guard and an assign are pure by
285
+ // contract: both are run.
286
+ const probe = probeReads(where, event, params, reads, read);
287
+ probed &&= probe.probed;
288
+ const lost = params.filter((p) => !read.has(p));
289
+ const picked = effects.filter((e) => isPickMapper(e.mapper));
290
+ if (probed && lost.length && probe.unrun.length) {
291
+ // Any other action may do work, so the probe never runs one; it may read the param.
292
+ warnings.push(unrunWarning(where, lost, probe.unrun));
293
+ } else if (probed && lost.length && picked.length) {
294
+ const inputs =
295
+ picked.length === 1
296
+ ? `the Effect's input (${picked[0]!.keys?.join(", ") || "none"})`
297
+ : `any fromPick Effect's input (${picked.map((e) => `"${e.slot}": ${e.keys?.join(", ") || "none"}`).join("; ")})`;
298
+ errors.push(
299
+ `${where} with fromPick: ${lost.map((p) => `"${p}"`).join(", ")} is no key of ${inputs}, and nothing ` +
300
+ `else reads it — the Agent supplies it and it reaches nothing; name an argv slot for it, read it in ` +
301
+ `\`fill\`, store it with assign, or drop it from the def`,
302
+ );
303
+ } else if (probed && lost.length) {
304
+ errors.push(
305
+ `${where}, which never reads ${lost.map((p) => `"${p}"`).join(", ")}: the Agent supplies it and nothing ` +
306
+ `uses it — map it in an invoke's input, store it with assign, or drop it from the def`,
307
+ );
308
+ }
309
+ }
310
+ // Across a timer: every invoke's input reads the timer's event, so only what read the pick
311
+ // before the timer counts. A `fromPick` here is refused above (it is entered on the timer's
312
+ // event, which does not carry the params).
313
+ if (invokes.some(({ mapper }) => isPickMapper(mapper))) continue;
314
+ for (const [event, hops] of crossing.get(n) ?? []) {
315
+ const def = defs.get(event);
316
+ if (!def) continue;
317
+ const params = Object.keys(def.input.shape);
318
+ if (params.length === 0) continue;
319
+ for (const { reads, via } of hops) {
320
+ const where = `pick "${event}" enters state "${via.id}", whose \`after\` moves on to ${at}`;
321
+ const read = new Set<string>();
322
+ const probe = probeReads(where, event, params, reads, read);
323
+ const lost = params.filter((p) => !read.has(p));
324
+ if (!probe.probed || !lost.length) continue;
325
+ if (probe.unrun.length) warnings.push(unrunWarning(where, lost, probe.unrun));
326
+ else
327
+ errors.push(
328
+ `${where}, and nothing stores ${lost.map((p) => `"${p}"`).join(", ")} before the timer: the ` +
329
+ `invokes' input reads the timer's event, not the pick's, so the Agent supplies it and nothing uses ` +
330
+ `it — store it with assign on the pick's transition, or drop it from the def`,
331
+ );
332
+ }
333
+ }
334
+ }
335
+
336
+ // The narrow no-onDone warning, per Agent's state.
337
+ const invokesAgent = (n: Node): string | undefined =>
338
+ n.invoke.find((inv) => typeof inv.src === "string" && isAgent(actors[inv.src]))?.src as string | undefined;
339
+ for (const a of all) {
340
+ const agent = invokesAgent(a);
341
+ if (agent === undefined) continue;
342
+ const below: Node[] = [];
343
+ const descend = (p: Node): void => {
344
+ for (const child of Object.values(p.states)) {
345
+ if (invokesAgent(child) !== undefined || child.invoke.some((inv) => inv.src === "gate")) continue;
346
+ below.push(child);
347
+ descend(child);
348
+ }
349
+ };
350
+ descend(a);
351
+ const answering = below.find((m) => m.invoke.length === 0 && m.entry.some(isAnswer));
352
+ if (!answering) continue;
353
+ for (const m of below) {
354
+ for (const inv of m.invoke) {
355
+ if (!isEffect(logicOf(inv.src)) || m.transitions.has(`xstate.done.actor.${inv.id}`)) continue;
356
+ const slot = typeof inv.src === "string" ? inv.src : inv.id;
357
+ warnings.push(
358
+ `"${m.id}" invokes Effect "${slot}" with no onDone, and "${answering.id}" answers the Agent on entry: the ` +
359
+ `settled Effect stays in "${m.key}", "${answering.key}" is not entered again, and Agent "${agent}" reads ` +
360
+ `the Effect's output, not that answer — add onDone: "${answering.key}" if the answer must run`,
361
+ );
362
+ }
363
+ }
364
+ }
365
+ // An `answer` on an entry or an exit below no Agent's state shapes no pick (ADR-0067): an entry
366
+ // or exit there is no transition an Agent's event takes. Warned, naming the Agent states it could
367
+ // be moved under — those below it, else every one the Machine has.
368
+ const named = (machine.implementations?.actions ?? {}) as Record<string, unknown>;
369
+ const resolve = (a: unknown): unknown => {
370
+ const type = typeof a === "string" ? a : typeof a === "object" && a !== null ? (a as { type?: unknown }).type : a;
371
+ return typeof type === "string" ? named[type] : a;
372
+ };
373
+ const agentStates = all.filter((n) => invokesAgent(n) !== undefined);
374
+ const seated = (n: Node): boolean => {
375
+ for (let p: Node | undefined = n; p; p = p.parent) if (invokesAgent(p) !== undefined) return true;
376
+ return false;
377
+ };
378
+ for (const n of all) {
379
+ if (seated(n) || agentStates.length === 0) continue;
380
+ for (const [where, list] of [
381
+ ["entry", n.entry],
382
+ ["exit", n.exit],
383
+ ] as const) {
384
+ if (!list.some((a) => isAnswer(resolve(a)))) continue;
385
+ const under = agentStates.filter((s) => isDescendant(s, n));
386
+ const names = (under.length > 0 ? under : agentStates).map((s) => `"${s.id}"`);
387
+ const choice = names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names.at(-1)}` : names[0];
388
+ warnings.push(
389
+ `an answer on the ${where} of "${n.id}" is below no Agent's state, so it shapes no pick — an ${where} there ` +
390
+ `is no transition an Agent's event takes; move it under ${choice}, or onto a transition (ADR-0067)`,
391
+ );
392
+ }
393
+ }
394
+ return { errors, warnings: [...new Set(warnings)] };
395
+ }
396
+
397
+ type Transition = {
398
+ source: Node;
399
+ target?: readonly Node[] | undefined;
400
+ reenter: boolean;
401
+ guard?: unknown;
402
+ actions: readonly unknown[];
403
+ };
404
+
405
+ const isDescendant = (s: Node, of: Node): boolean => {
406
+ for (let p = s.parent; p; p = p.parent) if (p === of) return true;
407
+ return false;
408
+ };
409
+ const properAncestors = (s: Node, upTo?: Node): Node[] => {
410
+ const out: Node[] = [];
411
+ for (let p = s.parent; p && p !== upTo; p = p.parent) out.push(p);
412
+ return out;
413
+ };
414
+
415
+ /**
416
+ * The states a transition enters: xstate's own entry set (`computeEntrySet`), less history. A
417
+ * target is entered unless it is the source of a transition that does not reenter; below each
418
+ * entered state, its initial child (compound) or every region (parallel); above each target, its
419
+ * ancestors below the transition's domain, with every other region of a parallel one. A history
420
+ * target enters what was last active, which the walk cannot know: it is skipped here, and the caller
421
+ * warns where it may enter an Effect.
422
+ */
423
+ function entrySetOf(t: Transition, root: Node): Set<Node> {
424
+ const targets = (t.target ?? []).filter((s) => s.type !== "history");
425
+ const enter = new Set<Node>();
426
+ const domain = ((): Node | undefined => {
427
+ if (!t.reenter && targets.every((s) => s === t.source || isDescendant(s, t.source))) return t.source;
428
+ const [head, ...tail] = [...targets, t.source];
429
+ for (const anc of properAncestors(head!)) if (tail.every((s) => isDescendant(s, anc))) return anc;
430
+ return t.reenter ? undefined : root;
431
+ })();
432
+ const regions = (p: Node): void => {
433
+ for (const region of Object.values(p.states)) {
434
+ if (region.type === "history" || [...enter].some((s) => isDescendant(s, region))) continue;
435
+ enter.add(region);
436
+ descend(region);
437
+ }
438
+ };
439
+ const descend = (s: Node): void => {
440
+ if (s.type === "compound") {
441
+ const initial = s.initial.target[0];
442
+ if (!initial || initial.type === "history") return;
443
+ enter.add(initial);
444
+ descend(initial);
445
+ } else if (s.type === "parallel") regions(s);
446
+ };
447
+ for (const s of targets) {
448
+ if (s !== t.source || t.source !== domain || t.reenter) enter.add(s);
449
+ descend(s);
450
+ }
451
+ const within = !t.source.parent && t.reenter ? undefined : domain;
452
+ for (const s of targets) {
453
+ const ancestors = properAncestors(s, domain);
454
+ if (domain?.type === "parallel") ancestors.push(domain);
455
+ for (const anc of ancestors) {
456
+ if (!within || isDescendant(anc, within)) enter.add(anc);
457
+ if (anc.type === "parallel") regions(anc);
458
+ }
459
+ }
460
+ return enter;
461
+ }
462
+
463
+ const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));
464
+
465
+ /** The probe's entering event: `type` is the pick; every other read is recorded. Spread and
466
+ * destructuring see the params as own keys, so `paramsOf` and `{ type, ...rest }` record too. */
467
+ function recordingEvent(type: string, params: readonly string[], read: Set<string>): Record<string, unknown> {
468
+ return new Proxy({ type } as Record<string, unknown>, {
469
+ get(target, key) {
470
+ if (key === "type") return target["type"];
471
+ if (typeof key === "string" && params.includes(key)) read.add(key);
472
+ return typeof key === "string" && params.includes(key) ? `\u0000param:${key}` : undefined;
473
+ },
474
+ ownKeys: () => ["type", ...params],
475
+ getOwnPropertyDescriptor: (target, key) =>
476
+ key === "type" || (typeof key === "string" && params.includes(key))
477
+ ? { configurable: true, enumerable: true, writable: true, value: key === "type" ? target["type"] : undefined }
478
+ : undefined,
479
+ });
480
+ }
481
+
482
+ /** The probe's `params` for `fill`: records reads. */
483
+ function recorder(params: readonly string[], read: Set<string>): never {
484
+ return new Proxy({} as Record<string, unknown>, {
485
+ get(_t, key) {
486
+ if (typeof key === "string" && params.includes(key)) read.add(key);
487
+ return typeof key === "string" ? `\u0000param:${key}` : undefined;
488
+ },
489
+ }) as never;
490
+ }
491
+
492
+ /** The delayed `raise` xstate adds to a state's entry for each `after`: xstate's own, it reads no
493
+ * param, and the walk neither runs nor names it. */
494
+ const isTimerArm = (action: unknown): boolean => {
495
+ const { type, id } = (action ?? {}) as { type?: unknown; id?: unknown };
496
+ return type === "xstate.raise" && typeof id === "string" && id.startsWith("xstate.after.");
497
+ };
498
+
499
+ const isAssign = (action: unknown): boolean =>
500
+ typeof action === "function" && (action as { type?: unknown }).type === "xstate.assign";
501
+
502
+ /** How a warning names an action the probe does not run. */
503
+ function actionName(action: unknown): string {
504
+ if (typeof action === "string") return `"${action}"`;
505
+ const type = (action as { type?: unknown } | null)?.type;
506
+ if (typeof type === "string") return type.startsWith("xstate.") ? type.slice("xstate.".length) : `"${type}"`;
507
+ return "an inline action";
508
+ }
509
+
510
+ /**
511
+ * Run a guard against the probe's event: a guard is pure by contract (xstate). Inline, named (by
512
+ * string or `{ type, params }`, from the Machine's own guards), and inside `and`/`or`/`not`. A
513
+ * guard that reads no event (`stateIn`) is nothing to probe. A named guard the Machine does not
514
+ * carry throws: the caller warns.
515
+ */
516
+ function probeGuard(guard: unknown, event: unknown, named: Record<string, unknown>): void {
517
+ if (guard === undefined) return;
518
+ if (typeof guard === "string") return probeGuard({ type: guard }, event, named);
519
+ if (typeof guard === "function") {
520
+ const nested = (guard as { guards?: unknown }).guards;
521
+ if (Array.isArray(nested)) {
522
+ for (const g of nested) probeGuard(g, event, named);
523
+ return;
524
+ }
525
+ if ((guard as { check?: unknown }).check !== undefined) return;
526
+ (guard as (args: unknown, params: unknown) => unknown)({ context: anything(), event }, undefined);
527
+ return;
528
+ }
529
+ const { type, params } = guard as { type?: unknown; params?: unknown };
530
+ const impl = typeof type === "string" ? named[type] : undefined;
531
+ if (typeof impl !== "function") throw new Error(`no guard named ${JSON.stringify(type)}`);
532
+ const resolved = typeof params === "function" ? params({ context: anything(), event }) : params;
533
+ (impl as (args: unknown, params: unknown) => unknown)({ context: anything(), event }, resolved);
534
+ }
535
+
536
+ /** Run an `assign` against the probe's event: an assign is pure by contract, and is run. */
537
+ function probeAssign(action: unknown, event: unknown): void {
538
+ if (!isAssign(action)) return;
539
+ const assignment = (action as { assignment?: unknown }).assignment;
540
+ const args = { event, context: anything(), self: anything(), spawn: anything() };
541
+ const call = (f: unknown): void => {
542
+ if (typeof f === "function") (f as (a: unknown) => unknown)(args);
543
+ };
544
+ if (typeof assignment === "function") call(assignment);
545
+ else if (assignment && typeof assignment === "object") Object.values(assignment).forEach(call);
546
+ }
547
+
548
+ /** A value that survives any read, call, or coercion — the probe's context and self. */
549
+ function anything(): never {
550
+ const f = function () {};
551
+ const p: unknown = new Proxy(f, {
552
+ get: (_t, key) =>
553
+ key === Symbol.toPrimitive ? () => "" : key === Symbol.iterator ? [][Symbol.iterator].bind([]) : p,
554
+ apply: () => p,
555
+ });
556
+ return p as never;
557
+ }