@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,1444 @@
1
+ // The Open body (ADR-0070): the body slot a Chart wrapper declares and a run binds with a Chart.
2
+ // `openBody(declaration)` is a slot constructor placed in `actors`, as `agent()` is, and its
3
+ // declaration states three things: what the slot LENDS a Chart by slot name, what a Chart may
4
+ // DEFINE (the models and the Workspace access of its own Agents), and the CEILINGS that bound a
5
+ // Chart's run. Nothing a Chart names resolves anywhere but here.
6
+ //
7
+ // This file has two halves. The DECLARATION half (below) is the brand, the validation of a
8
+ // declaration, and the lent set `jr2Setup` stamps at createMachine; the door (`admitChart`,
9
+ // chart.ts) reads only this half. The ACTOR half (at the end of the file) is the logic that
10
+ // DELEGATES to a Machine compiled from the Chart the run binds: it admits the Chart, compiles it
11
+ // with `jr2Setup` over the lent parts and the Chart's own Agents, enforces the ceilings, and keeps
12
+ // the Chart's text in its snapshot, so that restore compiles the same Machine again (ADR-0007).
13
+
14
+ import {
15
+ and,
16
+ assign,
17
+ not,
18
+ or,
19
+ type AnyActorRef,
20
+ type AnyActorScope,
21
+ type AnyEventObject,
22
+ type AnyMachineSnapshot,
23
+ type AnyStateMachine,
24
+ type UnknownActorLogic,
25
+ } from "xstate";
26
+ import { isAgent, type AgentDefinition, type WorkspaceAccess } from "./agent.ts";
27
+ import {
28
+ admitChart,
29
+ chartDigest,
30
+ chartHash,
31
+ ChartRefusal,
32
+ GENERIC_ACTIONS,
33
+ pathSegments,
34
+ resolveSource,
35
+ resolveReferences,
36
+ type AdmittedChart,
37
+ type ChartAction,
38
+ type ChartGuard,
39
+ type ChartInvoke,
40
+ type ChartState,
41
+ type ChartValue,
42
+ type ChartText,
43
+ type ChartTransition,
44
+ type ChartTransitions,
45
+ } from "./chart.ts";
46
+ import { inputKeysOf, isEffect, type Duration } from "./effect.ts";
47
+ import { agent } from "./harness-client.ts";
48
+ import { SENT_BY } from "./party.ts";
49
+ import { wrapperBodyOf } from "./parts.ts";
50
+ import { actorPath, boundRun } from "./registration.ts";
51
+ import { jr2Setup } from "./setup.ts";
52
+ import { attachBoundMachine, boundMachineFor } from "./vocabulary.ts";
53
+
54
+ /**
55
+ * The ceilings an Open body sets on the Chart that binds it (ADR-0070: "counters are the Open
56
+ * body's"). Each is a positive bound; reaching one settles the body with a `ceiling` output.
57
+ */
58
+ export type Ceilings = {
59
+ /** The Agent Turns started under the body, at any depth: a lent Machine's Turns count too. */
60
+ turns: number;
61
+ /** The wall clock from the body's start. */
62
+ wallClock: Duration;
63
+ /** The microsteps the body takes, over the whole run: a budget, since xstate has no loop guard. */
64
+ transitions: number;
65
+ /** The children live under the body at once. */
66
+ fanOut: number;
67
+ /** The Chart's canonical JSON, in bytes: the door's own limit. At most {@link MAX_CHART_SIZE}. */
68
+ size: number;
69
+ };
70
+
71
+ /** The largest `ceilings.size` a declaration may state: 1 MiB. */
72
+ export const MAX_CHART_SIZE = 1024 * 1024;
73
+
74
+ /**
75
+ * The most JSON a Chart's run data may hold, in bytes: its context (its `input` included) and its
76
+ * output. Reaching it settles the body with the `size` ceiling. A kit bound, not a declaration's:
77
+ * the run host saves, serves and feeds this JSON, so it is the host's limit.
78
+ */
79
+ export const MAX_CONTEXT_SIZE = 4 * 1024 * 1024;
80
+
81
+ /**
82
+ * Is the JSON of `value` more than `cap` bytes? A walk with an explicit stack that stops at the
83
+ * cap, and counts a shared reference each time it occurs, as `JSON.stringify` writes it. So a
84
+ * value that is small in memory and huge as JSON costs at most `cap` to measure.
85
+ */
86
+ export function jsonOver(value: unknown, cap: number): boolean {
87
+ let total = 0;
88
+ const stack: unknown[] = [value];
89
+ while (stack.length) {
90
+ const v = stack.pop();
91
+ if (typeof v === "string") total += v.length > cap ? v.length : Buffer.byteLength(JSON.stringify(v));
92
+ else if (Array.isArray(v)) {
93
+ total += 2 + v.length;
94
+ for (const x of v) stack.push(x);
95
+ } else if (typeof v === "object" && v !== null) {
96
+ const entries = Object.entries(v);
97
+ total += 2 + entries.length;
98
+ for (const [k, x] of entries) {
99
+ if (x === undefined || typeof x === "function") continue;
100
+ total += Buffer.byteLength(k) + 3;
101
+ stack.push(x);
102
+ }
103
+ } else total += v === undefined ? 0 : String(v).length;
104
+ if (total > cap) return true;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ /** The kinds of part an Open body lends, each as a list of the wrapper's own slot names; and the
110
+ * kit's Gate, lent whole (`gate: true`), since there is one and it has no name to list. */
111
+ export type Lends = {
112
+ agents?: string[];
113
+ effects?: string[];
114
+ machines?: string[];
115
+ guards?: string[];
116
+ actions?: string[];
117
+ delays?: string[];
118
+ gate?: boolean;
119
+ };
120
+
121
+ /** What an Open body states (ADR-0070): the parts it lends, what a Chart may define, the ceilings. */
122
+ export type OpenBodyDeclaration = {
123
+ lends: Lends;
124
+ /** What a Chart's own Agents may have: a model from `models`, a Workspace access from `workspace`. */
125
+ allows: { models: string[]; workspace: WorkspaceAccess[] };
126
+ ceilings: Ceilings;
127
+ };
128
+
129
+ /** The implementations of the lent names, resolved on the wrapper's setup at createMachine. */
130
+ export type LentParts = {
131
+ actors: Record<string, UnknownActorLogic>;
132
+ guards: Record<string, unknown>;
133
+ actions: Record<string, unknown>;
134
+ delays: Record<string, unknown>;
135
+ };
136
+
137
+ /** The brand an Open body slot carries, as `definition` marks an Agent slot (ADR-0049). */
138
+ export type OpenBodySlot = { readonly openBody: OpenBodyDeclaration };
139
+
140
+ /** The logic `openBody()` returns: actor logic that carries the brand. */
141
+ export type OpenBodyLogic = UnknownActorLogic & OpenBodySlot;
142
+
143
+ const LEND_KINDS = ["agents", "effects", "machines", "guards", "actions", "delays"] as const;
144
+ const WORKSPACE: readonly WorkspaceAccess[] = ["write", "read", "none"];
145
+ const CEILING_KEYS = ["turns", "wallClock", "transitions", "fanOut", "size"] as const;
146
+ const DURATION = /^(\d+(\.\d+)?)(s|m|h)$/;
147
+ /** The longest wall clock an Open body arms as one timer: Node clamps a longer one to 1 ms. */
148
+ const MAX_WALL_CLOCK_MS = 2_147_483_647;
149
+ /** A name a Chart's grammar reads as its own, so a lent part by that name would be shadowed. */
150
+ const reservedName = (kind: (typeof LEND_KINDS)[number], v: string): string | undefined => {
151
+ if (v.includes(".")) return `"${v}" holds a dot; a lent name is a slot key, and dotted names are jr2's and xstate's`;
152
+ if ((kind === "agents" || kind === "effects" || kind === "machines") && v === "gate")
153
+ return `"gate" is the kit's Gate; lend a slot of another name`;
154
+ if (kind === "actions" && (GENERIC_ACTIONS as readonly string[]).includes(v))
155
+ return `"${v}" is a generic action every Chart has; lend an action of another name`;
156
+ if (kind === "delays" && /^(0|[1-9][0-9]*)$/.test(v))
157
+ return `"${v}" reads as a delay of ${v} ms in a Chart; lend a delay by a name that is not an integer`;
158
+ return undefined;
159
+ };
160
+
161
+ /** The milliseconds of a {@link Duration}, or undefined when the text is not a positive one. */
162
+ export function durationMs(d: unknown): number | undefined {
163
+ if (typeof d !== "string") return undefined;
164
+ const m = DURATION.exec(d);
165
+ if (!m) return undefined;
166
+ const n = Number.parseFloat(m[1]!);
167
+ if (!(n > 0)) return undefined;
168
+ return m[3] === "h" ? n * 3_600_000 : m[3] === "m" ? n * 60_000 : n * 1000;
169
+ }
170
+
171
+ /**
172
+ * The faults of a declaration, one line each; empty when it is valid. Every list holds distinct
173
+ * non-empty strings, every ceiling is present and positive, `size` is at most 1 MiB, `wallClock`
174
+ * is a Duration (`"90s"`, `"10m"`, `"2h"`), and `allows.workspace` holds Workspace accesses only.
175
+ */
176
+ export function declarationFaults(decl: unknown): string[] {
177
+ const faults: string[] = [];
178
+ if (typeof decl !== "object" || decl === null) return ["the declaration is not an object"];
179
+ const d = decl as Partial<Record<keyof OpenBodyDeclaration, unknown>>;
180
+ const list = (where: string, value: unknown, item?: (v: string) => string | undefined): void => {
181
+ if (!Array.isArray(value)) {
182
+ faults.push(`${where}: expected a list of names, found ${JSON.stringify(value)}`);
183
+ return;
184
+ }
185
+ const seen = new Set<string>();
186
+ for (const v of value) {
187
+ if (typeof v !== "string" || v === "") faults.push(`${where}: ${JSON.stringify(v)} is not a name`);
188
+ else if (seen.has(v)) faults.push(`${where}: "${v}" is listed twice`);
189
+ else {
190
+ seen.add(v);
191
+ const fault = item?.(v);
192
+ if (fault) faults.push(`${where}: ${fault}`);
193
+ }
194
+ }
195
+ };
196
+ const lends = d.lends;
197
+ if (typeof lends !== "object" || lends === null) faults.push("lends: expected an object of slot-name lists");
198
+ else {
199
+ for (const key of Object.keys(lends)) {
200
+ if (key === "gate") {
201
+ if (typeof (lends as { gate?: unknown }).gate !== "boolean") {
202
+ faults.push("lends.gate: the kit's Gate is lent whole: true or false");
203
+ }
204
+ } else if (!(LEND_KINDS as readonly string[]).includes(key)) {
205
+ faults.push(`lends.${key}: an Open body lends ${LEND_KINDS.join(", ")} and gate only`);
206
+ }
207
+ }
208
+ for (const kind of LEND_KINDS) {
209
+ const value = (lends as Record<string, unknown>)[kind];
210
+ if (value !== undefined) list(`lends.${kind}`, value, (v) => reservedName(kind, v));
211
+ }
212
+ }
213
+ const allows = d.allows as { models?: unknown; workspace?: unknown } | undefined;
214
+ if (typeof allows !== "object" || allows === null) faults.push("allows: expected { models, workspace }");
215
+ else {
216
+ list("allows.models", allows.models);
217
+ list("allows.workspace", allows.workspace, (v) =>
218
+ (WORKSPACE as readonly string[]).includes(v)
219
+ ? undefined
220
+ : `"${v}" is not a Workspace access (${WORKSPACE.join(", ")})`,
221
+ );
222
+ }
223
+ const ceilings = d.ceilings as Partial<Record<keyof Ceilings, unknown>> | undefined;
224
+ if (typeof ceilings !== "object" || ceilings === null) faults.push(`ceilings: expected ${CEILING_KEYS.join(", ")}`);
225
+ else {
226
+ for (const key of CEILING_KEYS) {
227
+ const v = ceilings[key];
228
+ if (v === undefined) faults.push(`ceilings.${key}: missing; an Open body states every ceiling`);
229
+ else if (key === "wallClock") {
230
+ const ms = durationMs(v);
231
+ if (ms === undefined) {
232
+ faults.push(`ceilings.wallClock: ${JSON.stringify(v)} is not a positive duration (like "90s", "10m", "2h")`);
233
+ } else if (ms > MAX_WALL_CLOCK_MS) {
234
+ faults.push(
235
+ `ceilings.wallClock: ${JSON.stringify(v)} is more than one timer holds (${MAX_WALL_CLOCK_MS} ms, about 596h)`,
236
+ );
237
+ }
238
+ } else if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) {
239
+ faults.push(`ceilings.${key}: ${JSON.stringify(v)} is not a positive integer`);
240
+ } else if (key === "size" && v > MAX_CHART_SIZE) {
241
+ faults.push(`ceilings.size: ${v} is more than ${MAX_CHART_SIZE} bytes (1 MiB)`);
242
+ }
243
+ }
244
+ for (const key of Object.keys(ceilings)) {
245
+ if (!(CEILING_KEYS as readonly string[]).includes(key)) {
246
+ faults.push(`ceilings.${key}: not a ceiling (${CEILING_KEYS.join(", ")})`);
247
+ }
248
+ }
249
+ }
250
+ for (const key of Object.keys(d)) {
251
+ if (key !== "lends" && key !== "allows" && key !== "ceilings") faults.push(`${key}: not a part of a declaration`);
252
+ }
253
+ return faults;
254
+ }
255
+
256
+ /** A copy of the declaration with every list copied and frozen, so the brand cannot change later. */
257
+ function frozen(decl: OpenBodyDeclaration): OpenBodyDeclaration {
258
+ const lends: Lends = {};
259
+ for (const kind of LEND_KINDS) {
260
+ const v = decl.lends[kind];
261
+ if (v !== undefined) lends[kind] = Object.freeze([...v]) as string[];
262
+ }
263
+ if (decl.lends.gate !== undefined) lends.gate = decl.lends.gate;
264
+ return Object.freeze({
265
+ lends: Object.freeze(lends),
266
+ allows: Object.freeze({
267
+ models: Object.freeze([...decl.allows.models]) as string[],
268
+ workspace: Object.freeze([...decl.allows.workspace]) as WorkspaceAccess[],
269
+ }),
270
+ ceilings: Object.freeze({ ...decl.ceilings }),
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Declare an Open body (ADR-0070). It validates the declaration now, so a bad one fails where the
276
+ * Machine is written, and returns actor logic branded with it. `jr2Setup` resolves every lent name
277
+ * against its own `actors`, `guards`, `actions` and `delays` at createMachine, and stamps the
278
+ * result with {@link attachLent}.
279
+ */
280
+ export function openBody(decl: OpenBodyDeclaration): OpenBodyLogic {
281
+ return openBodyWith(decl);
282
+ }
283
+
284
+ /** How an Open body builds what a Chart mints. The default runs a minted Agent over the Harness
285
+ * wire, as `agent()` does; a unit test passes its own port here, as it does for `agentActorWith`. */
286
+ export type OpenBodyOptions = {
287
+ /** Build the logic of an Agent the Chart mints, from its slot key and its definition. */
288
+ mintAgent?: (slot: string, definition: AgentDefinition) => UnknownActorLogic;
289
+ };
290
+
291
+ /** {@link openBody} with the options a test seam needs. The declaration is validated the same way. */
292
+ export function openBodyWith(decl: OpenBodyDeclaration, options: OpenBodyOptions = {}): OpenBodyLogic {
293
+ const faults = declarationFaults(decl);
294
+ if (faults.length) throw new Error(`openBody: ${faults.join("; ")}`);
295
+ const declaration = frozen(decl);
296
+ const logic = openBodyLogic(declaration, options);
297
+ bodyOptions.set(logic, options);
298
+ return Object.assign(logic, { openBody: declaration }) as OpenBodyLogic;
299
+ }
300
+
301
+ /** The options each Open body slot was built with, so a copy keeps them. */
302
+ const bodyOptions = new WeakMap<object, OpenBodyOptions>();
303
+
304
+ /**
305
+ * A new Open body slot with the same declaration and options: what `customize()` stamps a retuned
306
+ * lent set on, so the Machine it was given keeps its own (ADR-0049: two customizations of one
307
+ * import are two Machines).
308
+ */
309
+ export function copyOpenBody(logic: OpenBodyLogic): OpenBodyLogic {
310
+ const options = bodyOptions.get(logic) ?? {};
311
+ const declaration = openBodyDeclarationOf(logic);
312
+ const copy = openBodyLogic(declaration, options);
313
+ bodyOptions.set(copy, options);
314
+ return Object.assign(copy, { openBody: declaration }) as OpenBodyLogic;
315
+ }
316
+
317
+ /** Is this actor logic an Open body slot? A duck test on the brand, like `isAgent`. */
318
+ export function isOpenBody(logic: unknown): logic is OpenBodyLogic {
319
+ if (typeof logic !== "object" || logic === null) return false;
320
+ const decl = (logic as { openBody?: unknown }).openBody;
321
+ if (typeof decl !== "object" || decl === null) return false;
322
+ const { lends, allows, ceilings } = decl as Partial<OpenBodyDeclaration>;
323
+ return typeof lends === "object" && typeof allows === "object" && typeof ceilings === "object";
324
+ }
325
+
326
+ /** The declaration an Open body slot carries. */
327
+ export function openBodyDeclarationOf(logic: OpenBodyLogic): OpenBodyDeclaration {
328
+ return logic.openBody;
329
+ }
330
+
331
+ // Keyed on the logic object, as the vocabulary is keyed on a Machine's config: `customize` and the
332
+ // host's per-run `provide` rebuild the actors map, and a WeakMap keeps the logic bit-identical.
333
+ const lentSets = new WeakMap<object, LentParts>();
334
+
335
+ /** Stamp the resolved lent set on an Open body slot. `jr2Setup` calls it at createMachine, and
336
+ * again after a `customize` retunes a lent slot. */
337
+ export function attachLent(logic: OpenBodyLogic, lent: LentParts): void {
338
+ lentSets.set(logic, lent);
339
+ }
340
+
341
+ /** The lent set stamped on an Open body slot; undefined before `jr2Setup` resolved it. */
342
+ export function lentOf(logic: OpenBodyLogic): LentParts | undefined {
343
+ return lentSets.get(logic);
344
+ }
345
+
346
+ // --- The actor half ------------------------------------------------------------------------------
347
+ // The logic `openBody()` returns DELEGATES to a Machine compiled from the Chart (ADR-0070). At
348
+ // start it reads `input.chart` (the text, or the name of a Chart of the run's own wrapper, D-C),
349
+ // admits it against the declaration and the lent set it has NOW, compiles it with `jr2Setup` (so
350
+ // an Agent the Chart's states invoke derives its Menu from the Chart's events, as under any
351
+ // Machine), and runs it in its own snapshot. The snapshot is the compiled Machine's, plus the
352
+ // Chart's text and hash, the start time and the counters: xstate's own `value`, `context` and
353
+ // `children` stay where `durability.ts`, `runChildren` and the timers read them. Restore admits
354
+ // the text again and compiles the same Machine (ADR-0007). A Chart the door refuses settles the
355
+ // body with a `refused` output, never an error (D-A): the wrapper routes it with `onDone`, and a
356
+ // planner reads the report as a link in its Chain. An error of the body stays a defect.
357
+
358
+ /** What an Open body is invoked with: the Chart, by text or by name, and its `input` (the rest). */
359
+ export type OpenBodyInput = { chart: ChartText | { name: string } } & Record<string, unknown>;
360
+
361
+ /** How an Open body settles: the Chart's output, the door's refusal, or the ceiling it reached. */
362
+ export type OpenBodyOutput =
363
+ | { outcome: "done"; output: unknown }
364
+ | { outcome: "refused"; report: string[] }
365
+ | { outcome: "ceiling"; ceiling: keyof Ceilings; detail: string };
366
+
367
+ /** The context key the compiled Machine keeps the Chart's `input` under. `$` is no path segment,
368
+ * so no Chart reference or generic action can name it. */
369
+ export const CHART_INPUT_KEY = "$input";
370
+
371
+ /**
372
+ * The keys of the body's input that are a Turn's placement (ADR-0057's `AgentTurnPlacement`): the
373
+ * stub tier's explicit Harness url, and the Sandbox a delivery is scoped to. A Chart carries no
374
+ * url (ADR-0070), so these never reach it: they are not in its `input` scope, not among the keys
375
+ * the door checks `input.*` against, and the body writes them on every Agent Turn its Chart
376
+ * starts. Inside a `workspace()` the wrapper passes none, and a Turn resolves its placement
377
+ * ambiently through the actor chain, as a hand-written Machine's Turn does.
378
+ */
379
+ export const PLACEMENT_KEYS = ["endpoint", "sandbox"] as const;
380
+
381
+ /** `input` without its placement keys: what the Chart reads as `input`. */
382
+ export function chartInputOf(input: Record<string, unknown>): Record<string, unknown> {
383
+ return Object.fromEntries(Object.entries(input).filter(([k]) => !(PLACEMENT_KEYS as readonly string[]).includes(k)));
384
+ }
385
+
386
+ /** The placement in `input`, as strings: what rides each Agent Turn the Chart starts. */
387
+ function placementOf(input: unknown): Record<string, string> {
388
+ const out: Record<string, string> = {};
389
+ if (typeof input !== "object" || input === null) return out;
390
+ for (const key of PLACEMENT_KEYS) {
391
+ const v = (input as Record<string, unknown>)[key];
392
+ if (typeof v === "string") out[key] = v;
393
+ }
394
+ return out;
395
+ }
396
+
397
+ /** The event the body sends itself when its wall clock runs out (xstate's own delayed event). */
398
+ export const WALL_CLOCK_EVENT = "jr2.openBody.wallClock";
399
+
400
+ type Counters = { turns: number; transitions: number };
401
+
402
+ /** What the body keeps beside the compiled Machine's snapshot. `chart` and `chartHash` are absent
403
+ * on a body that bound no Chart (a refusal at start). */
404
+ type BodyFields = { chart?: ChartText; chartHash?: string; startedAt: number; counters: Counters };
405
+
406
+ type BodySnapshot = Record<string, unknown> &
407
+ BodyFields & {
408
+ status: string;
409
+ machine?: AnyStateMachine;
410
+ children?: Record<string, AnyActorRef>;
411
+ context?: Record<string, unknown>;
412
+ };
413
+
414
+ const EMPTY_LENT: LentParts = { actors: {}, guards: {}, actions: {}, delays: {} };
415
+
416
+ /** The lent names of a declaration, by the implementation map each resolves in. */
417
+ function lentNames(decl: OpenBodyDeclaration) {
418
+ return {
419
+ actors: [...(decl.lends.agents ?? []), ...(decl.lends.effects ?? []), ...(decl.lends.machines ?? [])],
420
+ guards: [...(decl.lends.guards ?? [])],
421
+ actions: [...(decl.lends.actions ?? [])],
422
+ delays: [...(decl.lends.delays ?? [])],
423
+ };
424
+ }
425
+
426
+ type Implementations = {
427
+ actors?: Record<string, unknown>;
428
+ guards?: Record<string, unknown>;
429
+ actions?: Record<string, unknown>;
430
+ delays?: Record<string, unknown>;
431
+ };
432
+
433
+ /**
434
+ * The lent set of a declaration, read off a Machine's implementations by the lent names. A name the
435
+ * Machine does not implement is left out, so the door refuses a Chart that uses it.
436
+ */
437
+ export function lentIn(impl: Implementations, decl: OpenBodyDeclaration): LentParts {
438
+ const names = lentNames(decl);
439
+ const pick = <T>(from: Record<string, unknown> | undefined, keys: string[]) =>
440
+ Object.fromEntries(keys.filter((k) => from?.[k] !== undefined).map((k) => [k, from![k]])) as Record<string, T>;
441
+ return {
442
+ actors: pick<UnknownActorLogic>(impl.actors, names.actors),
443
+ guards: pick(impl.guards, names.guards),
444
+ actions: pick(impl.actions, names.actions),
445
+ delays: pick(impl.delays, names.delays),
446
+ };
447
+ }
448
+
449
+ /**
450
+ * The lent set an Open body's actor has NOW: the live implementations of the Machine that invoked
451
+ * it, by the lent names (ADR-0049: a part resolves through the live actor's logic, so a run's
452
+ * `provide()` and a `customize()` retune are what the Chart borrows), or the set `jr2Setup`
453
+ * stamped when nothing a Machine invoked it.
454
+ */
455
+ function liveLentOf(self: AnyActorRef, logic: object): LentParts {
456
+ const parent = (self._parent as { logic?: unknown } | undefined)?.logic as AnyStateMachine | undefined;
457
+ const stamped = lentOf(logic as OpenBodyLogic);
458
+ if (!parent?.root) return stamped ?? EMPTY_LENT;
459
+ return lentIn(parent.implementations as Implementations, openBodyDeclarationOf(logic as OpenBodyLogic));
460
+ }
461
+
462
+ /** The refusal line of a compile's throw: `jr2Setup`'s own checks over the Chart (rule 6). */
463
+ const compileLine = (err: unknown): string => `(chart): ${err instanceof Error ? err.message : String(err)}`;
464
+
465
+ /**
466
+ * The door's whole check of a Chart for one Open body (ADR-0070: "admission is static and
467
+ * mandatory"): {@link admitChart}, then the compile `jr2Setup` runs over the Chart, with no run
468
+ * (a parallel region below an Agent's state, a `sentBy` that names no party). A throw of the
469
+ * compile is a refusal line, so the HTTP door, a PUT, a publish and the body refuse alike. Throws
470
+ * ChartRefusal.
471
+ */
472
+ export function admitBodyChart(
473
+ text: unknown,
474
+ decl: OpenBodyDeclaration,
475
+ lent: LentParts,
476
+ options: { inputKeys?: readonly string[] } = {},
477
+ ): AdmittedChart {
478
+ const admitted = admitChart(text, decl, lent, options.inputKeys ? { inputKeys: options.inputKeys } : {});
479
+ try {
480
+ compileChart(admitted, lent, decl);
481
+ } catch (err) {
482
+ if (err instanceof ChartRefusal) throw err;
483
+ throw new ChartRefusal([compileLine(err)]);
484
+ }
485
+ return admitted;
486
+ }
487
+
488
+ const isMachine = (logic: unknown): logic is AnyStateMachine =>
489
+ typeof logic === "object" &&
490
+ logic !== null &&
491
+ typeof (logic as { config?: unknown }).config === "object" &&
492
+ typeof (logic as { implementations?: unknown }).implementations === "object";
493
+
494
+ /**
495
+ * The Machine that declares the Open bodies of `machine`, seen through jr2's transparent wrappers
496
+ * (ADR-0049: `workspace()` and `pool()` are transparent to their body), with the slot route down
497
+ * to it. A Chart wrapper that is a `workspace()`'s body is still the run's Chart wrapper: the door
498
+ * is the `workspace()`'s, and the bodies and the lent set are the inner Machine's. `machine`
499
+ * itself, with an empty route, when it declares an Open body or is no wrapper.
500
+ */
501
+ export function chartWrapperMachineOf(machine: AnyStateMachine): { machine: AnyStateMachine; route: string[] } {
502
+ let at = machine;
503
+ const route: string[] = [];
504
+ const seen = new Set<AnyStateMachine>([machine]);
505
+ while (!openBodiesOf(at).length) {
506
+ const slot = wrapperBodyOf(at);
507
+ const child = slot === undefined ? undefined : (at.implementations.actors as Record<string, unknown>)[slot];
508
+ if (slot === undefined || !isMachine(child) || seen.has(child)) break;
509
+ seen.add(child);
510
+ route.push(slot);
511
+ at = child;
512
+ }
513
+ return { machine: at, route };
514
+ }
515
+
516
+ /**
517
+ * Admit a Chart for the Open body `slot` of `machine` (ADR-0070), seen through transparent
518
+ * wrappers, with the lent set the Machine's implementations hold. Throws ChartRefusal.
519
+ */
520
+ export function admitOpenBodyChart(
521
+ machine: AnyStateMachine,
522
+ slot: string,
523
+ text: unknown,
524
+ inputKeys?: readonly string[],
525
+ ): AdmittedChart {
526
+ const inner = chartWrapperMachineOf(machine).machine;
527
+ const logic = (inner.implementations.actors as Record<string, unknown>)[slot];
528
+ if (!isOpenBody(logic)) throw new Error(`machine "${machine.id}" has no Open body "${slot}"`);
529
+ const decl = openBodyDeclarationOf(logic);
530
+ return admitBodyChart(text, decl, lentIn(inner.implementations as Implementations, decl), {
531
+ ...(inputKeys ? { inputKeys } : {}),
532
+ });
533
+ }
534
+
535
+ /**
536
+ * Admit a Chart field of a Chart wrapper's door (ADR-0070), the door a run's start runs before the
537
+ * run exists. The door cannot tell which Open body a field feeds, so the Chart is admitted against
538
+ * each body, and it is admitted when one body admits it; else each line names its body when there
539
+ * are two or more. `input.*` references are not checked here: the Chart's `input` is what the
540
+ * wrapper's invoke sends, which the door cannot see, and the body checks them at its start. Throws
541
+ * ChartRefusal.
542
+ */
543
+ export function admitWrapperChart(machine: AnyStateMachine, text: unknown): AdmittedChart {
544
+ const inner = chartWrapperMachineOf(machine).machine;
545
+ const bodies = openBodiesOf(inner);
546
+ if (!bodies.length) throw new Error(`machine "${machine.id}" declares no Open body`);
547
+ const report: string[] = [];
548
+ for (const { slot, logic } of bodies) {
549
+ const decl = openBodyDeclarationOf(logic);
550
+ try {
551
+ return admitBodyChart(text, decl, lentIn(inner.implementations as Implementations, decl));
552
+ } catch (err) {
553
+ if (!(err instanceof ChartRefusal)) throw err;
554
+ report.push(...err.report.map((line) => (bodies.length > 1 ? `body "${slot}": ${line}` : line)));
555
+ }
556
+ }
557
+ throw new ChartRefusal(report);
558
+ }
559
+
560
+ /** Every Open body slot a Machine declares, in `actors` order. */
561
+ export function openBodiesOf(machine: AnyStateMachine): Array<{ slot: string; logic: OpenBodyLogic }> {
562
+ const actors = (machine.implementations?.actors ?? {}) as Record<string, unknown>;
563
+ return Object.entries(actors).flatMap(([slot, logic]) => (isOpenBody(logic) ? [{ slot, logic }] : []));
564
+ }
565
+
566
+ // --- Compiling a Chart ---------------------------------------------------------------------------
567
+
568
+ type Args = { context: Record<string, unknown>; event: unknown };
569
+
570
+ /** The scope a reference resolves in: the run's context and event, and the Chart's input. An
571
+ * invoke's error is a line jr2 writes (see {@link errorAsLine}). */
572
+ const scopeOf = (args: Args) => ({
573
+ context: args.context,
574
+ event: errorAsLine(args.event),
575
+ input: chartInputOf((args.context?.[CHART_INPUT_KEY] as Record<string, unknown> | undefined) ?? {}),
576
+ });
577
+
578
+ /** An invoke's error whose message jr2 wrote for a Chart to read: a lent Effect's input fault. */
579
+ class ChartInvokeFault extends Error {}
580
+
581
+ /**
582
+ * An invoke's error event as a Chart reads it: `event.error` is a line jr2 writes, never the
583
+ * error's own message. A child's error is a defect whose message never reaches the model
584
+ * (ADR-0067, holds.ts), and a Chart's prompts and output reach one; an error message may also
585
+ * name what is internal (an Effector's address). The one exception is a fault jr2 wrote for the
586
+ * Chart: a lent Effect's input that failed its keys.
587
+ */
588
+ function errorAsLine(event: unknown): unknown {
589
+ const e = event as { type?: unknown; error?: unknown } | undefined;
590
+ if (typeof e?.type !== "string" || !e.type.startsWith("xstate.error.actor.")) return event;
591
+ const child = e.type.slice("xstate.error.actor.".length);
592
+ const line =
593
+ e.error instanceof ChartInvokeFault
594
+ ? e.error.message
595
+ : `the invoke "${child}" failed; its error is a defect the Orchestrator logs, and a Chart never reads it (ADR-0067)`;
596
+ return { ...e, error: line };
597
+ }
598
+
599
+ /**
600
+ * A reference that resolved to nothing at run time. The door admits what its dataflow check can
601
+ * see (rule 7), and a run's data can still lack a path it admitted (a read before the state that
602
+ * writes it). The body settles `refused` with the reference named, never an error: an error of
603
+ * the body is a defect, and the planner would never get a report (D-A).
604
+ */
605
+ class ChartRunFault extends Error {}
606
+
607
+ /** The fault the last resolution threw, read by the body after its delegate threw: xstate wraps a
608
+ * guard's throw in an Error of its own, so the class alone does not survive. */
609
+ let runFault: ChartRunFault | undefined;
610
+
611
+ function resolveAt(template: ChartValue, args: Args): unknown {
612
+ try {
613
+ return resolveReferences(template, scopeOf(args));
614
+ } catch (err) {
615
+ runFault = new ChartRunFault(err instanceof Error ? err.message : String(err));
616
+ throw runFault;
617
+ }
618
+ }
619
+
620
+ const resolver = (template: ChartValue) => (args: Args) => resolveAt(template, args);
621
+
622
+ /** The copy of `value` with `fn(old)` at `path`; objects and lists are copied, never changed. */
623
+ function withPath(value: unknown, path: Array<string | number>, fn: (old: unknown) => unknown): unknown {
624
+ if (path.length === 0) return fn(value);
625
+ const [head, ...rest] = path as [string | number, ...Array<string | number>];
626
+ if (typeof head === "number") {
627
+ const list = Array.isArray(value) ? [...value] : [];
628
+ list[head] = withPath(list[head], rest, fn);
629
+ return list;
630
+ }
631
+ const obj = typeof value === "object" && value !== null && !Array.isArray(value) ? { ...value } : {};
632
+ (obj as Record<string, unknown>)[head] = withPath((obj as Record<string, unknown>)[head], rest, fn);
633
+ return obj;
634
+ }
635
+
636
+ /** The copy of `value` without `path`. */
637
+ function withoutPath(value: unknown, path: Array<string | number>): unknown {
638
+ if (typeof value !== "object" || value === null || path.length === 0) return value;
639
+ const [head, ...rest] = path as [string | number, ...Array<string | number>];
640
+ if (Array.isArray(value)) {
641
+ if (typeof head !== "number" || head >= value.length) return value;
642
+ const list = [...value];
643
+ if (rest.length) list[head] = withoutPath(list[head], rest);
644
+ else list.splice(head, 1);
645
+ return list;
646
+ }
647
+ if (!Object.hasOwn(value, head)) return value;
648
+ const obj = { ...(value as Record<string, unknown>) };
649
+ if (rest.length) obj[head] = withoutPath(obj[head], rest);
650
+ else delete obj[head];
651
+ return obj;
652
+ }
653
+
654
+ /** `absent`: a `set` or `append` from one reference that resolved to nothing writes nothing. */
655
+ type PathParams = { path: string; value?: unknown; by?: number; absent?: boolean };
656
+
657
+ /** The four generic actions (ADR-0070), implemented once, as `assign`s driven by their params. */
658
+ const GENERIC = {
659
+ set: assign(({ context }: { context: Record<string, unknown> }, p: PathParams) =>
660
+ p.absent ? context : (withPath(context, pathSegments(p.path), () => p.value) as Record<string, unknown>),
661
+ ),
662
+ append: assign(({ context }: { context: Record<string, unknown> }, p: PathParams) =>
663
+ p.absent
664
+ ? context
665
+ : (withPath(context, pathSegments(p.path), (old) => [
666
+ ...(Array.isArray(old) ? old : old === undefined ? [] : [old]),
667
+ p.value,
668
+ ]) as Record<string, unknown>),
669
+ ),
670
+ increment: assign(
671
+ ({ context }: { context: Record<string, unknown> }, p: PathParams) =>
672
+ withPath(context, pathSegments(p.path), (old) => (typeof old === "number" ? old : 0) + (p.by ?? 1)) as Record<
673
+ string,
674
+ unknown
675
+ >,
676
+ ),
677
+ clear: assign(({ context }: { context: Record<string, unknown> }, p: PathParams) => {
678
+ const segments = pathSegments(p.path);
679
+ // A top-level key is assigned `undefined`: `assign` merges, so a key it leaves out stays.
680
+ if (segments.length === 1) return { [segments[0]!]: undefined };
681
+ return withoutPath(context, segments) as Record<string, unknown>;
682
+ }),
683
+ } as Record<string, unknown>;
684
+
685
+ function guardOf(g: ChartGuard): unknown {
686
+ if (typeof g === "string") return g;
687
+ if ("not" in g) return not(guardOf(g.not) as never);
688
+ if ("and" in g) return and(g.and.map(guardOf) as never);
689
+ if ("or" in g) return or(g.or.map(guardOf) as never);
690
+ if (g.params === undefined) return { type: g.type };
691
+ // `jr2.sentBy`'s params are a literal `{ id }`, which the party walk reads (ADR-0015).
692
+ if (g.type === SENT_BY) return { type: g.type, params: g.params };
693
+ return { type: g.type, params: resolver(g.params) };
694
+ }
695
+
696
+ function actionOf(a: ChartAction): unknown {
697
+ const action = a as { type: string; path?: string; from?: ChartValue; by?: number; params?: ChartValue };
698
+ switch (action.type) {
699
+ case "set":
700
+ case "append": {
701
+ const { path, from } = action;
702
+ return {
703
+ type: action.type,
704
+ params: (args: Args) => {
705
+ let source: { value: unknown } | undefined;
706
+ try {
707
+ source = resolveSource(from, scopeOf(args));
708
+ } catch (err) {
709
+ runFault = new ChartRunFault(err instanceof Error ? err.message : String(err));
710
+ throw runFault;
711
+ }
712
+ return source ? { path, value: source.value } : { path, absent: true };
713
+ },
714
+ };
715
+ }
716
+ case "increment":
717
+ return { type: "increment", params: { path: action.path, by: action.by ?? 1 } };
718
+ case "clear":
719
+ return { type: "clear", params: { path: action.path } };
720
+ default:
721
+ return action.params === undefined
722
+ ? { type: action.type }
723
+ : { type: action.type, params: resolver(action.params) };
724
+ }
725
+ }
726
+
727
+ function transitionOf(t: ChartTransition): unknown {
728
+ if (typeof t === "string") return t;
729
+ const out: Record<string, unknown> = {};
730
+ if (t.target !== undefined) out.target = t.target;
731
+ if (t.guard !== undefined) out.guard = guardOf(t.guard);
732
+ if (t.actions !== undefined) out.actions = t.actions.map(actionOf);
733
+ if (t.reenter !== undefined) out.reenter = t.reenter;
734
+ if (t.description !== undefined) out.description = t.description;
735
+ return out;
736
+ }
737
+
738
+ function transitionsOf(ts: ChartTransitions): unknown {
739
+ return Array.isArray(ts) ? ts.map(transitionOf) : transitionOf(ts);
740
+ }
741
+
742
+ const mapValues = <T, U>(o: Record<string, T>, fn: (v: T) => U): Record<string, U> =>
743
+ Object.fromEntries(Object.entries(o).map(([k, v]) => [k, fn(v)]));
744
+
745
+ /**
746
+ * Why a resolved Effect input is not exactly the Effect's input keys, each a string (ADR-0070: "the
747
+ * resolved object again at invoke time"), or undefined when it is. The door checked the literal
748
+ * parts; a reference may still resolve to a value that is no string.
749
+ */
750
+ function effectInputFault(keys: readonly string[], value: unknown): string | undefined {
751
+ const given = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : undefined;
752
+ const ok =
753
+ given !== undefined &&
754
+ given.length === keys.length &&
755
+ keys.every((k) => typeof (value as Record<string, unknown>)[k] === "string");
756
+ return ok
757
+ ? undefined
758
+ : `its input resolved to ${JSON.stringify(value)}; it takes exactly ${keys.join(", ") || "nothing"}, each a string (ADR-0070)`;
759
+ }
760
+
761
+ const checkedEffects = new WeakMap<object, UnknownActorLogic>();
762
+
763
+ /**
764
+ * A lent Effect as a Chart invokes it: the Effect itself (its brand and every method, by the
765
+ * prototype), whose start refuses an input that fails its keys. The refusal is THAT invoke's
766
+ * error, so the Chart's `onError` takes it: a throw in the input mapper would end the whole Chart.
767
+ */
768
+ function checkedEffect(slot: string, logic: UnknownActorLogic): UnknownActorLogic {
769
+ if (!isEffect(logic)) return logic;
770
+ const keys = inputKeysOf(logic.effect);
771
+ if (keys === undefined) return logic;
772
+ const hit = checkedEffects.get(logic);
773
+ if (hit) return hit;
774
+ const checked = Object.create(logic) as UnknownActorLogic;
775
+ checked.getInitialSnapshot = (scope, input) => {
776
+ const fault = effectInputFault(keys, input);
777
+ if (fault === undefined) return logic.getInitialSnapshot(scope, input);
778
+ return {
779
+ status: "error",
780
+ output: undefined,
781
+ error: new ChartInvokeFault(`the lent Effect "${slot}": ${fault}`),
782
+ } as never;
783
+ };
784
+ checkedEffects.set(logic, checked);
785
+ return checked;
786
+ }
787
+
788
+ /** The lent actors as a Chart invokes them: each Effect checked at its start (see {@link checkedEffect}). */
789
+ function chartActors(actors: Record<string, UnknownActorLogic>): Record<string, UnknownActorLogic> {
790
+ return Object.fromEntries(Object.entries(actors).map(([slot, logic]) => [slot, checkedEffect(slot, logic)]));
791
+ }
792
+
793
+ /** An Agent Turn's input with `prompt` and `cwd` as strings: a whole placeholder may resolve to
794
+ * an object or a number, which a Turn's Frame never takes, so it is written as text (JSON for an
795
+ * object or a list), as an interpolated placeholder is. The body's own placement rides it (see
796
+ * {@link PLACEMENT_KEYS}): the Chart named none. */
797
+ function turnInput(value: unknown, placement: Record<string, string>): unknown {
798
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
799
+ const out = { ...(value as Record<string, unknown>), ...placement };
800
+ for (const key of ["prompt", "cwd"]) {
801
+ const v = out[key];
802
+ if (v === undefined || typeof v === "string") continue;
803
+ out[key] = typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
804
+ }
805
+ return out;
806
+ }
807
+
808
+ /**
809
+ * A Chart Gate's input with its `gate` id below the body's own actor path: a Chart cannot take
810
+ * the caller-facing name of a Gate of its wrapper (gate.ts: two live Gates with one id fail at
811
+ * invoke), and a human told to approve that name approves the wrapper's Gate, not the Chart's.
812
+ * `self` is the body's actor, which runs the compiled Machine.
813
+ */
814
+ function gateInput(value: unknown, self: AnyActorRef): unknown {
815
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
816
+ const id = (value as { gate?: unknown }).gate;
817
+ if (typeof id !== "string") return value;
818
+ return { ...(value as Record<string, unknown>), gate: [...actorPath(self), id].join(".") };
819
+ }
820
+
821
+ function invokeOf(inv: ChartInvoke, agents: ReadonlySet<string>): unknown {
822
+ const out: Record<string, unknown> = { src: inv.src };
823
+ if (inv.id !== undefined) out.id = inv.id;
824
+ if (inv.input !== undefined) {
825
+ const template = inv.input;
826
+ // Resolved at invoke time, with `{ context, event, input }` (ADR-0070).
827
+ out.input = agents.has(inv.src)
828
+ ? (args: Args) => turnInput(resolveAt(template, args), placementOf(args.context?.[CHART_INPUT_KEY]))
829
+ : inv.src === "gate"
830
+ ? (args: Args & { self: AnyActorRef }) => gateInput(resolveAt(template, args), args.self)
831
+ : (args: Args) => resolveAt(template, args);
832
+ }
833
+ if (inv.onDone !== undefined) out.onDone = transitionsOf(inv.onDone);
834
+ if (inv.onError !== undefined) out.onError = transitionsOf(inv.onError);
835
+ return out;
836
+ }
837
+
838
+ function stateOf(st: ChartState, agents: ReadonlySet<string>): Record<string, unknown> {
839
+ const out: Record<string, unknown> = {};
840
+ if (st.type !== undefined) out.type = st.type;
841
+ if (st.initial !== undefined) out.initial = st.initial;
842
+ if (st.history !== undefined) out.history = st.history;
843
+ if (st.states !== undefined) out.states = mapValues(st.states, (child) => stateOf(child, agents));
844
+ if (st.invoke !== undefined)
845
+ out.invoke = Array.isArray(st.invoke) ? st.invoke.map((inv) => invokeOf(inv, agents)) : invokeOf(st.invoke, agents);
846
+ if (st.on !== undefined) out.on = mapValues(st.on, transitionsOf);
847
+ if (st.always !== undefined) out.always = transitionsOf(st.always);
848
+ if (st.after !== undefined) out.after = mapValues(st.after, transitionsOf);
849
+ if (st.entry !== undefined) out.entry = st.entry.map(actionOf);
850
+ if (st.exit !== undefined) out.exit = st.exit.map(actionOf);
851
+ if (st.output !== undefined) out.output = resolver(st.output);
852
+ if (st.meta !== undefined) out.meta = st.meta;
853
+ if (st.tags !== undefined) out.tags = [...st.tags];
854
+ if (st.description !== undefined) out.description = st.description;
855
+ return out;
856
+ }
857
+
858
+ /** How {@link compileChart} builds what is not in the Chart. */
859
+ export type CompileOptions = OpenBodyOptions & {
860
+ /** The machine id when the Chart names none: the Open body's slot key. Default `"chart"`. */
861
+ id?: string;
862
+ };
863
+
864
+ /**
865
+ * Build the Machine a Chart describes (ADR-0070), with `jr2Setup` over: the lent actors by their
866
+ * slot names, `agent(def)` for each Agent the Chart mints, and the kit's `gate`; the lent guards
867
+ * and `jr2.sentBy`; the lent actions and the four generic ones; the lent delays. Every reference
868
+ * resolves at invoke time, so the Machine holds the text's shape and no run's data. The Chart's
869
+ * input is the context key {@link CHART_INPUT_KEY}; a done Chart outputs `{ outcome: "done",
870
+ * output }`, its `output` resolved (or, without one, its final state's). The transitions ceiling
871
+ * is xstate's `maxIterations` as well, so a guarded `always` loop that never ends in one macrostep
872
+ * still ends. `jr2Setup`'s own refusals (a parallel state below an Agent's) throw here.
873
+ */
874
+ export function compileChart(
875
+ admitted: AdmittedChart,
876
+ lent: LentParts,
877
+ decl: OpenBodyDeclaration,
878
+ options: CompileOptions = {},
879
+ ): AnyStateMachine {
880
+ const text = admitted.text;
881
+ const mint = options.mintAgent ?? ((_slot: string, definition: AgentDefinition) => agent(definition));
882
+ const minted = mapValues(admitted.agents, (def) => def);
883
+ const actors: Record<string, UnknownActorLogic> = chartActors(lent.actors);
884
+ for (const [slot, def] of Object.entries(minted)) actors[slot] = mint(slot, def);
885
+ // The slots an invoke runs an Agent Turn in: the lent Agents and the Chart's own.
886
+ const agentSlots = new Set([
887
+ ...Object.keys(lent.actors).filter((slot) => isAgent(lent.actors[slot])),
888
+ ...Object.keys(minted),
889
+ ]);
890
+ const initialContext = text.context ?? {};
891
+ const config = {
892
+ id: text.id ?? options.id ?? "chart",
893
+ context: ({ input }: { input: unknown }) => ({
894
+ ...(JSON.parse(JSON.stringify(initialContext)) as Record<string, unknown>),
895
+ [CHART_INPUT_KEY]: input ?? {},
896
+ }),
897
+ initial: text.initial,
898
+ states: mapValues(text.states, (st) => stateOf(st, agentSlots)),
899
+ output: ({ context, event }: Args) =>
900
+ ({
901
+ outcome: "done",
902
+ output:
903
+ text.output !== undefined
904
+ ? resolveAt(text.output, { context, event })
905
+ : (event as { output?: unknown } | undefined)?.output,
906
+ }) satisfies OpenBodyOutput,
907
+ options: { maxIterations: decl.ceilings.transitions },
908
+ };
909
+ return jr2Setup({
910
+ events: admitted.events,
911
+ actors,
912
+ guards: lent.guards as never,
913
+ actions: { ...(lent.actions as Record<string, unknown>), ...GENERIC } as never,
914
+ delays: lent.delays as never,
915
+ }).createMachine(config as never) as unknown as AnyStateMachine;
916
+ }
917
+
918
+ // One compile per Chart text and id, per declaration, per process (the brief's memo by hash): the
919
+ // lent implementations vary per run and are put in with `provide`, which keeps the config, so the
920
+ // vocabulary and the seats `jr2Setup` attached stay. What the compile reads off the lent set is
921
+ // the KIND of each lent actor (an Agent's invoke input is a Turn's), so the key holds the kinds:
922
+ // a run whose `provide()` lends a fake in an Agent's place compiles a Machine of its own. Bounded:
923
+ // the least recently used Machine goes first, so distinct one-shot Charts do not grow the process.
924
+ const compiled = new WeakMap<OpenBodyDeclaration, Map<string, AnyStateMachine>>();
925
+
926
+ /** The compiled Machines one declaration keeps. */
927
+ export const COMPILE_MEMO_SIZE = 64;
928
+
929
+ const kindOf = (logic: unknown): string => (isAgent(logic) ? "agent" : isEffect(logic) ? "effect" : "machine");
930
+
931
+ function compiledFor(
932
+ admitted: AdmittedChart,
933
+ lent: LentParts,
934
+ decl: OpenBodyDeclaration,
935
+ options: CompileOptions,
936
+ ): AnyStateMachine {
937
+ let byKey = compiled.get(decl);
938
+ if (!byKey) compiled.set(decl, (byKey = new Map()));
939
+ // The full digest: a 12-hex hash is a display, and two texts that share one must never share a
940
+ // Machine.
941
+ const kinds = Object.keys(lent.actors)
942
+ .sort()
943
+ .map((slot) => `${slot}:${kindOf(lent.actors[slot])}`)
944
+ .join(",");
945
+ const key = `${chartDigest(admitted.text)}#${admitted.text.id ?? options.id ?? "chart"}#${kinds}`;
946
+ let machine = byKey.get(key);
947
+ if (machine) {
948
+ byKey.delete(key);
949
+ byKey.set(key, machine);
950
+ return machine;
951
+ }
952
+ try {
953
+ machine = compileChart(admitted, lent, decl, options);
954
+ } catch (err) {
955
+ if (err instanceof ChartRefusal) throw err;
956
+ throw new ChartRefusal([compileLine(err)]);
957
+ }
958
+ byKey.set(key, machine);
959
+ if (byKey.size > COMPILE_MEMO_SIZE) byKey.delete(byKey.keys().next().value!);
960
+ return machine;
961
+ }
962
+
963
+ /** The compiled Machines a declaration's memo holds now: a test reads the bound. */
964
+ export function compiledCount(decl: OpenBodyDeclaration): number {
965
+ return compiled.get(decl)?.size ?? 0;
966
+ }
967
+
968
+ /** The Machine an Open body's actor runs: compiled from its Chart, with the lent parts it has now.
969
+ * What `invokingMachine` consults first, so an Agent or a Gate a Chart's state invokes resolves its
970
+ * vocabulary, its Menu and its iid as under any Machine. Undefined for any other actor. */
971
+ export function boundMachineOf(actor: AnyActorRef): AnyStateMachine | undefined {
972
+ return boundMachineFor(actor);
973
+ }
974
+
975
+ /** A Chart a persisted (or live) snapshot tree binds: the child ids down to its body, the body's
976
+ * slot key, and the text and hash the body keeps. */
977
+ export type BoundChart = { path: string[]; src: string; chart: ChartText; chartHash: string };
978
+
979
+ /** {@link BoundChart} with the slot key of every actor on the path, root's child first: what the
980
+ * host resolves the body slot through, Machine by Machine. */
981
+ export type BoundChartRoute = BoundChart & { srcs: string[] };
982
+
983
+ /** Every Chart bound in a snapshot tree, with the slot route to its body. Both shapes a run's tree
984
+ * arrives in are read: a live actor's children are actor refs, a persisted one's `{ src, snapshot }`. */
985
+ export function boundChartRoutesOf(snapshot: unknown): BoundChartRoute[] {
986
+ const out: BoundChartRoute[] = [];
987
+ const walk = (snap: unknown, path: string[], srcs: string[]): void => {
988
+ const children = (snap as { children?: Record<string, unknown> } | undefined)?.children ?? {};
989
+ for (const [id, entry] of Object.entries(children)) {
990
+ const child = entry as { src?: unknown; snapshot?: unknown; getSnapshot?: () => unknown };
991
+ const inner = (typeof child.getSnapshot === "function" ? child.getSnapshot() : child.snapshot) as
992
+ | { chart?: unknown; chartHash?: unknown; status?: unknown }
993
+ | undefined;
994
+ const src = typeof child.src === "string" ? child.src : "";
995
+ const at = [...path, id];
996
+ const route = [...srcs, src];
997
+ if (inner && typeof inner.chart === "object" && inner.chart !== null && typeof inner.chartHash === "string") {
998
+ out.push({ path: at, src, srcs: route, chart: inner.chart as ChartText, chartHash: inner.chartHash });
999
+ }
1000
+ walk(inner, at, route);
1001
+ }
1002
+ };
1003
+ walk(snapshot, [], []);
1004
+ return out;
1005
+ }
1006
+
1007
+ /** Every Chart bound in a snapshot tree (D-B: the text and hash ride the body's own snapshot). */
1008
+ export function boundChartsOf(persisted: unknown): BoundChart[] {
1009
+ return boundChartRoutesOf(persisted).map(({ srcs: _srcs, ...chart }) => chart);
1010
+ }
1011
+
1012
+ const isLoop = (err: unknown): boolean => err instanceof Error && err.message.startsWith("Infinite loop detected");
1013
+
1014
+ const fieldsOf = (s: BodyFields): BodyFields => ({
1015
+ ...(s.chart !== undefined ? { chart: s.chart } : {}),
1016
+ ...(s.chartHash !== undefined ? { chartHash: s.chartHash } : {}),
1017
+ startedAt: s.startedAt,
1018
+ counters: { ...s.counters },
1019
+ });
1020
+
1021
+ /** Is this the `{ name }` form of a body's `chart` input? */
1022
+ function isChartName(raw: unknown): raw is { name: string } {
1023
+ return (
1024
+ typeof raw === "object" &&
1025
+ raw !== null &&
1026
+ !Array.isArray(raw) &&
1027
+ Object.keys(raw).length === 1 &&
1028
+ typeof (raw as { name?: unknown }).name === "string"
1029
+ );
1030
+ }
1031
+
1032
+ /** The event the body sends itself when an Agent Turn starts below one of its children: a lent
1033
+ * Machine's Turn, which no transition of the body sees, counted where the body's counters are. */
1034
+ export const TURN_EVENT = "jr2.openBody.turn";
1035
+
1036
+ /**
1037
+ * A scope whose `defer` holds each function until the delegate returns. xstate defers the start
1038
+ * of each child a macrostep spawns; a macrostep the transitions ceiling aborts (or a reference
1039
+ * that resolved to nothing) is in no snapshot, so its children must never start: their Turns would
1040
+ * run outside every counter, and CANCEL could not reach them. `flush` hands the held functions to
1041
+ * the body's own scope, in order, and every later `defer` goes there at once (before the body
1042
+ * starts, xstate runs the initial actions later, and they defer through this scope then); `drop`
1043
+ * discards them, and every later one.
1044
+ */
1045
+ function buffered(scope: AnyActorScope): { scope: AnyActorScope; flush: () => void; drop: () => void } {
1046
+ const held: Array<() => void> = [];
1047
+ let mode: "hold" | "forward" | "drop" = "hold";
1048
+ return {
1049
+ scope: {
1050
+ ...scope,
1051
+ defer: (fn: () => void) => {
1052
+ if (mode === "hold") held.push(fn);
1053
+ else if (mode === "forward") scope.defer(fn);
1054
+ },
1055
+ } as AnyActorScope,
1056
+ flush: () => {
1057
+ mode = "forward";
1058
+ for (const fn of held.splice(0)) scope.defer(fn);
1059
+ },
1060
+ drop: () => {
1061
+ mode = "drop";
1062
+ held.length = 0;
1063
+ },
1064
+ };
1065
+ }
1066
+
1067
+ /** The Turns a body's actor counts, at any depth below it, and its watch on the actor system. */
1068
+ type TurnWatch = { turns: number; unsubscribe: () => void };
1069
+ const turnWatches = new WeakMap<AnyActorRef, TurnWatch>();
1070
+
1071
+ const isBelow = (ref: AnyActorRef, self: AnyActorRef): boolean => {
1072
+ for (let p = ref._parent; p; p = p._parent) if (p === self) return true;
1073
+ return false;
1074
+ };
1075
+
1076
+ /**
1077
+ * Count every Agent Turn that starts below the body (ADR-0070: the Turns counter is the Open
1078
+ * body's), a lent Machine's included: an Agent actor CREATED below it, not one restored (a restore
1079
+ * re-attaches a Turn that was counted). The Turn one past the ceiling is stopped before its
1080
+ * deferred start, so it is never admitted, and the body is told; a Turn below a child is told too,
1081
+ * so the count rides the body's snapshot.
1082
+ */
1083
+ function watchTurns(self: AnyActorRef, turns: number, ceiling: number): TurnWatch {
1084
+ turnWatches.get(self)?.unsubscribe();
1085
+ const watch: TurnWatch = { turns, unsubscribe: () => {} };
1086
+ const sub = self.system.inspect((ev) => {
1087
+ if (ev.type !== "@xstate.actor") return;
1088
+ const ref = ev.actorRef as AnyActorRef & { logic?: unknown; options?: { snapshot?: unknown; state?: unknown } };
1089
+ if (!isAgent(ref.logic) || ref.options?.snapshot !== undefined || ref.options?.state !== undefined) return;
1090
+ if (!isBelow(ref, self)) return;
1091
+ watch.turns += 1;
1092
+ if (watch.turns > ceiling) (ref as unknown as { _stop: () => void })._stop();
1093
+ if (watch.turns > ceiling || ref._parent !== self) self.send({ type: TURN_EVENT });
1094
+ });
1095
+ watch.unsubscribe = () => sub.unsubscribe();
1096
+ turnWatches.set(self, watch);
1097
+ return watch;
1098
+ }
1099
+
1100
+ /** The actor logic of an Open body: a custom ActorLogic that delegates to the compiled Chart. */
1101
+ export function openBodyLogic(decl: OpenBodyDeclaration, options: OpenBodyOptions = {}): UnknownActorLogic {
1102
+ const wallMs = durationMs(decl.ceilings.wallClock) ?? Number.POSITIVE_INFINITY;
1103
+ const c = decl.ceilings;
1104
+
1105
+ const settled = (output: OpenBodyOutput, fields: BodyFields, from?: BodySnapshot): BodySnapshot => ({
1106
+ status: "done",
1107
+ output,
1108
+ error: undefined,
1109
+ value: from?.value ?? {},
1110
+ context: {},
1111
+ children: {},
1112
+ historyValue: {},
1113
+ ...fields,
1114
+ });
1115
+
1116
+ /** Settle with `output` and stop the body's children (a ceiling, a run-time refusal). */
1117
+ const settleWith = (
1118
+ output: OpenBodyOutput,
1119
+ from: BodySnapshot,
1120
+ fields: BodyFields,
1121
+ scope: AnyActorScope,
1122
+ ): BodySnapshot => {
1123
+ for (const child of Object.values(from.children ?? {})) {
1124
+ if ((child as { _parent?: unknown })._parent === scope.self) scope.stopChild(child);
1125
+ }
1126
+ return settled(output, fields, from);
1127
+ };
1128
+
1129
+ /** Settle on a ceiling (ADR-0070): the body's output names it, and its children are stopped. */
1130
+ const ceiling = (
1131
+ which: keyof Ceilings,
1132
+ detail: string,
1133
+ from: BodySnapshot,
1134
+ fields: BodyFields,
1135
+ scope: AnyActorScope,
1136
+ ): BodySnapshot => settleWith({ outcome: "ceiling", ceiling: which, detail }, from, fields, scope);
1137
+
1138
+ const turnsDetail = (turns: number) =>
1139
+ `the Chart started ${turns} Agent Turns; this Open body's ceiling is ${c.turns}`;
1140
+
1141
+ /** The counters and the ceilings a transition's result is held to: Turns started, children live,
1142
+ * the size of the context and the output. */
1143
+ const held = (next: AnyMachineSnapshot, fields: BodyFields, scope: AnyActorScope): BodySnapshot => {
1144
+ const turns = turnWatches.get(scope.self)?.turns ?? fields.counters.turns;
1145
+ const counted: BodyFields = { ...fields, counters: { ...fields.counters, turns } };
1146
+ const out = { ...(next as unknown as Record<string, unknown>), ...counted } as BodySnapshot;
1147
+ // A generic `set` or `append` can copy a structure into itself, so a context that is small in
1148
+ // memory (shared references) can double its JSON on each transition; every save, status read
1149
+ // and feed frame writes that JSON. So the data a Chart holds is bounded too.
1150
+ const big = jsonOver(next.context, MAX_CONTEXT_SIZE)
1151
+ ? "context"
1152
+ : next.status === "done" && jsonOver(next.output, MAX_CONTEXT_SIZE)
1153
+ ? "output"
1154
+ : undefined;
1155
+ if (big) {
1156
+ return ceiling(
1157
+ "size",
1158
+ `the Chart's ${big} is more than ${MAX_CONTEXT_SIZE} bytes of JSON; an Open body holds at most that`,
1159
+ out,
1160
+ counted,
1161
+ scope,
1162
+ );
1163
+ }
1164
+ if (next.status !== "active") return out;
1165
+ if (turns > c.turns) return ceiling("turns", turnsDetail(turns), out, counted, scope);
1166
+ // A child that is done while its state is still active is no longer live (xstate removes it
1167
+ // only when its state exits).
1168
+ const children = Object.values((next.children ?? {}) as Record<string, AnyActorRef>);
1169
+ const live = children.filter((child) => child.getSnapshot()?.status === "active").length;
1170
+ if (live > c.fanOut) {
1171
+ return ceiling(
1172
+ "fanOut",
1173
+ `the Chart has ${live} children live at once; this Open body's ceiling is ${c.fanOut}`,
1174
+ out,
1175
+ counted,
1176
+ scope,
1177
+ );
1178
+ }
1179
+ return out;
1180
+ };
1181
+
1182
+ const transitionsDetail = (n: number) =>
1183
+ `the Chart took ${n} microsteps; this Open body's ceiling is ${c.transitions}`;
1184
+
1185
+ /**
1186
+ * Run one step of the compiled Machine over a buffered scope, and hold its result to the
1187
+ * ceilings. The transitions ceiling is a microstep budget for the whole run (ADR-0070): each
1188
+ * step counts the microsteps xstate takes (its `@xstate.microstep` inspection events), and the
1189
+ * step may take at most what is left (xstate's `maxIterations`, set per step on the body's own
1190
+ * Machine), so a macrostep that never comes to rest ends too. Past the budget the body settles
1191
+ * `ceiling`; a reference that resolved to nothing settles `refused`. Either way the aborted
1192
+ * step's deferred starts are dropped: its children never run.
1193
+ */
1194
+ const step = (
1195
+ machine: AnyStateMachine,
1196
+ run: (scope: AnyActorScope) => AnyMachineSnapshot,
1197
+ from: BodySnapshot,
1198
+ fields: BodyFields,
1199
+ scope: AnyActorScope,
1200
+ ): BodySnapshot => {
1201
+ const { scope: inner, flush, drop } = buffered(scope);
1202
+ // `provide()` gave this body a Machine of its own, so its options are this run's alone.
1203
+ (machine.options as { maxIterations?: number }).maxIterations = Math.max(
1204
+ 1,
1205
+ c.transitions - fields.counters.transitions,
1206
+ );
1207
+ let microsteps = 0;
1208
+ const counting = scope.system.inspect((ev) => {
1209
+ if (ev.type === "@xstate.microstep" && ev.actorRef === scope.self) microsteps++;
1210
+ });
1211
+ runFault = undefined;
1212
+ let next: AnyMachineSnapshot;
1213
+ try {
1214
+ next = run(inner);
1215
+ } catch (err) {
1216
+ drop();
1217
+ // Set by a resolver inside `run`, which the compiler cannot see.
1218
+ const fault = runFault as ChartRunFault | undefined;
1219
+ runFault = undefined;
1220
+ if (isLoop(err)) {
1221
+ const total = fields.counters.transitions + microsteps;
1222
+ return ceiling(
1223
+ "transitions",
1224
+ `${transitionsDetail(Math.max(total, c.transitions + 1))}, and it did not come to rest`,
1225
+ from,
1226
+ { ...fields, counters: { ...fields.counters, transitions: total } },
1227
+ scope,
1228
+ );
1229
+ }
1230
+ if (fault !== undefined) {
1231
+ return settleWith(
1232
+ {
1233
+ outcome: "refused",
1234
+ report: [`(run time): ${fault.message}; the door admitted it, and the run's data does not hold it`],
1235
+ },
1236
+ from,
1237
+ fields,
1238
+ scope,
1239
+ );
1240
+ }
1241
+ throw err;
1242
+ } finally {
1243
+ counting.unsubscribe();
1244
+ }
1245
+ const counted: BodyFields = {
1246
+ ...fields,
1247
+ counters: { ...fields.counters, transitions: fields.counters.transitions + microsteps },
1248
+ };
1249
+ if (counted.counters.transitions > c.transitions) {
1250
+ drop();
1251
+ return ceiling("transitions", transitionsDetail(counted.counters.transitions), from, counted, scope);
1252
+ }
1253
+ const out = held(next, counted, scope);
1254
+ // A ceiling `held` reached stopped the new children, so their deferred starts do nothing.
1255
+ flush();
1256
+ return out;
1257
+ };
1258
+
1259
+ /** Admit and compile a Chart text, and record the Machine the actor runs. Throws ChartRefusal. */
1260
+ const bind = (self: AnyActorRef, text: unknown, inputKeys: readonly string[]) => {
1261
+ const lent = liveLentOf(self, logic);
1262
+ const admitted = admitChart(text, decl, lent, { inputKeys });
1263
+ const src = (self as { src?: unknown }).src;
1264
+ const machine = compiledFor(admitted, lent, decl, {
1265
+ ...options,
1266
+ ...(typeof src === "string" ? { id: src } : {}),
1267
+ }).provide({
1268
+ actors: chartActors(lent.actors),
1269
+ guards: lent.guards,
1270
+ actions: lent.actions,
1271
+ delays: lent.delays,
1272
+ } as Parameters<AnyStateMachine["provide"]>[0]);
1273
+ attachBoundMachine(self, machine);
1274
+ return { admitted, machine };
1275
+ };
1276
+
1277
+ /** Is `ref` the body of a transparent wrapper (ADR-0049: `workspace()`, `pool()`)? */
1278
+ const isTransparentBody = (ref: AnyActorRef): boolean => {
1279
+ const parent = (ref._parent as { logic?: unknown } | undefined)?.logic;
1280
+ return isMachine(parent) && wrapperBodyOf(parent) === (ref as { src?: unknown }).src;
1281
+ };
1282
+
1283
+ /** The Chart a body's `chart` input names: its text, or a named Chart of the run's own wrapper. */
1284
+ const chartOf = (raw: unknown, scope: AnyActorScope): unknown => {
1285
+ if (raw === undefined || raw === null) {
1286
+ throw new ChartRefusal(["chart: the Open body's input carries no `chart` (its text, or { name })"]);
1287
+ }
1288
+ if (!isChartName(raw)) return raw;
1289
+ // D-C: a name resolves among the Charts of the wrapper that is the run's Workflow, seen through
1290
+ // the transparent wrappers above it; a body whose wrapper another Machine invokes resolves none.
1291
+ let wrapper = scope.self._parent;
1292
+ while (wrapper?._parent && isTransparentBody(wrapper)) wrapper = wrapper._parent;
1293
+ if (wrapper?._parent !== undefined) {
1294
+ throw new ChartRefusal([
1295
+ `chart.name: "${raw.name}" — a Chart is named only under the Chart wrapper that is the run's own Workflow, ` +
1296
+ "and this Open body's Chart wrapper is invoked by another Machine; pass the Chart's text (ADR-0070)",
1297
+ ]);
1298
+ }
1299
+ const text = boundRun(scope.system)?.chartNamed?.(raw.name);
1300
+ if (text === undefined) {
1301
+ throw new ChartRefusal([`chart.name: the run's Chart wrapper has no Chart named "${raw.name}"`]);
1302
+ }
1303
+ return text;
1304
+ };
1305
+
1306
+ /** The keys of the Chart's `input` that hold a value: a key sent as undefined is absent, and
1307
+ * the body's placement is never the Chart's to read. */
1308
+ const keysOf = (input: Record<string, unknown>): string[] =>
1309
+ Object.keys(chartInputOf(input)).filter((k) => input[k] !== undefined);
1310
+
1311
+ /** The body is done with the system: its Turn watch ends. */
1312
+ const release = (self: AnyActorRef, snap: BodySnapshot): BodySnapshot => {
1313
+ if (snap.status !== "active") {
1314
+ turnWatches.get(self)?.unsubscribe();
1315
+ turnWatches.delete(self);
1316
+ }
1317
+ return snap;
1318
+ };
1319
+
1320
+ const logic = {
1321
+ getInitialSnapshot(scope: AnyActorScope, input: unknown): BodySnapshot {
1322
+ const base: BodyFields = { startedAt: Date.now(), counters: { turns: 0, transitions: 0 } };
1323
+ const { chart: raw, ...chartInput } = (input ?? {}) as Record<string, unknown>;
1324
+ try {
1325
+ const text = chartOf(raw, scope);
1326
+ const { admitted, machine } = bind(scope.self, text, keysOf(chartInput));
1327
+ const fields: BodyFields = { ...base, chart: admitted.text, chartHash: admitted.hash };
1328
+ watchTurns(scope.self, 0, c.turns);
1329
+ const from = settled({ outcome: "done", output: undefined }, fields);
1330
+ const out = step(
1331
+ machine,
1332
+ (inner) => machine.getInitialSnapshot(inner as never, chartInput) as AnyMachineSnapshot,
1333
+ from,
1334
+ fields,
1335
+ scope,
1336
+ );
1337
+ if (out.status === "active") {
1338
+ // The wall clock is xstate's own delayed event, so a parked Chart still ends; the run
1339
+ // host records it with the run's other timers and arms it again at restore (timers.ts).
1340
+ scope.defer(() => {
1341
+ (scope.system as unknown as { scheduler: Scheduler }).scheduler.schedule(
1342
+ scope.self,
1343
+ scope.self,
1344
+ { type: WALL_CLOCK_EVENT },
1345
+ wallMs,
1346
+ WALL_CLOCK_EVENT,
1347
+ );
1348
+ });
1349
+ }
1350
+ return release(scope.self, out);
1351
+ } catch (err) {
1352
+ if (!(err instanceof ChartRefusal)) throw err;
1353
+ return release(scope.self, settled({ outcome: "refused", report: err.report }, base));
1354
+ }
1355
+ },
1356
+
1357
+ transition(snapshot: BodySnapshot, event: AnyEventObject, scope: AnyActorScope): BodySnapshot {
1358
+ const machine = snapshot.machine;
1359
+ if (snapshot.status !== "active" || !machine) return snapshot;
1360
+ const fields = fieldsOf(snapshot);
1361
+ if (event.type === "xstate.stop") {
1362
+ release(scope.self, { ...snapshot, status: "stopped" });
1363
+ return {
1364
+ ...(machine.transition(snapshot as never, event, scope as never) as object),
1365
+ ...fields,
1366
+ } as BodySnapshot;
1367
+ }
1368
+ if (event.type === TURN_EVENT) {
1369
+ // A Turn started below a child of the body: the count rides the snapshot, and one past
1370
+ // the ceiling settles the body (the Turn itself was stopped before it started).
1371
+ const turns = turnWatches.get(scope.self)?.turns ?? fields.counters.turns;
1372
+ const counted: BodyFields = { ...fields, counters: { ...fields.counters, turns } };
1373
+ if (turns > c.turns) return release(scope.self, ceiling("turns", turnsDetail(turns), snapshot, counted, scope));
1374
+ return { ...snapshot, ...counted };
1375
+ }
1376
+ const elapsed = Date.now() - fields.startedAt;
1377
+ if (event.type === WALL_CLOCK_EVENT || elapsed >= wallMs) {
1378
+ return release(
1379
+ scope.self,
1380
+ ceiling(
1381
+ "wallClock",
1382
+ `the Chart ran ${decl.ceilings.wallClock} by the wall clock; this Open body's ceiling is ${decl.ceilings.wallClock}`,
1383
+ snapshot,
1384
+ fields,
1385
+ scope,
1386
+ ),
1387
+ );
1388
+ }
1389
+ if (fields.counters.transitions >= c.transitions) {
1390
+ return release(
1391
+ scope.self,
1392
+ ceiling("transitions", transitionsDetail(fields.counters.transitions), snapshot, fields, scope),
1393
+ );
1394
+ }
1395
+ return release(
1396
+ scope.self,
1397
+ step(
1398
+ machine,
1399
+ (inner) => machine.transition(snapshot as never, event, inner as never) as AnyMachineSnapshot,
1400
+ snapshot,
1401
+ fields,
1402
+ scope,
1403
+ ),
1404
+ );
1405
+ },
1406
+
1407
+ getPersistedSnapshot(snapshot: BodySnapshot, options?: unknown): unknown {
1408
+ const fields = fieldsOf(snapshot);
1409
+ if (!snapshot.machine) {
1410
+ const { machine: _m, ...plain } = snapshot;
1411
+ return { ...plain, ...fields };
1412
+ }
1413
+ return { ...(snapshot.machine.getPersistedSnapshot(snapshot as never, options as never) as object), ...fields };
1414
+ },
1415
+
1416
+ restoreSnapshot(persisted: unknown, scope: AnyActorScope): BodySnapshot {
1417
+ const p = persisted as BodySnapshot;
1418
+ if (p.status !== "active" || p.chart === undefined) return p;
1419
+ const hash = chartHash(p.chart);
1420
+ if (hash !== p.chartHash) {
1421
+ throw new Error(`Open body: the Chart's hash ${String(p.chartHash)} does not match its text (${hash})`);
1422
+ }
1423
+ const input = (p.context?.[CHART_INPUT_KEY] ?? {}) as Record<string, unknown>;
1424
+ // Admitted again against the declaration and the lent set the actor has NOW: a lent slot the
1425
+ // Assembly dropped refuses (D-B). The host checks this before it restores; a refusal here is
1426
+ // the body's error.
1427
+ const { machine } = bind(scope.self, p.chart, keysOf(input));
1428
+ // The Turns counted so far ride the snapshot; a restored Agent re-attaches and is not counted.
1429
+ watchTurns(scope.self, p.counters?.turns ?? 0, c.turns);
1430
+ const inner = machine.restoreSnapshot!(p as never, scope as never) as object;
1431
+ return { ...inner, ...fieldsOf(p) } as BodySnapshot;
1432
+ },
1433
+
1434
+ start(snapshot: BodySnapshot): void {
1435
+ if (snapshot.status === "active" && snapshot.machine) snapshot.machine.start?.(snapshot as never);
1436
+ },
1437
+ };
1438
+ return logic as unknown as UnknownActorLogic;
1439
+ }
1440
+
1441
+ /** xstate's system scheduler, as the wall clock arms it (an xstate internal; timers.ts reads it too). */
1442
+ type Scheduler = {
1443
+ schedule: (source: AnyActorRef, target: AnyActorRef, event: AnyEventObject, delay: number, id?: string) => void;
1444
+ };