@jr2/orchestrator 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -5
  13. package/src/actor.ts +507 -54
  14. package/src/agent.ts +2 -2
  15. package/src/ambient.ts +2 -0
  16. package/src/assembly-checks.ts +165 -0
  17. package/src/assembly-tar.ts +494 -0
  18. package/src/assembly.ts +721 -0
  19. package/src/chart.ts +2050 -0
  20. package/src/charts.ts +720 -0
  21. package/src/config.ts +233 -22
  22. package/src/content-hash.ts +105 -0
  23. package/src/custodian.lua +313 -0
  24. package/src/custodian.ts +951 -0
  25. package/src/customize.ts +216 -25
  26. package/src/effect-checks.ts +557 -0
  27. package/src/effect.ts +905 -0
  28. package/src/effector-wire.ts +525 -0
  29. package/src/fingerprint.ts +4 -3
  30. package/src/gate.ts +8 -0
  31. package/src/git-clone.ts +65 -0
  32. package/src/git-push.ts +336 -0
  33. package/src/harness-client.ts +247 -25
  34. package/src/held-secrets.ts +643 -0
  35. package/src/holds.ts +156 -0
  36. package/src/http-effect.ts +981 -0
  37. package/src/http.ts +514 -119
  38. package/src/images.ts +58 -31
  39. package/src/index.ts +49 -4
  40. package/src/instance.ts +445 -59
  41. package/src/kube-client.ts +333 -0
  42. package/src/machine-doc.ts +88 -6
  43. package/src/model-keys.ts +92 -0
  44. package/src/mount-digest.ts +53 -0
  45. package/src/names.ts +89 -7
  46. package/src/notices.ts +88 -0
  47. package/src/open-body.ts +1444 -0
  48. package/src/open-effect.ts +144 -0
  49. package/src/open.ts +4 -4
  50. package/src/parts.ts +530 -33
  51. package/src/party.ts +468 -0
  52. package/src/pick.ts +309 -0
  53. package/src/pool.ts +1 -1
  54. package/src/registration.ts +301 -17
  55. package/src/repo-fetch.ts +61 -86
  56. package/src/repos.ts +63 -74
  57. package/src/rest.ts +133 -0
  58. package/src/run-host.ts +1841 -105
  59. package/src/sandbox-effect.ts +891 -0
  60. package/src/sandbox-effector.ts +111 -0
  61. package/src/sandbox-kube.ts +1719 -0
  62. package/src/sandbox-watch.ts +260 -0
  63. package/src/secret-placement.ts +494 -0
  64. package/src/server.ts +285 -89
  65. package/src/setup.ts +589 -70
  66. package/src/size.ts +342 -0
  67. package/src/snapshot-store.ts +64 -13
  68. package/src/stub-harness.ts +5 -0
  69. package/src/sync-client.ts +54 -0
  70. package/src/timers.ts +178 -0
  71. package/src/tokens.ts +44 -12
  72. package/src/vocabulary.ts +66 -1
  73. package/src/wire.ts +156 -2
  74. package/src/workspace.ts +529 -194
  75. package/src/sandbox-kubectl.ts +0 -1144
package/src/chart.ts ADDED
@@ -0,0 +1,2050 @@
1
+ // The Chart (ADR-0070): a Machine written as data alone, and the door that admits one.
2
+ //
3
+ // A Chart is xstate's own JSON-serializable config with every guard, action and delay named and
4
+ // every `invoke.src` a slot name. It carries no function, no import, no secret name, no url, no
5
+ // argv, no image and no Repo. It MINTS its states, its events and its own Agents; it BORROWS every
6
+ // other part by slot name from the Open body it binds (open-body.ts). So authority is a constant
7
+ // the door checks, and data flows only through references: a placeholder `{{context.ticket}}` or
8
+ // an object `{ "$ref": "event.notes" }`, a path lookup that jr2's own code performs. There is no
9
+ // evaluation, no operator and no call, and the roots are `context`, `event` and `input` only.
10
+ //
11
+ // The door is static and mandatory, because xstate refuses nothing: an un-lent `src` kills the
12
+ // Chart wrapper, an unknown action is ignored, an unknown delay fires at once, and an unguarded `always`
13
+ // cycle exhausts the heap. `admitChart` checks, in this order: (1) the size, (2) the grammar,
14
+ // (3) the names, (4) the events, (5) the minted Agents, (6) the structure, (7) the dataflow and
15
+ // (8) the lent input shapes. Each refusal is one line that starts with the path in the Chart, so
16
+ // an author (or a planner Agent, which reads the report as its answer) can find and fix it.
17
+ //
18
+ // This module is socket-free. It builds a Machine once, as the last check: a trial `compileChart`
19
+ // (open-body.ts), so that `jr2Setup`'s own refusals and load warnings are lines of the same report.
20
+
21
+ import { createHash } from "node:crypto";
22
+ import { z } from "zod";
23
+ import { defineEvent, type EventAudience, type EventDef } from "@jr2/agent-protocol";
24
+ import type { AgentDefinition, ThinkingLevel, WorkspaceAccess } from "./agent.ts";
25
+ import { inputKeysOf, isEffect } from "./effect.ts";
26
+ import { compileChart, durationMs, type LentParts, type OpenBodyDeclaration } from "./open-body.ts";
27
+ import { SENT_BY } from "./party.ts";
28
+ import { loadWarningsOf } from "./vocabulary.ts";
29
+
30
+ // --- The text -------------------------------------------------------------------------------------
31
+
32
+ /** Plain JSON. */
33
+ export type ChartJson = string | number | boolean | null | ChartJson[] | { [key: string]: ChartJson };
34
+
35
+ /**
36
+ * A value whose strings may hold `{{<root>.<path>}}` placeholders and whose objects may be
37
+ * `{ "$ref": "<root>.<path>" }`. Resolved by {@link resolveReferences} at invoke time.
38
+ */
39
+ export type ChartValue = ChartJson;
40
+
41
+ /** A guard: a lent guard by name, with or without params, `jr2.sentBy`, or `not`/`and`/`or`. */
42
+ export type ChartGuard =
43
+ | string
44
+ | { type: string; params?: ChartValue }
45
+ | { not: ChartGuard }
46
+ | { and: ChartGuard[] }
47
+ | { or: ChartGuard[] };
48
+
49
+ /**
50
+ * An action. The four generic ones write the run's context at `path`, a context path WITHOUT the
51
+ * `context.` root (`"round"`, `"ticket.notes"`, `"items.0"`); every other `type` is a lent action.
52
+ */
53
+ export type ChartAction =
54
+ | { type: "set"; path: string; from: ChartValue }
55
+ | { type: "append"; path: string; from: ChartValue }
56
+ | { type: "increment"; path: string; by?: number }
57
+ | { type: "clear"; path: string }
58
+ | { type: string; params?: ChartValue };
59
+
60
+ /** A transition: a target alone, or the object form. */
61
+ export type ChartTransition =
62
+ | string
63
+ | {
64
+ target?: string | string[];
65
+ guard?: ChartGuard;
66
+ actions?: ChartAction[];
67
+ reenter?: boolean;
68
+ description?: string;
69
+ };
70
+
71
+ /** One transition or a list, in xstate's order. */
72
+ export type ChartTransitions = ChartTransition | ChartTransition[];
73
+
74
+ /** An invoke: a lent Agent, Effect or Machine, a minted Agent, or the kit's `gate`. */
75
+ export type ChartInvoke = {
76
+ src: string;
77
+ id?: string;
78
+ input?: ChartValue;
79
+ onDone?: ChartTransitions;
80
+ onError?: ChartTransitions;
81
+ };
82
+
83
+ /** A state node. */
84
+ export type ChartState = {
85
+ type?: "atomic" | "compound" | "parallel" | "final" | "history";
86
+ initial?: string;
87
+ states?: Record<string, ChartState>;
88
+ history?: "shallow" | "deep";
89
+ invoke?: ChartInvoke | ChartInvoke[];
90
+ on?: Record<string, ChartTransitions>;
91
+ always?: ChartTransitions;
92
+ after?: Record<string, ChartTransitions>;
93
+ entry?: ChartAction[];
94
+ exit?: ChartAction[];
95
+ output?: ChartValue;
96
+ meta?: ChartJson;
97
+ tags?: string[];
98
+ description?: string;
99
+ };
100
+
101
+ /** An event a Chart mints: its payload is a JSON Schema object, built into zod at admission. */
102
+ export type ChartEvent = { description?: string; input: Record<string, unknown>; audience?: EventAudience };
103
+
104
+ /** An Agent a Chart mints: the plain fields of an Agent definition (ADR-0049). */
105
+ export type ChartAgent = {
106
+ model: string;
107
+ instructions: string;
108
+ description?: string;
109
+ thinkingLevel?: ThinkingLevel;
110
+ workspace?: WorkspaceAccess;
111
+ };
112
+
113
+ /** The Chart text (ADR-0070). Every key here is the whole grammar: the door refuses any other. */
114
+ export type ChartText = {
115
+ version?: 1;
116
+ /** The machine id. Default: the Open body's slot key. A `#` target needs it. */
117
+ id?: string;
118
+ /** The initial context: plain JSON, no references. */
119
+ context?: Record<string, ChartJson>;
120
+ events?: Record<string, ChartEvent>;
121
+ agents?: Record<string, ChartAgent>;
122
+ initial: string;
123
+ states: Record<string, ChartState>;
124
+ /** The Chart's output, resolved when the Chart is done. */
125
+ output?: ChartValue;
126
+ };
127
+
128
+ /** The four actions every Chart has; every other action `type` is lent. */
129
+ export const GENERIC_ACTIONS = ["set", "append", "increment", "clear"] as const;
130
+
131
+ /** The mechanism events a Chart may handle by name (ADR-0011): jr2 delivers them, no def declares them. */
132
+ const MECHANISM_EVENTS: Record<string, { fields: Record<string, boolean> }> = {
133
+ "agent.fault": { fields: { type: true, instanceId: true, reason: true } },
134
+ "workspace.lost": { fields: { type: true, reason: false, message: false } },
135
+ };
136
+
137
+ /** The keys an Agent invoke's input may carry (ADR-0057's Frame and Dials, and the stub tier's
138
+ * placement; see {@link checkAgentInput}). */
139
+ const AGENT_INPUT_KEYS = ["prompt", "cwd", "continue", "model", "thinkingLevel"] as const;
140
+
141
+ /**
142
+ * The Chart briefing (ADR-0070): jr2's text on how a Chart runs, for whoever writes one. It is the
143
+ * grammar's root description, so it rides `GET /workflows/<wrapper>/chart-schema` and a pick's tool
144
+ * parameters; and the surface marks a pick whose parameter is a Chart with it, so the Harness says
145
+ * it in the Turn part when that pick is Allowed (`AgentSurfaceView`). Each sentence is a fault a
146
+ * measured planner made without it. It names no task and no pick.
147
+ */
148
+ export const CHART_BRIEFING = chartBriefing();
149
+
150
+ /**
151
+ * The Chart briefing for one body's grammar: the sentence on where an Agent comes from names the
152
+ * lent Agents, and a body that allows no model has no `agents` to speak of.
153
+ */
154
+ export function chartBriefing(narrow?: Narrow): string {
155
+ const lent = narrow?.lentAgents.length ? ` (${narrow.lentAgents.map((n) => `\`${n}\``).join(", ")})` : "";
156
+ const mints = narrow !== undefined && narrow.models.length === 0;
157
+ const gate = narrow?.gate
158
+ ? ' `src: "gate"` is the kit\'s Gate: the Chart waits there for an outside answer (a human, a webhook), ' +
159
+ 'one of its `events` with `audience: "external"`, not an Agent\'s Turn.'
160
+ : "";
161
+ const agents = mints
162
+ ? `A state runs an Agent with \`invoke: { src, input: { prompt } }\`: \`src\` is a lent Agent's name${lent}. ` +
163
+ "This Chart mints no Agent of its own: there is no `agents`."
164
+ : `A state runs an Agent with \`invoke: { src, input: { prompt } }\`: \`src\` is a lent Agent's name${lent} or ` +
165
+ "a key of `agents`; a lent Agent needs no entry in `agents`, which mints new ones.";
166
+ return (
167
+ `A Chart is a workflow written as data: states, the events its Agents pick${mints ? "" : ", and the Agents it mints"}. ` +
168
+ `${agents}${gate} An Agent's Turn ends only when it ` +
169
+ "picks one of the Chart's `events`: declare each pick in `events` with an `input` schema " +
170
+ '(`{ "type": "object", "properties": { "<field>": { "type": "string" } }, "required": ["<field>"] }`; a field not in ' +
171
+ "`required` may be absent), and " +
172
+ "handle it in the `on` of the invoking state or a state above it, with a `target`; `onDone` never fires for an " +
173
+ "Agent. A value is a reference: `{{input.<key>}}` (the Chart's input), `{{context.<path>}}`, or " +
174
+ "`{{event.<field>}}` on a transition for the pick it handles. Keep what a pick says with an action " +
175
+ '`{ "type": "set", "path": "<path>", "from": "{{event.<field>}}" }`, and read it back from ' +
176
+ '`context`. The Chart ends at a state of `type: "final"`, and its `output` is resolved from `context` then. ' +
177
+ "A cycle between states needs a guard on one of its edges."
178
+ );
179
+ }
180
+
181
+ /** A Turn's placement (ADR-0057's `AgentTurnPlacement`): never the Chart's to name. The Open body
182
+ * places each Turn its Chart starts from its own input (open-body.ts `PLACEMENT_KEYS`). */
183
+ const PLACEMENT_INPUT_KEYS = ["endpoint", "sandbox"] as const;
184
+ const THINKING: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
185
+
186
+ /** A name: an xstate event type, a state key, a slot key. No dot (xstate's delimiter), no `#`. */
187
+ const NAME = /^[A-Za-z0-9_-]+$/;
188
+ /** One segment of a path: an identifier, or an integer that indexes an array. */
189
+ const SEGMENT = /^(?:[A-Za-z_][A-Za-z0-9_-]*|0|[1-9][0-9]*)$/;
190
+ /** Segments a path may never walk: they reach the prototype, never the data. */
191
+ const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
192
+ const ROOTS = ["context", "event", "input"] as const;
193
+ type Root = (typeof ROOTS)[number];
194
+ /** A placeholder in a Chart string. Its content must be a reference path. */
195
+ const PLACEHOLDER = /\{\{([^{}]*)\}\}/g;
196
+ const WHOLE_PLACEHOLDER = /^\{\{([^{}]*)\}\}$/;
197
+
198
+ /** The faults of a context path (`round`, `ticket.notes`), or undefined when it is one. */
199
+ function pathFault(path: string): string | undefined {
200
+ const segments = path.split(".");
201
+ for (const s of segments) {
202
+ if (!SEGMENT.test(s)) return `"${path}" is not a path: identifiers or integers joined by "."`;
203
+ if (FORBIDDEN_SEGMENTS.has(s)) return `"${path}" walks "${s}", which no path may name`;
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ /** The faults of a reference (`context.round`), or undefined when it is one. */
209
+ function referenceFault(ref: string): string | undefined {
210
+ const [root, ...rest] = ref.split(".");
211
+ if (!(ROOTS as readonly string[]).includes(root!)) {
212
+ return `"${ref}" is not a reference: it starts at context, event or input (a reference is a path, never an expression)`;
213
+ }
214
+ if (rest.length === 0) return `"${ref}" names a whole root; a reference names a path below it, like "${root}.x"`;
215
+ const fault = pathFault(rest.join("."));
216
+ return fault
217
+ ? `"${ref}" is not a reference: identifiers or integers joined by ".", with no operator or call`
218
+ : undefined;
219
+ }
220
+
221
+ /**
222
+ * The segments of a path: an integer segment is a number, every other a property name. A generic
223
+ * action's `path` is a context path (no `context.` root); a reference keeps its root as segment 0.
224
+ */
225
+ export function pathSegments(path: string): Array<string | number> {
226
+ return path.split(".").map((s) => (/^(0|[1-9][0-9]*)$/.test(s) ? Number(s) : s));
227
+ }
228
+
229
+ // --- The grammar ----------------------------------------------------------------------------------
230
+
231
+ /** What a narrowed grammar fixes as enums (ADR-0070: the grammar with the Chart wrapper's names). */
232
+ type Narrow = {
233
+ srcs: string[];
234
+ lentAgents: string[];
235
+ gate: boolean;
236
+ guards: string[];
237
+ actions: string[];
238
+ delays: string[];
239
+ models: string[];
240
+ workspace: string[];
241
+ };
242
+
243
+ const enumOf = (values: string[]): z.ZodType<string> =>
244
+ values.length ? (z.enum(values as [string, ...string[]]) as unknown as z.ZodType<string>) : z.never();
245
+
246
+ /** Check a value's placeholders and `$ref` objects, adding an issue per fault at its path. */
247
+ function referenceIssues(value: unknown, ctx: z.RefinementCtx, path: PropertyKey[]): void {
248
+ if (typeof value === "string") {
249
+ for (const m of value.matchAll(PLACEHOLDER)) {
250
+ const fault = referenceFault(m[1]!.trim());
251
+ if (fault) ctx.addIssue({ code: "custom", path, message: fault });
252
+ }
253
+ return;
254
+ }
255
+ if (Array.isArray(value)) {
256
+ value.forEach((v, i) => referenceIssues(v, ctx, [...path, i]));
257
+ return;
258
+ }
259
+ if (typeof value === "object" && value !== null) {
260
+ if ("$ref" in value) {
261
+ const keys = Object.keys(value);
262
+ const ref = (value as { $ref: unknown }).$ref;
263
+ if (keys.length !== 1) {
264
+ ctx.addIssue({ code: "custom", path, message: "a `$ref` object holds the key `$ref` alone" });
265
+ } else if (typeof ref !== "string") {
266
+ ctx.addIssue({ code: "custom", path: [...path, "$ref"], message: "a `$ref` is a reference path, as a string" });
267
+ } else {
268
+ const fault = referenceFault(ref);
269
+ if (fault) ctx.addIssue({ code: "custom", path: [...path, "$ref"], message: fault });
270
+ }
271
+ return;
272
+ }
273
+ for (const [k, v] of Object.entries(value)) referenceIssues(v, ctx, [...path, k]);
274
+ }
275
+ }
276
+
277
+ /** The deepest a Chart's JSON may nest (objects and lists): zod and the canonical form recurse. */
278
+ const MAX_JSON_DEPTH = 64;
279
+
280
+ /**
281
+ * The faults of a Chart's raw JSON that zod cannot report: nesting deeper than
282
+ * {@link MAX_JSON_DEPTH} (zod's walk would overflow the stack) and a key that reaches the
283
+ * prototype (`__proto__`, which zod drops with no message, `constructor` and `prototype`). One
284
+ * walk with an explicit stack, so no text can overflow it; at most a few lines.
285
+ */
286
+ export function jsonFaults(value: unknown): Array<{ path: PropertyKey[]; message: string }> {
287
+ const out: Array<{ path: PropertyKey[]; message: string }> = [];
288
+ const stack: Array<{ v: unknown; path: PropertyKey[] }> = [{ v: value, path: [] }];
289
+ while (stack.length && out.length < 8) {
290
+ const { v, path } = stack.pop()!;
291
+ if (typeof v !== "object" || v === null) continue;
292
+ if (path.length >= MAX_JSON_DEPTH) {
293
+ out.push({ path, message: `the text nests deeper than ${MAX_JSON_DEPTH} objects and lists here` });
294
+ continue;
295
+ }
296
+ if (Array.isArray(v)) {
297
+ v.forEach((x, i) => stack.push({ v: x, path: [...path, i] }));
298
+ continue;
299
+ }
300
+ for (const key of Object.keys(v)) {
301
+ if (FORBIDDEN_SEGMENTS.has(key)) {
302
+ out.push({ path: [...path, key], message: `a key named "${key}" is refused anywhere in a Chart` });
303
+ continue;
304
+ }
305
+ stack.push({ v: (v as Record<string, unknown>)[key], path: [...path, key] });
306
+ }
307
+ }
308
+ return out;
309
+ }
310
+
311
+ /**
312
+ * The prefix of the grammar's JSON Schema ids (`$defs` keys): a schema that holds the grammar
313
+ * beside one of the author's own never meets a duplicate id.
314
+ */
315
+ const ID_PREFIX = "jr2.";
316
+
317
+ /**
318
+ * The grammar's worked fragment (ADR-0070): one Agent state and the pick that ends its Turn, with
319
+ * the body's first lent Agent as the name. It is the one shape every Agent state must have to run,
320
+ * and nothing of a Chart's path. A grammar with no lent Agent shows nothing: there is no real name.
321
+ */
322
+ function fragmentOf(narrow?: Narrow): { state: unknown; events: unknown } | undefined {
323
+ const agent = narrow?.lentAgents[0];
324
+ if (agent === undefined) return undefined;
325
+ return {
326
+ state: {
327
+ invoke: { src: agent, input: { prompt: "Do this step." } },
328
+ on: { finished: { target: "next", actions: [{ type: "set", path: "result", from: "{{event.result}}" }] } },
329
+ },
330
+ events: {
331
+ finished: { input: { type: "object", properties: { result: { type: "string" } }, required: ["result"] } },
332
+ },
333
+ };
334
+ }
335
+
336
+ function grammar(narrow?: Narrow): z.ZodType<ChartText> {
337
+ const fragment = fragmentOf(narrow);
338
+ const name = (what: string) => z.string().regex(NAME, `a ${what} is letters, digits, "_" and "-" only`);
339
+ const template = z
340
+ .json()
341
+ .superRefine((v, ctx) => referenceIssues(v, ctx, []))
342
+ .describe(
343
+ 'JSON. A string may hold placeholders like "{{context.ticket.title}}", and an object { "$ref": "event.notes" } ' +
344
+ "is replaced by the value. The roots are context, event and input; a reference is a path, never an expression.",
345
+ );
346
+ // Plain data: a `{{` or a `$ref` in it is never resolved, as in any value a reference inserts.
347
+ const plain = z.json();
348
+ const contextPath = z
349
+ .string()
350
+ .superRefine((p, ctx) => {
351
+ const fault = pathFault(p);
352
+ if (fault) ctx.addIssue({ code: "custom", message: fault });
353
+ })
354
+ .describe('A context path without the "context." root, like "round" or "ticket.notes".');
355
+
356
+ // `jr2.sentBy` is the kit's routing guard (ADR-0015), and the one guard that is never lent.
357
+ const sentByArm = z.strictObject({ type: z.literal(SENT_BY), params: z.strictObject({ id: z.string() }) });
358
+ const lentGuardName = narrow
359
+ ? enumOf(narrow.guards)
360
+ : z.string().refine((t) => t !== SENT_BY, { message: `"${SENT_BY}" takes params { id }` });
361
+ const guard: z.ZodType<ChartGuard> = z
362
+ .union([
363
+ lentGuardName,
364
+ sentByArm,
365
+ z.strictObject({ type: lentGuardName, params: template.optional() }),
366
+ z.strictObject({
367
+ get not() {
368
+ return guard;
369
+ },
370
+ }),
371
+ z.strictObject({
372
+ get and() {
373
+ return z.array(guard).min(1);
374
+ },
375
+ }),
376
+ z.strictObject({
377
+ get or() {
378
+ return z.array(guard).min(1);
379
+ },
380
+ }),
381
+ ])
382
+ .meta({
383
+ id: `${ID_PREFIX}ChartGuard`,
384
+ description: "A lent guard by name, `jr2.sentBy`, or not/and/or of guards.",
385
+ }) as never;
386
+
387
+ const lentActionName = narrow
388
+ ? enumOf(narrow.actions)
389
+ : z.string().refine((t) => !(GENERIC_ACTIONS as readonly string[]).includes(t), {
390
+ message: "a generic action takes its own fields",
391
+ });
392
+ const action: z.ZodType<ChartAction> = z
393
+ .union([
394
+ z.strictObject({ type: z.literal("set"), path: contextPath, from: template }),
395
+ z.strictObject({ type: z.literal("append"), path: contextPath, from: template }),
396
+ z.strictObject({ type: z.literal("increment"), path: contextPath, by: z.number().int().optional() }),
397
+ z.strictObject({ type: z.literal("clear"), path: contextPath }),
398
+ z.strictObject({ type: lentActionName, params: template.optional() }),
399
+ ])
400
+ .meta({
401
+ id: `${ID_PREFIX}ChartAction`,
402
+ description: "A generic action (set, append, increment, clear) or a lent one.",
403
+ }) as never;
404
+
405
+ const target = z.string().min(1);
406
+ const transition: z.ZodType<ChartTransition> = z
407
+ .union([
408
+ target,
409
+ z.strictObject({
410
+ target: z.union([target, z.array(target).min(1)]).optional(),
411
+ guard: guard.optional(),
412
+ actions: z.array(action).optional(),
413
+ reenter: z.boolean().optional(),
414
+ description: z.string().optional(),
415
+ }),
416
+ ])
417
+ .meta({
418
+ id: `${ID_PREFIX}ChartTransition`,
419
+ description: 'A target ("sibling", ".child", "#<chart id>.a.b"), or { target, guard, actions }.',
420
+ }) as never;
421
+ const transitions = z.union([transition, z.array(transition)]);
422
+
423
+ const src = narrow
424
+ ? z.union([enumOf(narrow.srcs), z.string().describe("A minted Agent: a key of this Chart's own `agents`.")])
425
+ : z.string();
426
+ const invoke = z
427
+ .strictObject({
428
+ src: src.describe(
429
+ "A lent Agent, Effect or Machine by its name; the kit's `gate` (waits for an outside answer: a human, a " +
430
+ "webhook) where the body lends it; or a key of this Chart's `agents`.",
431
+ ),
432
+ id: name("invoke id").optional(),
433
+ input: template.optional().describe("An Agent takes `{ prompt }`; an Effect or a Machine takes its own input."),
434
+ onDone: transitions
435
+ .optional()
436
+ .describe(
437
+ "For an Effect or a Machine that settles. Never for an Agent: its Turn ends by a pick, handled in `on`.",
438
+ ),
439
+ onError: transitions.optional(),
440
+ })
441
+ .meta({ id: `${ID_PREFIX}ChartInvoke` });
442
+
443
+ const delayKey = narrow
444
+ ? z.union([
445
+ enumOf(narrow.delays),
446
+ z
447
+ .string()
448
+ .regex(/^(0|[1-9][0-9]{0,9})$/)
449
+ .describe(`an integer of ms, at most ${MAX_TIMER_MS} (a longer timer fires at once) and the wall clock`),
450
+ ])
451
+ : z
452
+ .string()
453
+ .regex(/^(?:[A-Za-z_][A-Za-z0-9_-]*|0|[1-9][0-9]*)$/, "a delay is a lent delay name or an integer of ms");
454
+
455
+ const state: z.ZodType<ChartState> = z
456
+ .strictObject({
457
+ type: z.enum(["atomic", "compound", "parallel", "final", "history"]).optional(),
458
+ initial: z.string().optional(),
459
+ get states() {
460
+ return z.record(name("state key"), state).optional();
461
+ },
462
+ history: z.enum(["shallow", "deep"]).optional(),
463
+ invoke: z.union([invoke, z.array(invoke)]).optional(),
464
+ on: z.record(z.string(), transitions).optional(),
465
+ always: transitions.optional(),
466
+ after: z.record(delayKey, transitions).optional(),
467
+ entry: z.array(action).optional(),
468
+ exit: z.array(action).optional(),
469
+ output: template.optional(),
470
+ meta: z.json().optional(),
471
+ tags: z.array(z.string()).optional(),
472
+ description: z.string().optional(),
473
+ })
474
+ .meta({
475
+ id: `${ID_PREFIX}ChartState`,
476
+ description: "A state. One that runs an Agent handles, in `on`, the pick that ends its Turn.",
477
+ // The one shape every Agent state must have to run, with a real lent name, and nothing of
478
+ // the path: not how many states, not their order, not the prompts (ADR-0070, measured).
479
+ ...(fragment ? { examples: [fragment.state] } : {}),
480
+ }) as never;
481
+
482
+ const event = z.strictObject({
483
+ description: z.string().optional(),
484
+ input: z
485
+ .looseObject({ type: z.literal("object") })
486
+ .describe(
487
+ "The pick's payload, as a JSON Schema of type object: `properties` are its fields, read as `event.<field>`, " +
488
+ "and a field not in `required` may be absent.",
489
+ ),
490
+ audience: z.enum(["agent", "external", "any"]).optional(),
491
+ });
492
+ const agentDef = z.strictObject({
493
+ model: narrow ? enumOf(narrow.models) : z.string().min(1),
494
+ instructions: z.string(),
495
+ description: z.string().optional(),
496
+ thinkingLevel: z.enum(THINKING as [ThinkingLevel, ...ThinkingLevel[]]).optional(),
497
+ workspace: (narrow ? enumOf(narrow.workspace) : z.enum(["write", "read", "none"])).optional(),
498
+ });
499
+
500
+ return z
501
+ .strictObject({
502
+ version: z.literal(1).optional(),
503
+ id: name("machine id").optional(),
504
+ context: z.record(z.string(), plain).optional().describe("Initial context: plain data, no references."),
505
+ events: z
506
+ .record(name("event name"), event)
507
+ .optional()
508
+ .meta({
509
+ description: "Every pick an Agent of this Chart may make, by name; a state handles it in `on`.",
510
+ ...(fragment ? { examples: [fragment.events] } : {}),
511
+ }),
512
+ // A body that allows no model mints nothing, so its grammar has no `agents` at all: a planner
513
+ // reads a field it cannot fill as one to fill (measured: eight of ten first Charts minted).
514
+ ...(narrow !== undefined && narrow.models.length === 0
515
+ ? {}
516
+ : {
517
+ agents: z
518
+ .record(name("slot key"), agentDef)
519
+ .optional()
520
+ .describe("The Agents this Chart mints. A lent Agent is not listed here: it is invoked by its name."),
521
+ }),
522
+ initial: z.string(),
523
+ states: z.record(name("state key"), state),
524
+ output: template.optional().describe("Resolved from `context` when the Chart reaches a final state."),
525
+ })
526
+ .describe(chartBriefing(narrow)) as unknown as z.ZodType<ChartText>;
527
+ }
528
+
529
+ /**
530
+ * The Chart grammar (ADR-0070), as zod. A door field or a pick param that IS this schema is a
531
+ * Chart field: `isChartSchema` tells, and jr2 reads a Chart wrapper's one-shot door off it.
532
+ */
533
+ export const chartSchema: z.ZodType<ChartText> = guarded(grammar());
534
+
535
+ /** The defs of every Chart grammar: {@link chartSchema} and each {@link chartSchemaFor}. */
536
+ const chartSchemaDefs = new WeakSet<object>([(chartSchema as unknown as { _zod: { def: object } })._zod.def]);
537
+ /** The briefing of every Chart grammar, by def: the text the surface marks a Chart pick with. */
538
+ const chartBriefings = new WeakMap<object, string>([
539
+ [(chartSchema as unknown as { _zod: { def: object } })._zod.def, CHART_BRIEFING],
540
+ ]);
541
+
542
+ /** A grammar behind the raw-JSON check, which runs before zod's own walk. */
543
+ function guarded(inner: z.ZodType<ChartText>, narrow?: Narrow): z.ZodType<ChartText> {
544
+ return z.preprocess((value, ctx) => {
545
+ // Before zod's own walk, which recurses: a text too deep for it is a report line, never a throw.
546
+ for (const fault of jsonFaults(value)) {
547
+ ctx.addIssue({ code: "custom", path: fault.path, message: fault.message, input: value });
548
+ }
549
+ // The grammar has no `agents` under a body that mints nothing: the line says why, where the
550
+ // strict object would say "unrecognized key".
551
+ if (narrow?.models.length === 0 && typeof value === "object" && value !== null && "agents" in value) {
552
+ const lent = narrow.lentAgents.length ? narrow.lentAgents.join(", ") : "none";
553
+ ctx.addIssue({
554
+ code: "custom",
555
+ path: ["agents"],
556
+ message: `this body allows no model, so a Chart under it mints no Agent; its Agents are the lent ones: ${lent}`,
557
+ input: value,
558
+ });
559
+ }
560
+ return value;
561
+ }, inner) as unknown as z.ZodType<ChartText>;
562
+ }
563
+
564
+ /** Is this schema a Chart grammar, under `.optional()`, `.nullable()`, `.default()` or a description? */
565
+ export function isChartSchema(schema: unknown): boolean {
566
+ return chartBriefingOf(schema) !== undefined;
567
+ }
568
+
569
+ /** The Chart briefing of a Chart field's grammar, or undefined for a schema that is no Chart grammar. */
570
+ export function chartBriefingOf(schema: unknown): string | undefined {
571
+ let s = schema as { _zod?: { def?: { innerType?: unknown } } } | undefined;
572
+ for (let i = 0; i < 8 && s?._zod?.def; i++) {
573
+ if (chartSchemaDefs.has(s._zod.def)) return chartBriefings.get(s._zod.def);
574
+ s = s._zod.def.innerType as typeof s;
575
+ }
576
+ return undefined;
577
+ }
578
+
579
+ const narrowedSchemas = new WeakMap<OpenBodyDeclaration, z.ZodType<ChartText>>();
580
+
581
+ /**
582
+ * The Chart grammar as zod, narrowed to one Open body (ADR-0070): the lent `src` names, guard,
583
+ * action and delay names, and the allowed models and Workspace accesses are enums. A planner's
584
+ * pick whose field is this schema gets the narrowed grammar as its tool parameters, so the model
585
+ * is constrained when it writes the Chart; a Chart wrapper's door may take it too. Either way it
586
+ * is a Chart field (`isChartSchema`), and the door still admits the Chart against the lent set the
587
+ * body has at its start. One schema per declaration.
588
+ */
589
+ export function chartSchemaFor(decl: OpenBodyDeclaration): z.ZodType<ChartText> {
590
+ const hit = narrowedSchemas.get(decl);
591
+ if (hit) return hit;
592
+ const narrow = narrowOf(decl);
593
+ const schema = guarded(grammar(narrow), narrow);
594
+ chartSchemaDefs.add((schema as unknown as { _zod: { def: object } })._zod.def);
595
+ chartBriefings.set((schema as unknown as { _zod: { def: object } })._zod.def, chartBriefing(narrow));
596
+ narrowedSchemas.set(decl, schema);
597
+ return schema;
598
+ }
599
+
600
+ /** The names an Open body lends, in each kind, as lists. */
601
+ function lendsOf(decl: OpenBodyDeclaration) {
602
+ return {
603
+ agents: decl.lends.agents ?? [],
604
+ effects: decl.lends.effects ?? [],
605
+ machines: decl.lends.machines ?? [],
606
+ guards: decl.lends.guards ?? [],
607
+ actions: decl.lends.actions ?? [],
608
+ delays: decl.lends.delays ?? [],
609
+ gate: decl.lends.gate === true,
610
+ };
611
+ }
612
+
613
+ /**
614
+ * The Chart grammar as JSON Schema, narrowed to one Open body (ADR-0070): `GET
615
+ * /workflows/<wrapper>/chart-schema`. The lent `src` names, guard, action and delay names, and the
616
+ * allowed models and Workspace accesses are `enum`s, so an editor completes them and a planner's
617
+ * pick is constrained when the model writes it.
618
+ */
619
+ export function narrowedChartSchema(decl: OpenBodyDeclaration): Record<string, unknown> {
620
+ return z.toJSONSchema(grammar(narrowOf(decl)), { unrepresentable: "any" }) as Record<string, unknown>;
621
+ }
622
+
623
+ /** What one Open body's declaration fixes as enums in the grammar. */
624
+ function narrowOf(decl: OpenBodyDeclaration): Narrow {
625
+ const l = lendsOf(decl);
626
+ return {
627
+ srcs: [...l.agents, ...l.effects, ...l.machines, ...(l.gate ? ["gate"] : [])],
628
+ lentAgents: l.agents,
629
+ gate: l.gate,
630
+ guards: l.guards,
631
+ actions: l.actions,
632
+ delays: l.delays,
633
+ models: [...decl.allows.models],
634
+ workspace: [...decl.allows.workspace],
635
+ };
636
+ }
637
+
638
+ // --- Canonical form and hash ----------------------------------------------------------------------
639
+
640
+ /** The canonical JSON of a value: object keys sorted, no whitespace. Undefined object values are
641
+ * dropped, as `JSON.stringify` drops them. */
642
+ export function canonicalJson(value: unknown): string {
643
+ const seen = new Set<object>();
644
+ const walk = (v: unknown): string => {
645
+ if (v === null || typeof v === "boolean" || typeof v === "string") return JSON.stringify(v);
646
+ if (typeof v === "number") {
647
+ if (!Number.isFinite(v)) throw new Error(`${v} is not JSON`);
648
+ return JSON.stringify(v);
649
+ }
650
+ if (typeof v !== "object") throw new Error(`a ${typeof v} is not JSON`);
651
+ if (seen.has(v)) throw new Error("a cycle is not JSON");
652
+ seen.add(v);
653
+ const out = Array.isArray(v)
654
+ ? `[${v.map((x) => (x === undefined ? "null" : walk(x))).join(",")}]`
655
+ : `{${Object.keys(v)
656
+ .filter((k) => (v as Record<string, unknown>)[k] !== undefined)
657
+ .sort()
658
+ .map((k) => `${JSON.stringify(k)}:${walk((v as Record<string, unknown>)[k])}`)
659
+ .join(",")}}`;
660
+ seen.delete(v);
661
+ return out;
662
+ };
663
+ return walk(value);
664
+ }
665
+
666
+ /** A Chart's identity (ADR-0070, D-B): sha256 of its canonical JSON, 12 hex. Key order does not count. */
667
+ export function chartHash(text: ChartText): string {
668
+ return chartDigest(text).slice(0, 12);
669
+ }
670
+
671
+ /** The full sha256 of a Chart's canonical JSON, 64 hex: a key no one can collide by search, as
672
+ * the compile memo needs. {@link chartHash} is its first 12 hex, for display and the stamp. */
673
+ export function chartDigest(text: ChartText): string {
674
+ return createHash("sha256").update(canonicalJson(text)).digest("hex");
675
+ }
676
+
677
+ // --- References -----------------------------------------------------------------------------------
678
+
679
+ /** Every reference a value makes, as written (`context.round`), in the order met. */
680
+ export function referencesOf(value: unknown): string[] {
681
+ const out: string[] = [];
682
+ const walk = (v: unknown): void => {
683
+ if (typeof v === "string") {
684
+ for (const m of v.matchAll(PLACEHOLDER)) out.push(m[1]!.trim());
685
+ } else if (Array.isArray(v)) v.forEach(walk);
686
+ else if (typeof v === "object" && v !== null) {
687
+ if ("$ref" in v && typeof (v as { $ref: unknown }).$ref === "string" && Object.keys(v).length === 1) {
688
+ out.push((v as { $ref: string }).$ref);
689
+ return;
690
+ }
691
+ Object.values(v).forEach(walk);
692
+ }
693
+ };
694
+ walk(value);
695
+ return out;
696
+ }
697
+
698
+ /** The value at a reference, walking own properties only. A missing path throws: the door made
699
+ * it impossible, so a throw here is a defect. */
700
+ function lookup(ref: string, scope: { context: unknown; event: unknown; input: unknown }): unknown {
701
+ const [root, ...rest] = pathSegments(ref);
702
+ if (!(ROOTS as readonly unknown[]).includes(root)) throw new Error(`Chart reference "${ref}": no root "${root}"`);
703
+ let v: unknown = scope[root as Root];
704
+ for (const seg of rest) {
705
+ if (typeof v !== "object" || v === null || !Object.hasOwn(v, seg) || FORBIDDEN_SEGMENTS.has(String(seg))) {
706
+ throw new Error(`Chart reference "${ref}" resolves to nothing (at "${seg}")`);
707
+ }
708
+ v = (v as Record<string | number, unknown>)[seg];
709
+ }
710
+ if (v === undefined) throw new Error(`Chart reference "${ref}" resolves to nothing`);
711
+ return v;
712
+ }
713
+
714
+ /**
715
+ * Resolve a value's references against the run's `{ context, event, input }` (ADR-0070). A path walk and
716
+ * string interpolation only: a string that is exactly one placeholder yields the value itself (so
717
+ * an object can be passed); any other string interpolates each value (a string as itself, an
718
+ * object or a list as its JSON, anything else through `String`). `{{` inside DATA is never
719
+ * resolved: the walk reads only the value's own strings, and an inserted value is never read
720
+ * again.
721
+ */
722
+ export function resolveReferences(
723
+ value: unknown,
724
+ scope: { context: unknown; event: unknown; input: unknown },
725
+ ): unknown {
726
+ if (typeof value === "string") {
727
+ const whole = WHOLE_PLACEHOLDER.exec(value);
728
+ if (whole) return lookup(whole[1]!.trim(), scope);
729
+ return value.replace(PLACEHOLDER, (_m, ref: string) => {
730
+ const v = lookup(ref.trim(), scope);
731
+ return typeof v === "string" ? v : typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
732
+ });
733
+ }
734
+ if (Array.isArray(value)) return value.map((v) => resolveReferences(v, scope));
735
+ if (typeof value === "object" && value !== null) {
736
+ const ref = (value as { $ref?: unknown }).$ref;
737
+ if (typeof ref === "string" && Object.keys(value).length === 1) return lookup(ref, scope);
738
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveReferences(v, scope)]));
739
+ }
740
+ return value;
741
+ }
742
+
743
+ /**
744
+ * Resolve the source of a generic `set` or `append`: undefined when it is one reference whose
745
+ * path is absent (an optional field the door admitted there), so the write writes nothing and the
746
+ * path keeps what it had; else what {@link resolveReferences} gives.
747
+ */
748
+ export function resolveSource(
749
+ value: unknown,
750
+ scope: { context: unknown; event: unknown; input: unknown },
751
+ ): { value: unknown } | undefined {
752
+ const ref = wholeRefOf(value);
753
+ if (ref !== undefined) {
754
+ try {
755
+ return { value: lookup(ref, scope) };
756
+ } catch {
757
+ return undefined;
758
+ }
759
+ }
760
+ return { value: resolveReferences(value, scope) };
761
+ }
762
+
763
+ // --- The door -------------------------------------------------------------------------------------
764
+
765
+ /** The door's refusal: one line per fault, each starting with the path in the Chart. */
766
+ export class ChartRefusal extends Error {
767
+ readonly report: string[];
768
+ constructor(report: string[]) {
769
+ super(`Chart refused:\n${report.map((line) => `- ${line}`).join("\n")}`);
770
+ this.name = "ChartRefusal";
771
+ this.report = report;
772
+ }
773
+ }
774
+
775
+ /** A Chart the door admitted: its text and hash, its minted events and Agents, its size. */
776
+ export type AdmittedChart = {
777
+ text: ChartText;
778
+ hash: string;
779
+ events: EventDef[];
780
+ agents: Record<string, AgentDefinition>;
781
+ /** The bytes of the text's canonical JSON. */
782
+ size: number;
783
+ };
784
+
785
+ /** What the door knows beyond the declaration and the lent set. */
786
+ export type AdmitOptions = {
787
+ /**
788
+ * The keys the Chart's `input` has: the body invoke's input minus its Chart fields (the
789
+ * Chart wrapper's door schema minus its Chart fields, or the live input at start). Given, an
790
+ * `input.<key>` reference is checked against it; absent, `input` references are not checked.
791
+ */
792
+ inputKeys?: readonly string[];
793
+ };
794
+
795
+ const MAX_STATES = 200;
796
+ /** The longest delay a timer holds: Node clamps a longer `setTimeout` to 1 ms. */
797
+ const MAX_TIMER_MS = 2_147_483_647;
798
+ const MAX_NESTING = 8;
799
+
800
+ type Line = { rule: number; line: string };
801
+
802
+ /** The `{ states }` tree of an unknown value: its state count and its deepest nesting. */
803
+ function measure(text: unknown): { states: number; depth: number } {
804
+ let states = 0;
805
+ let depth = 0;
806
+ const walk = (node: unknown, d: number): void => {
807
+ const children = (node as { states?: unknown } | null)?.states;
808
+ if (typeof children !== "object" || children === null || Array.isArray(children)) return;
809
+ for (const child of Object.values(children)) {
810
+ states++;
811
+ depth = Math.max(depth, d + 1);
812
+ if (d + 1 <= MAX_NESTING + 1) walk(child, d + 1);
813
+ }
814
+ };
815
+ walk(text, 0);
816
+ return { states, depth };
817
+ }
818
+
819
+ /** A zod issue tree as report lines. A union failure reports the arm the value came closest to. */
820
+ function issueLines(
821
+ issues: readonly z.core.$ZodIssue[],
822
+ base: PropertyKey[] = [],
823
+ ): Array<{ path: PropertyKey[]; message: string; code: string }> {
824
+ const out: Array<{ path: PropertyKey[]; message: string; code: string }> = [];
825
+ for (const issue of issues) {
826
+ const path = [...base, ...issue.path];
827
+ if (issue.code === "invalid_union" && issue.errors.length) {
828
+ const arms = issue.errors.map((arm) => issueLines(arm, path));
829
+ const rootTypes = arms.every(
830
+ (a) => a.length === 1 && a[0]!.code === "invalid_type" && a[0]!.path.length === path.length,
831
+ );
832
+ if (rootTypes) {
833
+ const expected = [...new Set(issue.errors.map((arm) => String((arm[0] as { expected?: unknown }).expected)))];
834
+ const found = /received (\w+)/.exec(arms[0]![0]!.message)?.[1];
835
+ out.push({
836
+ path,
837
+ code: "invalid_type",
838
+ message: `expected ${expected.join(" or ")}${found ? `, found ${found}` : ""}`,
839
+ });
840
+ continue;
841
+ }
842
+ const score = (arm: typeof out): number =>
843
+ arm.reduce((s, l) => {
844
+ if (l.path.length === path.length && l.code === "invalid_type") return s + 100;
845
+ if (l.path.length === path.length + 1 && l.path[l.path.length - 1] === "type") return s + 50;
846
+ return s + 1;
847
+ }, 0);
848
+ const best = arms.reduce((a, b) => (score(b) < score(a) ? b : a));
849
+ out.push(...best);
850
+ continue;
851
+ }
852
+ if (issue.code === "unrecognized_keys") {
853
+ for (const key of issue.keys) {
854
+ out.push({ path: [...path, key], code: issue.code, message: "this key is not in the Chart grammar here" });
855
+ }
856
+ continue;
857
+ }
858
+ if (issue.code === "invalid_key") {
859
+ const inner = issue.issues.map((i) => i.message).join("; ");
860
+ out.push({ path, code: issue.code, message: `the key is refused: ${inner}` });
861
+ continue;
862
+ }
863
+ out.push({ path, code: issue.code, message: issue.message });
864
+ }
865
+ return out;
866
+ }
867
+
868
+ const pathText = (path: readonly PropertyKey[]): string => (path.length ? path.map(String).join(".") : "(chart)");
869
+
870
+ /** One state of the Chart, indexed for the checks. */
871
+ type Node = {
872
+ key: string;
873
+ /** The doc path: `states.review.states.wait`; the root's is "". */
874
+ doc: string;
875
+ state: ChartState;
876
+ kind: NonNullable<ChartState["type"]>;
877
+ parent?: Node;
878
+ children: Map<string, Node>;
879
+ };
880
+
881
+ /** Where a reference may look for `event`. */
882
+ type EventScope =
883
+ | { kind: "none"; why: string }
884
+ | { kind: "event"; name: string }
885
+ | { kind: "mechanism"; name: string }
886
+ | { kind: "done" }
887
+ | { kind: "error" }
888
+ /** The event was refused: its `event` references are not checked again. */
889
+ | { kind: "skip" };
890
+
891
+ const at = (doc: string, rest: string): string => (doc ? `${doc}.${rest}` : rest);
892
+ const list = <T>(v: T | T[] | undefined): T[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
893
+ const listAt = <T>(v: T | T[] | undefined, path: string): Array<[T, string]> =>
894
+ v === undefined ? [] : Array.isArray(v) ? v.map((x, i) => [x, `${path}.${i}`]) : [[v, path]];
895
+ const names = (xs: readonly string[]): string => (xs.length ? xs.join(", ") : "none");
896
+
897
+ /**
898
+ * Admit a Chart against an Open body's declaration and the lent set it has now (ADR-0070). Returns
899
+ * the admitted Chart, or throws {@link ChartRefusal} with every fault, rule by rule.
900
+ */
901
+ export function admitChart(
902
+ text: unknown,
903
+ decl: OpenBodyDeclaration,
904
+ lent: LentParts,
905
+ options: AdmitOptions = {},
906
+ ): AdmittedChart {
907
+ // 1. Size: the JSON's nesting and keys, the canonical JSON, the state count, the state nesting.
908
+ const shape = jsonFaults(text);
909
+ if (shape.length) throw new ChartRefusal(shape.map((f) => `${pathText(f.path)}: ${f.message}`));
910
+ let canonical: string;
911
+ try {
912
+ canonical = canonicalJson(text);
913
+ } catch (err) {
914
+ throw new ChartRefusal([`(chart): the text is not JSON: ${(err as Error).message}`]);
915
+ }
916
+ const size = Buffer.byteLength(canonical, "utf8");
917
+ const sized: string[] = [];
918
+ if (size > decl.ceilings.size) {
919
+ sized.push(`(chart): the text is ${size} bytes; this Open body's ceiling is ${decl.ceilings.size}`);
920
+ }
921
+ const { states: stateCount, depth } = measure(text);
922
+ if (stateCount > MAX_STATES) sized.push(`states: the Chart has ${stateCount} states; at most ${MAX_STATES}`);
923
+ if (depth > MAX_NESTING) sized.push(`states: the Chart nests states ${depth} deep; at most ${MAX_NESTING}`);
924
+ if (sized.length) throw new ChartRefusal(sized);
925
+
926
+ // 2. Grammar.
927
+ const parsed = chartSchema.safeParse(text);
928
+ if (!parsed.success) {
929
+ throw new ChartRefusal(issueLines(parsed.error.issues).map((l) => `${pathText(l.path)}: ${l.message}`));
930
+ }
931
+ const chart = parsed.data;
932
+ return new Door(chart, decl, lent, options).admit(size);
933
+ }
934
+
935
+ /** The checks from rule 3 on, over a Chart the grammar admitted. */
936
+ class Door {
937
+ private readonly lines: Line[] = [];
938
+ private readonly lends: ReturnType<typeof lendsOf>;
939
+ private readonly root: Node;
940
+ private readonly nodes: Node[] = [];
941
+ /** The minted events' JSON Schemas, by name. */
942
+ private readonly eventSchemas = new Map<string, Record<string, unknown>>();
943
+ private readonly handled = new Set<string>();
944
+ /** Every context path a generic `set`, `append` or `increment` writes, with the event its
945
+ * transition handles. */
946
+ private readonly written: Array<{
947
+ path: Array<string | number>;
948
+ kind: "set" | "append" | "increment";
949
+ from?: ChartValue;
950
+ scope: EventScope;
951
+ /** The action's path in the Chart, for a line that names the write. */
952
+ at: string;
953
+ }> = [];
954
+ /** Every reference, checked once the walk has seen every write (rule 7). */
955
+ private readonly refs: Array<{ ref: string; path: string; scope: EventScope; optionalOk: boolean }> = [];
956
+ /** Every `always` edge, for the cycle check. */
957
+ private readonly alwaysEdges: Array<{
958
+ from: Node;
959
+ targets: Node[];
960
+ guarded: boolean;
961
+ reenter: boolean;
962
+ doc: string;
963
+ }> = [];
964
+ /** What each invoke's `src` names, once rule 3 resolved it. */
965
+ private readonly invokeKinds = new Map<ChartInvoke, NonNullable<ReturnType<Door["srcKind"]>>>();
966
+ /** Every `jr2.sentBy` id, for the party check. */
967
+ private readonly sentBys: Array<{ id: string; path: string }> = [];
968
+
969
+ private readonly chart: ChartText;
970
+ private readonly decl: OpenBodyDeclaration;
971
+ private readonly lent: LentParts;
972
+ private readonly options: AdmitOptions;
973
+
974
+ constructor(chart: ChartText, decl: OpenBodyDeclaration, lent: LentParts, options: AdmitOptions) {
975
+ this.chart = chart;
976
+ this.decl = decl;
977
+ this.lent = lent;
978
+ this.options = options;
979
+ this.lends = lendsOf(decl);
980
+ this.root = this.index("(root)", "", { initial: chart.initial, states: chart.states }, undefined, "compound");
981
+ for (const [name, ev] of Object.entries(chart.events ?? {})) this.eventSchemas.set(name, ev.input);
982
+ }
983
+
984
+ private fail(rule: number, line: string): void {
985
+ this.lines.push({ rule, line });
986
+ }
987
+
988
+ private index(key: string, doc: string, state: ChartState, parent: Node | undefined, kind?: Node["kind"]): Node {
989
+ const node: Node = {
990
+ key,
991
+ doc,
992
+ state,
993
+ kind: kind ?? state.type ?? (state.states && Object.keys(state.states).length ? "compound" : "atomic"),
994
+ parent,
995
+ children: new Map(),
996
+ };
997
+ if (parent) this.nodes.push(node);
998
+ for (const [k, child] of Object.entries(state.states ?? {})) {
999
+ node.children.set(k, this.index(k, at(doc, `states.${k}`), child, node));
1000
+ }
1001
+ return node;
1002
+ }
1003
+
1004
+ admit(size: number): AdmittedChart {
1005
+ this.checkEvents();
1006
+ this.checkAgents();
1007
+ if (!this.root.children.has(this.chart.initial)) {
1008
+ this.fail(
1009
+ 6,
1010
+ `initial: "${this.chart.initial}" is not a state here; states: ${names([...this.root.children.keys()])}`,
1011
+ );
1012
+ }
1013
+ for (const node of this.nodes) this.checkState(node);
1014
+ this.checkUnhandled();
1015
+ this.checkAlwaysCycles();
1016
+ this.checkParties();
1017
+ this.checkOutput(this.chart.output, "output", {
1018
+ kind: "none",
1019
+ why: "the Chart's output is resolved when it is done",
1020
+ });
1021
+ this.checkAllRefs();
1022
+
1023
+ if (this.lines.length) {
1024
+ const sorted = this.lines.map((l, i) => ({ ...l, i })).sort((a, b) => a.rule - b.rule || a.i - b.i);
1025
+ throw new ChartRefusal(sorted.map((l) => l.line));
1026
+ }
1027
+ const events = Object.entries(this.chart.events ?? {}).map(([name, ev]) => this.eventDef(name, ev)!);
1028
+ const agents: Record<string, AgentDefinition> = {};
1029
+ for (const [slot, a] of Object.entries(this.chart.agents ?? {})) agents[slot] = { ...a };
1030
+ const admitted: AdmittedChart = { text: this.chart, hash: chartHash(this.chart), events, agents, size };
1031
+ this.trialCompile(admitted);
1032
+ return admitted;
1033
+ }
1034
+
1035
+ /**
1036
+ * The last check: compile the Chart as the Open body will, so `jr2Setup`'s own refusals (ADR-0011,
1037
+ * ADR-0015, ADR-0067) and its load warnings are lines of this report, at every door (a PUT, a
1038
+ * Chart file, a one-shot start) and not first at the body's start. For a Chart a warning is a
1039
+ * refusal: no author reads a planner's Chart at load, so a warning nobody reads is a silent fault.
1040
+ */
1041
+ private trialCompile(admitted: AdmittedChart): void {
1042
+ let lines: string[];
1043
+ try {
1044
+ const machine = compileChart(admitted, this.lent, this.decl);
1045
+ lines = loadWarningsOf(machine);
1046
+ } catch (err) {
1047
+ if (err instanceof ChartRefusal) throw err;
1048
+ lines = [err instanceof Error ? err.message : String(err)];
1049
+ }
1050
+ if (lines.length) throw new ChartRefusal(lines.map((l) => `(chart): ${l.replace(/^machine "[^"]*": /, "")}`));
1051
+ }
1052
+
1053
+ // --- rule 3: names --------------------------------------------------------------------------------
1054
+
1055
+ /** The lent actors by kind, as the lent set has them now. */
1056
+ private lentActors(): Array<{ name: string; kind: "Agent" | "Effect" | "Machine" }> {
1057
+ const out: Array<{ name: string; kind: "Agent" | "Effect" | "Machine" }> = [];
1058
+ for (const name of this.lends.agents) if (name in this.lent.actors) out.push({ name, kind: "Agent" });
1059
+ for (const name of this.lends.effects) if (name in this.lent.actors) out.push({ name, kind: "Effect" });
1060
+ for (const name of this.lends.machines) if (name in this.lent.actors) out.push({ name, kind: "Machine" });
1061
+ return out;
1062
+ }
1063
+
1064
+ private lentText(): string {
1065
+ const actors = this.lentActors().map((a) => `${a.name} (${a.kind})`);
1066
+ const minted = Object.keys(this.chart.agents ?? {});
1067
+ return `lent: ${names(actors)}; kit: ${this.lends.gate ? "gate" : "none"}; minted: ${names(minted)}`;
1068
+ }
1069
+
1070
+ /** What an invoke's `src` names, or undefined after a refusal line. */
1071
+ private srcKind(src: string, path: string): "agent" | "minted" | "effect" | "machine" | "gate" | undefined {
1072
+ const lentNow = (name: string) => name in this.lent.actors;
1073
+ const declared = [...this.lends.agents, ...this.lends.effects, ...this.lends.machines];
1074
+ if (declared.includes(src) && !lentNow(src)) {
1075
+ this.fail(
1076
+ 3,
1077
+ `${path}: "${src}" is lent by the Open body, but the Chart wrapper has no actor slot "${src}" now; ${this.lentText()}`,
1078
+ );
1079
+ return undefined;
1080
+ }
1081
+ if (this.lends.agents.includes(src)) return "agent";
1082
+ if (this.lends.effects.includes(src)) return "effect";
1083
+ if (this.lends.machines.includes(src)) return "machine";
1084
+ if (src === "gate") {
1085
+ if (this.lends.gate) return "gate";
1086
+ this.fail(
1087
+ 3,
1088
+ `${path}: "gate" is the kit's Gate, which waits for an outside answer (a human, a webhook), and this Open body does not lend it (\`lends.gate: true\` would); ${this.lentText()}`,
1089
+ );
1090
+ return undefined;
1091
+ }
1092
+ if (Object.hasOwn(this.chart.agents ?? {}, src)) return "minted";
1093
+ this.fail(3, `${path}: "${src}" is not a lent actor or an Agent of this Chart; ${this.lentText()}`);
1094
+ return undefined;
1095
+ }
1096
+
1097
+ private checkGuard(guard: ChartGuard, path: string, scope: EventScope): void {
1098
+ if (typeof guard === "string") return this.checkGuardName(guard, path);
1099
+ if ("not" in guard) return this.checkGuard(guard.not, `${path}.not`, scope);
1100
+ if ("and" in guard) return guard.and.forEach((g, i) => this.checkGuard(g, `${path}.and.${i}`, scope));
1101
+ if ("or" in guard) return guard.or.forEach((g, i) => this.checkGuard(g, `${path}.or.${i}`, scope));
1102
+ if (guard.type === SENT_BY) {
1103
+ this.sentBys.push({ id: (guard.params as { id: string }).id, path: `${path}.params.id` });
1104
+ return;
1105
+ }
1106
+ this.checkGuardName(guard.type, `${path}.type`);
1107
+ if (guard.params !== undefined) this.checkRefs(guard.params, `${path}.params`, scope);
1108
+ }
1109
+
1110
+ private checkGuardName(name: string, path: string): void {
1111
+ const lent = this.lends.guards.filter((g) => g in this.lent.guards);
1112
+ if (this.lends.guards.includes(name) && !(name in this.lent.guards)) {
1113
+ this.fail(
1114
+ 3,
1115
+ `${path}: guard "${name}" is lent by the Open body, but the Chart wrapper has no guard "${name}" now; lent guards: ${names(lent)}`,
1116
+ );
1117
+ } else if (!this.lends.guards.includes(name)) {
1118
+ this.fail(
1119
+ 3,
1120
+ `${path}: "${name}" is not a lent guard; lent guards: ${names(lent)} (and ${SENT_BY} with params { id })`,
1121
+ );
1122
+ }
1123
+ }
1124
+
1125
+ private checkAction(action: ChartAction, path: string, scope: EventScope): void {
1126
+ const t = action.type;
1127
+ if ((GENERIC_ACTIONS as readonly string[]).includes(t)) {
1128
+ const write = action as { path: string; from?: ChartValue };
1129
+ if (t === "set" || t === "append") this.checkRefs(write.from, `${path}.from`, scope, true);
1130
+ if (t === "set" || t === "append" || t === "increment") {
1131
+ this.written.push({
1132
+ path: pathSegments(write.path),
1133
+ kind: t,
1134
+ ...(write.from === undefined ? {} : { from: write.from }),
1135
+ scope,
1136
+ at: path,
1137
+ });
1138
+ }
1139
+ return;
1140
+ }
1141
+ const lent = this.lends.actions.filter((a) => a in this.lent.actions);
1142
+ if (this.lends.actions.includes(t) && !(t in this.lent.actions)) {
1143
+ this.fail(
1144
+ 3,
1145
+ `${path}.type: action "${t}" is lent by the Open body, but the Chart wrapper has no action "${t}" now; lent actions: ${names(lent)}`,
1146
+ );
1147
+ } else if (!this.lends.actions.includes(t)) {
1148
+ this.fail(
1149
+ 3,
1150
+ `${path}.type: "${t}" is not a generic or a lent action; generic: ${GENERIC_ACTIONS.join(", ")}; lent: ${names(lent)}`,
1151
+ );
1152
+ }
1153
+ const params = (action as { params?: unknown }).params;
1154
+ if (params !== undefined) this.checkRefs(params, `${path}.params`, scope);
1155
+ }
1156
+
1157
+ private checkDelay(key: string, path: string): void {
1158
+ if (/^(0|[1-9][0-9]*)$/.test(key)) {
1159
+ // A timer above the clamp fires at once (Node's setTimeout, which xstate's clock uses), the
1160
+ // same silent fault as an unknown delay; one above the wall clock can never fire.
1161
+ const ms = Number(key);
1162
+ const wall = durationMs(this.decl.ceilings.wallClock);
1163
+ if (ms > MAX_TIMER_MS) {
1164
+ this.fail(3, `${path}: ${key} ms is more than a timer holds (${MAX_TIMER_MS} ms, about 24.8 days)`);
1165
+ } else if (wall !== undefined && ms > wall) {
1166
+ this.fail(
1167
+ 3,
1168
+ `${path}: ${key} ms is more than this Open body's wallClock ceiling (${this.decl.ceilings.wallClock}), so it never fires`,
1169
+ );
1170
+ }
1171
+ return;
1172
+ }
1173
+ const lent = this.lends.delays.filter((d) => d in this.lent.delays);
1174
+ if (this.lends.delays.includes(key) && !(key in this.lent.delays)) {
1175
+ this.fail(
1176
+ 3,
1177
+ `${path}: delay "${key}" is lent by the Open body, but the Chart wrapper has no delay "${key}" now; lent delays: ${names(lent)}`,
1178
+ );
1179
+ } else if (!this.lends.delays.includes(key)) {
1180
+ this.fail(3, `${path}: "${key}" is not a lent delay or an integer of ms; lent delays: ${names(lent)}`);
1181
+ }
1182
+ }
1183
+
1184
+ /** An `on` key: a Chart event, a mechanism event, or a refusal. */
1185
+ private onScope(name: string, path: string): EventScope | undefined {
1186
+ if (name === "*") {
1187
+ this.fail(3, `${path}: "*" is refused; handle each event by name`);
1188
+ return undefined;
1189
+ }
1190
+ if (name.startsWith("xstate.done.actor.") || name.startsWith("xstate.error.actor.")) {
1191
+ this.fail(3, `${path}: "${name}" is refused; write the invoke's \`onDone\` or \`onError\``);
1192
+ return undefined;
1193
+ }
1194
+ if (Object.hasOwn(MECHANISM_EVENTS, name)) return { kind: "mechanism", name };
1195
+ if (name.includes(".")) {
1196
+ this.fail(
1197
+ 3,
1198
+ `${path}: "${name}" is not an event a Chart may handle (a dotted name is the kit's); mechanism events: ${Object.keys(MECHANISM_EVENTS).join(", ")}; a pick is a Chart event: declare it in \`events\` under a name with no dot, and handle it here in \`on\``,
1199
+ );
1200
+ return undefined;
1201
+ }
1202
+ this.handled.add(name);
1203
+ if (!this.eventSchemas.has(name)) {
1204
+ this.fail(
1205
+ 4,
1206
+ `${path}: event "${name}" is not declared in the Chart's \`events\`; declared: ${names([...this.eventSchemas.keys()])}; a state handles only the events the Chart declares: add \`events.${name}\` with its \`input\` schema (type object), and an Agent in this state gets it as a pick`,
1207
+ );
1208
+ return undefined;
1209
+ }
1210
+ return { kind: "event", name };
1211
+ }
1212
+
1213
+ // --- rule 4: events -------------------------------------------------------------------------------
1214
+
1215
+ private readonly defs = new Map<string, EventDef | undefined>();
1216
+
1217
+ private eventDef(name: string, ev: ChartEvent): EventDef | undefined {
1218
+ if (this.defs.has(name)) return this.defs.get(name);
1219
+ let def: EventDef | undefined;
1220
+ const path = `events.${name}.input`;
1221
+ const faults = payloadSchemaFaults(ev.input);
1222
+ if (faults.length) {
1223
+ for (const f of faults) this.fail(4, `${pathText([path, ...f.path])}: ${f.message}`);
1224
+ this.defs.set(name, undefined);
1225
+ return undefined;
1226
+ }
1227
+ try {
1228
+ const input = z.fromJSONSchema(ev.input as never);
1229
+ if (!(input instanceof z.ZodObject)) {
1230
+ this.fail(4, `${path}: the payload schema must read as an object schema`);
1231
+ } else {
1232
+ def = defineEvent({
1233
+ name,
1234
+ input,
1235
+ ...(ev.description === undefined ? {} : { description: ev.description }),
1236
+ ...(ev.audience === undefined ? {} : { audience: ev.audience }),
1237
+ });
1238
+ }
1239
+ } catch (err) {
1240
+ const message = (err as Error).message.replace(/^defineEvent: /, "");
1241
+ this.fail(4, `${path}: ${message}`);
1242
+ }
1243
+ this.defs.set(name, def);
1244
+ return def;
1245
+ }
1246
+
1247
+ private checkEvents(): void {
1248
+ for (const [name, ev] of Object.entries(this.chart.events ?? {})) this.eventDef(name, ev);
1249
+ }
1250
+
1251
+ private checkUnhandled(): void {
1252
+ for (const name of this.eventSchemas.keys()) {
1253
+ if (!this.handled.has(name)) {
1254
+ this.fail(
1255
+ 4,
1256
+ `events.${name}: no state handles "${name}"; a pick no state takes is on no Menu and no Gate; handle it in the \`on\` of the state whose Agent should pick it`,
1257
+ );
1258
+ }
1259
+ }
1260
+ }
1261
+
1262
+ // --- rule 5: minted Agents ------------------------------------------------------------------------
1263
+
1264
+ private checkAgents(): void {
1265
+ const lentNames = new Set([...this.lends.agents, ...this.lends.effects, ...this.lends.machines, "gate"]);
1266
+ for (const [slot, a] of Object.entries(this.chart.agents ?? {})) {
1267
+ const path = `agents.${slot}`;
1268
+ if (lentNames.has(slot)) {
1269
+ this.fail(
1270
+ 5,
1271
+ `${path}: "${slot}" is a lent name; a minted Agent takes a slot key of its own; a lent Agent needs no entry in \`agents\`: invoke it as \`src: "${slot}"\` with a \`prompt\``,
1272
+ );
1273
+ }
1274
+ if (!this.decl.allows.models.includes(a.model)) {
1275
+ this.fail(
1276
+ 5,
1277
+ `${path}.model: "${a.model}" is not a model this Open body allows; allowed: ${names(this.decl.allows.models)}`,
1278
+ );
1279
+ }
1280
+ const workspace = a.workspace ?? "write";
1281
+ if (!this.decl.allows.workspace.includes(workspace)) {
1282
+ this.fail(
1283
+ 5,
1284
+ `${path}.workspace: "${workspace}"${a.workspace ? "" : " (the default)"} is not a Workspace access this Open body allows; allowed: ${names(this.decl.allows.workspace)}`,
1285
+ );
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ // --- rule 6: structure ----------------------------------------------------------------------------
1291
+
1292
+ private checkStructure(node: Node): void {
1293
+ const s = node.state;
1294
+ const path = node.doc;
1295
+ const kids = [...node.children.keys()];
1296
+ if (node.kind === "compound") {
1297
+ if (!kids.length) this.fail(6, `${path}: a compound state has child \`states\``);
1298
+ else if (s.initial === undefined)
1299
+ this.fail(6, `${path}.initial: a compound state names its initial child; states: ${names(kids)}`);
1300
+ else if (!node.children.has(s.initial))
1301
+ this.fail(6, `${path}.initial: "${s.initial}" is not a child; states: ${names(kids)}`);
1302
+ } else if (s.initial !== undefined) {
1303
+ this.fail(6, `${path}.initial: a ${node.kind} state has no \`initial\``);
1304
+ }
1305
+ if ((node.kind === "atomic" || node.kind === "final" || node.kind === "history") && kids.length) {
1306
+ this.fail(6, `${path}.states: a ${node.kind} state has no child states`);
1307
+ }
1308
+ if (node.kind === "final") {
1309
+ for (const key of ["on", "invoke", "always", "after"] as const) {
1310
+ if (s[key] !== undefined) this.fail(6, `${path}.${key}: a final state has no \`${key}\``);
1311
+ }
1312
+ // A final state's output is read only as the Chart's own output: a Chart has no state
1313
+ // `onDone`, and the Chart's `output`, when it has one, is what the Chart outputs.
1314
+ if (s.output !== undefined && node.parent !== this.root) {
1315
+ this.fail(
1316
+ 6,
1317
+ `${path}.output: only a final state directly under the Chart's \`states\` has an output that is read; a Chart has no state \`onDone\``,
1318
+ );
1319
+ } else if (s.output !== undefined && this.chart.output !== undefined) {
1320
+ this.fail(
1321
+ 6,
1322
+ `${path}.output: the Chart has its own \`output\`, which is what it outputs; this one is never read: keep one of the two`,
1323
+ );
1324
+ }
1325
+ } else if (s.output !== undefined) {
1326
+ this.fail(6, `${path}.output: only a final state has an \`output\``);
1327
+ }
1328
+ if (s.history !== undefined && node.kind !== "history")
1329
+ this.fail(6, `${path}.history: only a history state has \`history\``);
1330
+ }
1331
+
1332
+ /** Resolve a target as xstate does: `#<id>.a.b` from the root, `.child` below the source, else a sibling path. */
1333
+ private resolveTarget(target: string, source: Node, path: string): Node | undefined {
1334
+ const walk = (from: Node, keys: string[]): Node | undefined => {
1335
+ let n: Node | undefined = from;
1336
+ for (const k of keys) {
1337
+ n = n.children.get(k);
1338
+ if (!n) return undefined;
1339
+ }
1340
+ return n;
1341
+ };
1342
+ let found: Node | undefined;
1343
+ if (target.startsWith("#")) {
1344
+ const [id, ...keys] = target.slice(1).split(".");
1345
+ if (this.chart.id === undefined) {
1346
+ this.fail(
1347
+ 6,
1348
+ `${path}: "${target}" is a \`#\` target, which needs the Chart's \`id\`; write a sibling or ".child" target, or give the Chart an \`id\``,
1349
+ );
1350
+ return undefined;
1351
+ }
1352
+ if (id !== this.chart.id) {
1353
+ this.fail(6, `${path}: "${target}" names "#${id}"; this Chart's id is "${this.chart.id}"`);
1354
+ return undefined;
1355
+ }
1356
+ found = walk(this.root, keys);
1357
+ } else if (target.startsWith(".")) {
1358
+ found = walk(source, target.slice(1).split("."));
1359
+ } else {
1360
+ found = walk(source.parent ?? this.root, target.split("."));
1361
+ }
1362
+ if (!found) {
1363
+ const siblings = [...(source.parent ?? this.root).children.keys()];
1364
+ this.fail(6, `${path}: target "${target}" resolves to no state; siblings: ${names(siblings)}`);
1365
+ }
1366
+ return found;
1367
+ }
1368
+
1369
+ /** Every state below `node`. */
1370
+ private descendants(node: Node): Node[] {
1371
+ const out: Node[] = [];
1372
+ const walk = (n: Node): void => {
1373
+ for (const c of n.children.values()) {
1374
+ out.push(c);
1375
+ walk(c);
1376
+ }
1377
+ };
1378
+ walk(node);
1379
+ return out;
1380
+ }
1381
+
1382
+ /** The states a state's entry enters below it: its initial child, or every region of a parallel. */
1383
+ private enterDown(n: Node, into: Set<Node>): void {
1384
+ into.add(n);
1385
+ if (n.kind === "parallel") for (const c of n.children.values()) this.enterDown(c, into);
1386
+ else if (n.kind === "compound" && n.state.initial !== undefined) {
1387
+ const c = n.children.get(n.state.initial);
1388
+ if (c) this.enterDown(c, into);
1389
+ }
1390
+ }
1391
+
1392
+ /** The transition domain, as xstate takes it: the source for a target below it (no `reenter`),
1393
+ * else the nearest proper ancestor of the source that holds every target. */
1394
+ private domainOf(e: { from: Node; targets: Node[]; reenter: boolean }): Node {
1395
+ if (!e.targets.length) return e.from;
1396
+ if (!e.reenter && e.targets.every((t) => t !== e.from && isAncestorOrSelf(e.from, t))) return e.from;
1397
+ for (let a = e.from.parent; a; a = a.parent) if (e.targets.every((t) => isAncestorOrSelf(a, t))) return a;
1398
+ return this.root;
1399
+ }
1400
+
1401
+ /**
1402
+ * The states an `always` edge enters, or keeps active as the source's ancestors: each target with
1403
+ * its ancestors and its initial descendants, the other regions of a parallel state on the way in,
1404
+ * and, for a history target, every state below its parent (what it may restore). A targetless
1405
+ * edge keeps its source.
1406
+ */
1407
+ private enteredBy(e: { from: Node; targets: Node[]; reenter: boolean }): Set<Node> {
1408
+ const out = new Set<Node>();
1409
+ if (!e.targets.length) {
1410
+ for (let n: Node | undefined = e.from; n; n = n.parent) out.add(n);
1411
+ return out;
1412
+ }
1413
+ const domain = this.domainOf(e);
1414
+ for (const t of e.targets) {
1415
+ for (let n: Node | undefined = t; n; n = n.parent) {
1416
+ out.add(n);
1417
+ // A parallel state on the way in, the domain included, enters its other regions too.
1418
+ if (n.kind === "parallel" && isAncestorOrSelf(domain, n)) {
1419
+ for (const c of n.children.values()) if (!isAncestorOrSelf(c, t)) this.enterDown(c, out);
1420
+ }
1421
+ }
1422
+ if (t.kind === "history") {
1423
+ const parent = t.parent ?? this.root;
1424
+ for (const d of this.descendants(parent)) out.add(d);
1425
+ } else this.enterDown(t, out);
1426
+ }
1427
+ return out;
1428
+ }
1429
+
1430
+ /** The states an `always` edge may leave active outside its domain: every state in the other
1431
+ * regions of a parallel state above the domain (any of them may be active). */
1432
+ private keptBy(e: { from: Node; targets: Node[]; reenter: boolean }): Set<Node> {
1433
+ const out = new Set<Node>();
1434
+ const domain = this.domainOf(e);
1435
+ for (let a: Node = domain; a.parent; a = a.parent) {
1436
+ const p: Node = a.parent;
1437
+ if (p.kind !== "parallel") continue;
1438
+ for (const c of p.children.values() as Iterable<Node>) {
1439
+ if (c === a) continue;
1440
+ out.add(c);
1441
+ for (const d of this.descendants(c)) out.add(d);
1442
+ }
1443
+ }
1444
+ return out;
1445
+ }
1446
+
1447
+ /**
1448
+ * No `always` cycle with an unguarded edge: such a cycle never ends, and exhausts the heap. An
1449
+ * edge can follow another when its source is entered by it, or stays active beside it in another
1450
+ * region of a parallel state. An edge whose source no `always` edge enters fires at most once, so
1451
+ * it is in no cycle (the fixed point below drops it): a region's one-time step beside another
1452
+ * region's loop is no cycle.
1453
+ */
1454
+ private checkAlwaysCycles(): void {
1455
+ const edges = this.alwaysEdges;
1456
+ const entered = edges.map((e) => this.enteredBy(e));
1457
+ const kept = edges.map((e) => this.keptBy(e));
1458
+ const live = new Set(edges.map((_e, i) => i));
1459
+ for (let changed = true; changed; ) {
1460
+ changed = false;
1461
+ for (const i of [...live]) {
1462
+ if (![...live].some((j) => entered[j]!.has(edges[i]!.from))) {
1463
+ live.delete(i);
1464
+ changed = true;
1465
+ }
1466
+ }
1467
+ }
1468
+ const next = edges.map((_e, i) =>
1469
+ [...live].filter((j) => entered[i]!.has(edges[j]!.from) || kept[i]!.has(edges[j]!.from)),
1470
+ );
1471
+ const reported = new Set<number>();
1472
+ edges.forEach((e, i) => {
1473
+ if (e.guarded || reported.has(i) || !live.has(i)) return;
1474
+ // Search from this unguarded edge for a way back to it.
1475
+ const seen = new Set<number>();
1476
+ const stack: Array<{ at: number; trail: number[] }> = [{ at: i, trail: [i] }];
1477
+ while (stack.length) {
1478
+ const { at: cur, trail } = stack.pop()!;
1479
+ for (const j of next[cur]!) {
1480
+ if (j === i) {
1481
+ for (const k of trail) if (!edges[k]!.guarded) reported.add(k);
1482
+ const states = trail.map((k) => edges[k]!.from.doc.replace(/^states\./, "").replace(/\.states\./g, "."));
1483
+ this.fail(
1484
+ 6,
1485
+ `${e.doc}: an \`always\` cycle (${[...states, states[0]].join(" -> ")}) has an unguarded edge; it would never end: guard every edge of it`,
1486
+ );
1487
+ return;
1488
+ }
1489
+ if (!seen.has(j)) {
1490
+ seen.add(j);
1491
+ stack.push({ at: j, trail: [...trail, j] });
1492
+ }
1493
+ }
1494
+ }
1495
+ });
1496
+ }
1497
+
1498
+ // --- the walk: rules 3, 6, 7 and 8 per state -------------------------------------------------------
1499
+
1500
+ private checkState(node: Node): void {
1501
+ const s = node.state;
1502
+ const doc = node.doc;
1503
+ this.checkStructure(node);
1504
+ const none = (why: string): EventScope => ({ kind: "none", why });
1505
+
1506
+ (s.entry ?? []).forEach((a, i) =>
1507
+ this.checkAction(a, `${doc}.entry.${i}`, none("an entry action runs on entering the state")),
1508
+ );
1509
+ (s.exit ?? []).forEach((a, i) =>
1510
+ this.checkAction(a, `${doc}.exit.${i}`, none("an exit action runs on leaving the state")),
1511
+ );
1512
+
1513
+ for (const [inv, path] of listAt(s.invoke, `${doc}.invoke`)) {
1514
+ const kind = this.srcKind(inv.src, `${path}.src`);
1515
+ if (kind) this.invokeKinds.set(inv, kind);
1516
+ if (inv.input !== undefined)
1517
+ this.checkRefs(inv.input, `${path}.input`, none("an invoke's input is resolved on entering the state"));
1518
+ if (kind) this.checkInvokeInput(inv, kind, path);
1519
+ this.checkTransitions(inv.onDone, `${path}.onDone`, node, { kind: "done" }, false);
1520
+ this.checkTransitions(inv.onError, `${path}.onError`, node, { kind: "error" }, false);
1521
+ }
1522
+ for (const [name, ts] of Object.entries(s.on ?? {})) {
1523
+ const path = `${doc}.on.${name}`;
1524
+ const scope = this.onScope(name, path);
1525
+ this.checkTransitions(ts, path, node, scope ?? { kind: "skip" }, false);
1526
+ }
1527
+ this.checkTransitions(s.always, `${doc}.always`, node, none("an `always` transition handles no event"), true);
1528
+ for (const [key, ts] of Object.entries(s.after ?? {})) {
1529
+ const path = `${doc}.after.${key}`;
1530
+ this.checkDelay(key, path);
1531
+ this.checkTransitions(ts, path, node, none("an `after` transition handles no event"), false);
1532
+ }
1533
+ if (s.output !== undefined)
1534
+ this.checkOutput(s.output, `${doc}.output`, none("a final state's output is resolved on entering it"));
1535
+ }
1536
+
1537
+ private checkOutput(value: ChartValue | undefined, path: string, scope: EventScope): void {
1538
+ if (value !== undefined) this.checkRefs(value, path, scope);
1539
+ }
1540
+
1541
+ private checkTransitions(
1542
+ ts: ChartTransitions | undefined,
1543
+ path: string,
1544
+ source: Node,
1545
+ scope: EventScope,
1546
+ always: boolean,
1547
+ ): void {
1548
+ for (const [t, tpath] of listAt(ts, path)) {
1549
+ const obj = typeof t === "string" ? { target: t } : t;
1550
+ const targets: Node[] = [];
1551
+ let unresolved = false;
1552
+ for (const [target, p] of listAt(obj.target, typeof t === "string" ? tpath : `${tpath}.target`)) {
1553
+ const n = this.resolveTarget(target, source, p);
1554
+ if (n) targets.push(n);
1555
+ else unresolved = true;
1556
+ }
1557
+ if (obj.guard !== undefined) this.checkGuard(obj.guard, `${tpath}.guard`, scope);
1558
+ (obj.actions ?? []).forEach((a, i) => this.checkAction(a, `${tpath}.actions.${i}`, scope));
1559
+ // An edge with a refused target is left out of the cycle check: it already has its line.
1560
+ if (always && !unresolved) {
1561
+ this.alwaysEdges.push({
1562
+ from: source,
1563
+ targets,
1564
+ guarded: obj.guard !== undefined,
1565
+ reenter: obj.reenter === true,
1566
+ doc: tpath,
1567
+ });
1568
+ }
1569
+ }
1570
+ }
1571
+
1572
+ // --- rules 3 and 6: the parties -------------------------------------------------------------------
1573
+
1574
+ /** Does a state invoke a party: an Agent (lent or minted) or a Gate? */
1575
+ private invokesParty(n: Node): boolean {
1576
+ return list(n.state.invoke).some((inv) => {
1577
+ const k = this.invokeKinds.get(inv);
1578
+ return k === "agent" || k === "minted" || k === "gate";
1579
+ });
1580
+ }
1581
+
1582
+ /**
1583
+ * Can something end an Agent's Turn at `node`? A Turn ends when its state exits (an Agent's
1584
+ * invoke never settles, so its `onDone` never runs): by a Chart event handled at, above or below
1585
+ * the state (its modes, with no party-invoking state between), or by an `after`, an `always` or
1586
+ * another invoke's `onDone`/`onError` at or above it. Under a parallel state another region may
1587
+ * end it, which this walk does not follow, so it says yes there.
1588
+ */
1589
+ private endsTurn(node: Node): boolean {
1590
+ const handles = (n: Node): boolean => Object.keys(n.state.on ?? {}).some((k) => this.eventSchemas.has(k));
1591
+ const leaves = (n: Node): boolean =>
1592
+ n.state.after !== undefined ||
1593
+ n.state.always !== undefined ||
1594
+ list(n.state.invoke).some((inv) => {
1595
+ const k = this.invokeKinds.get(inv);
1596
+ return k !== "agent" && k !== "minted" && (inv.onDone !== undefined || inv.onError !== undefined);
1597
+ });
1598
+ for (let n: Node | undefined = node; n && n !== this.root; n = n.parent) {
1599
+ if (handles(n) || leaves(n) || n.parent?.kind === "parallel") return true;
1600
+ }
1601
+ const below = (n: Node): boolean =>
1602
+ [...n.children.values()].some((c) => !this.invokesParty(c) && (handles(c) || below(c)));
1603
+ return below(node);
1604
+ }
1605
+
1606
+ /** The parties' rules, as `jr2Setup` applies them (ADR-0015), each with its path in the Chart. */
1607
+ private checkParties(): void {
1608
+ const agents = new Set<string>();
1609
+ const gates = new Set<string>();
1610
+ for (const node of this.nodes) {
1611
+ const invokes = listAt(node.state.invoke, `${node.doc}.invoke`);
1612
+ // A Gate's invoke id, as jr2Setup names an unnamed one: the state's key path.
1613
+ const key = node.doc
1614
+ .split(".")
1615
+ .filter((_s, i) => i % 2 === 1)
1616
+ .join(".");
1617
+ const unnamed = invokes.filter(([inv]) => this.invokeKinds.get(inv) === "gate" && inv.id === undefined).length;
1618
+ let ordinal = 0;
1619
+ for (const [inv, path] of invokes) {
1620
+ const kind = this.invokeKinds.get(inv);
1621
+ if (kind === "gate") gates.add(inv.id ?? (unnamed > 1 ? `${key}.${ordinal++}` : key));
1622
+ if (kind !== "agent" && kind !== "minted") continue;
1623
+ agents.add(inv.src);
1624
+ if (inv.onDone !== undefined) {
1625
+ this.fail(
1626
+ 6,
1627
+ `${path}.onDone: an Agent's Turn never settles, so this never runs; a Turn ends when the Agent picks one of the Chart's \`events\`: declare that pick in \`events\` and handle it in this state's \`on\``,
1628
+ );
1629
+ }
1630
+ const parallel = [node, ...this.descendants(node)].find((n) => n.kind === "parallel");
1631
+ if (parallel) {
1632
+ this.fail(
1633
+ 6,
1634
+ `${parallel.doc}: a parallel state at or below Agent "${inv.src}"'s state (${node.doc}) is refused: a Turn takes one pick at a time; background work goes above the Agent's state`,
1635
+ );
1636
+ }
1637
+ if (!this.endsTurn(node)) {
1638
+ this.fail(
1639
+ 6,
1640
+ `${node.doc}: nothing ends Agent "${inv.src}"'s Turn: no state at, above or below it handles a Chart event (its Menu is empty), and no \`after\` leaves it; declare the pick that ends the Turn in \`events\` and handle it in this state's \`on\``,
1641
+ );
1642
+ }
1643
+ }
1644
+ }
1645
+ for (const { id, path } of this.sentBys) {
1646
+ if (id.includes("{{")) {
1647
+ this.fail(3, `${path}: a sentBy id is a literal party name, never a reference`);
1648
+ } else if (!agents.has(id) && !gates.has(id)) {
1649
+ this.fail(
1650
+ 3,
1651
+ `${path}: sentBy("${id}") names no party of this Chart; Agents it invokes: ${names([...agents])}; Gate invoke ids: ${names([...gates])}`,
1652
+ );
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ // --- rule 7: dataflow -----------------------------------------------------------------------------
1658
+
1659
+ /** Note every reference of a value, checked once the walk has seen every write. `optionalOk`:
1660
+ * the value is one reference that a `set` or `append` writes only when it is there. */
1661
+ private checkRefs(value: unknown, path: string, scope: EventScope, optionalOk = false): void {
1662
+ for (const ref of referencesOf(value)) {
1663
+ this.refs.push({ ref, path, scope, optionalOk: optionalOk && isRef(value) });
1664
+ }
1665
+ }
1666
+
1667
+ private checkAllRefs(): void {
1668
+ for (const r of this.refs) {
1669
+ const check = this.refCheck(r.ref, r.scope, 0);
1670
+ if (check && !(check.optional && r.optionalOk)) this.fail(7, `${r.path}: ${check.fault}`);
1671
+ }
1672
+ }
1673
+
1674
+ /** Why a reference may resolve to nothing, or undefined when something always supplies it.
1675
+ * `optional`: the only fault is a field that may be absent. */
1676
+ private refCheck(ref: string, scope: EventScope, depth: number): RefCheck {
1677
+ const [root, ...rest] = pathSegments(ref);
1678
+ if (root === "context") {
1679
+ const supplied = this.contextSupplies(rest, depth);
1680
+ if (supplied === true) return undefined;
1681
+ // A write exists, from one field a pick may leave out: the line names the field and the
1682
+ // fix, because "supplied by nothing" hid it (measured: every miss of a planner that never
1683
+ // wrote `required`).
1684
+ if (supplied !== false) {
1685
+ return {
1686
+ fault: `${ref} is written only by \`${supplied.kind}\` at ${supplied.at}, from \`${supplied.source}\`, which the pick may leave out: ${supplied.why}; a field in the event's \`required\` is always there`,
1687
+ };
1688
+ }
1689
+ return {
1690
+ fault: `${ref} is supplied by nothing: it is not in the initial \`context\`, and no \`set\`, \`append\` or \`increment\` writes it (a write supplies its own path and the paths above it; below a \`set\`, only what its one reference supplies)`,
1691
+ };
1692
+ }
1693
+ if (root === "input") {
1694
+ const keys = this.options.inputKeys;
1695
+ if (keys === undefined) return undefined;
1696
+ if (!keys.includes(String(rest[0])))
1697
+ return { fault: `${ref} is not in the Chart's input; input keys: ${names([...keys])}` };
1698
+ if (rest.length > 1) {
1699
+ return {
1700
+ fault: `${ref}: the door knows the keys of the Chart's input, not what they hold; reference input.${String(rest[0])} whole`,
1701
+ };
1702
+ }
1703
+ return undefined;
1704
+ }
1705
+ // event
1706
+ switch (scope.kind) {
1707
+ case "none":
1708
+ return {
1709
+ fault: `${ref} has no event to read here: ${scope.why}; \`event\` is read on a transition that handles one`,
1710
+ };
1711
+ case "done":
1712
+ return rest.length === 1 && rest[0] === "output"
1713
+ ? undefined
1714
+ : {
1715
+ fault: `${ref}: an \`onDone\` transition supplies event.output, whole (the door does not know its shape)`,
1716
+ };
1717
+ case "error":
1718
+ return rest.length === 1 && rest[0] === "error"
1719
+ ? undefined
1720
+ : {
1721
+ fault: `${ref}: an \`onError\` transition supplies event.error, whole (a line jr2 writes that names the invoke; never the error's own message, ADR-0067)`,
1722
+ };
1723
+ case "mechanism": {
1724
+ const fields = MECHANISM_EVENTS[scope.name]!.fields;
1725
+ const f = String(rest[0]);
1726
+ if (!Object.hasOwn(fields, f) || rest.length > 1)
1727
+ return { fault: `${ref} is not a field of "${scope.name}"; fields: ${Object.keys(fields).join(", ")}` };
1728
+ if (!fields[f])
1729
+ return {
1730
+ fault: `${ref} is optional on "${scope.name}"; a reference reads a field that is always there`,
1731
+ optional: true,
1732
+ };
1733
+ return undefined;
1734
+ }
1735
+ case "skip":
1736
+ return undefined;
1737
+ case "event": {
1738
+ const first = String(rest[0]);
1739
+ if (first === "type" && rest.length === 1) return undefined;
1740
+ if (
1741
+ first === "sender" &&
1742
+ (rest.length === 1 || (rest.length === 2 && ["party", "id"].includes(String(rest[1]))))
1743
+ )
1744
+ return undefined;
1745
+ return this.schemaSupplies(this.eventSchemas.get(scope.name)!, rest, `event "${scope.name}"`, ref);
1746
+ }
1747
+ }
1748
+ }
1749
+
1750
+ /** Is a context path always there: in the initial context, at or above a path a write supplies,
1751
+ * or below a `set` whose one reference supplies the rest? */
1752
+ /** True when something always supplies the path; false when nothing writes it; or the one
1753
+ * write that would, whose source a pick may leave out (the first such write, in Chart order). */
1754
+ private contextSupplies(
1755
+ rest: Array<string | number>,
1756
+ depth: number,
1757
+ ): boolean | { kind: string; at: string; source: string; why: string } {
1758
+ if (depth > MAX_NESTING) return false;
1759
+ let v: unknown = this.chart.context ?? {};
1760
+ let inContext = true;
1761
+ for (const seg of rest) {
1762
+ if (typeof v !== "object" || v === null || !Object.hasOwn(v, seg)) {
1763
+ inContext = false;
1764
+ break;
1765
+ }
1766
+ v = (v as Record<string | number, unknown>)[seg];
1767
+ }
1768
+ if (inContext) return true;
1769
+ const isPrefix = (a: Array<string | number>, b: Array<string | number>) =>
1770
+ a.length <= b.length && a.every((s, i) => String(b[i]) === String(s));
1771
+ let optional: { kind: string; at: string; source: string; why: string } | undefined;
1772
+ for (const w of this.written) {
1773
+ const atOrAbove = isPrefix(rest, w.path);
1774
+ if (!atOrAbove && !(w.kind === "set" && isPrefix(w.path, rest))) continue;
1775
+ const source = w.kind === "increment" ? undefined : wholeRefOf(w.from);
1776
+ // A write from one reference that may be absent writes nothing then, so it supplies nothing.
1777
+ if (source !== undefined) {
1778
+ const check = this.refCheck(source, w.scope, depth + 1);
1779
+ if (check?.optional) {
1780
+ optional ??= { kind: w.kind, at: w.at, source, why: check.fault.split("; ")[0]! };
1781
+ continue;
1782
+ }
1783
+ }
1784
+ if (atOrAbove) return true;
1785
+ if (source === undefined) continue;
1786
+ const tail = rest.slice(w.path.length).map(String).join(".");
1787
+ if (this.refCheck(`${source}.${tail}`, w.scope, depth + 1) === undefined) return true;
1788
+ }
1789
+ return optional ?? false;
1790
+ }
1791
+
1792
+ /** Is the path a required field, all the way down, of a payload's JSON Schema? A segment walks
1793
+ * the `properties` of an object schema, or an index below `minItems` of a list schema. */
1794
+ private schemaSupplies(
1795
+ schema: Record<string, unknown>,
1796
+ rest: Array<string | number>,
1797
+ what: string,
1798
+ ref: string,
1799
+ ): RefCheck {
1800
+ let node: Record<string, unknown> = schema;
1801
+ let optional = false;
1802
+ for (const seg of rest) {
1803
+ if (typeof seg === "string") {
1804
+ const props = node.properties as Record<string, Record<string, unknown>> | undefined;
1805
+ if ((node.type !== undefined && node.type !== "object") || typeof props !== "object" || props === null)
1806
+ return {
1807
+ fault: `${ref}: ${what}'s input has no object with fields here; a reference walks named fields: a pick's fields are at the top of \`event\`, as \`event.<field>\`, the fields its \`input\` schema declares`,
1808
+ };
1809
+ if (!Object.hasOwn(props, seg))
1810
+ return {
1811
+ fault: `${ref} is not a field of ${what}'s input; fields: ${names(Object.keys(props))}; a pick's fields are at the top of \`event\`, as \`event.<field>\``,
1812
+ };
1813
+ const required: string[] = Array.isArray(node.required) ? (node.required as string[]) : [];
1814
+ if (!required.includes(seg)) optional = true;
1815
+ node = props[seg]!;
1816
+ } else {
1817
+ const items = node.items as Record<string, unknown> | undefined;
1818
+ if (node.type !== "array" || typeof items !== "object" || items === null)
1819
+ return { fault: `${ref}: ${what}'s input has no list here; an integer indexes a list` };
1820
+ const min = typeof node.minItems === "number" ? node.minItems : 0;
1821
+ if (seg >= min)
1822
+ return {
1823
+ fault: `${ref}: the list in ${what}'s input may hold fewer than ${seg + 1} items (its minItems is ${min})`,
1824
+ };
1825
+ node = items;
1826
+ }
1827
+ }
1828
+ return optional
1829
+ ? {
1830
+ fault: `${ref} is optional in ${what}'s input (not in its \`required\`); a reference reads a required field (a \`set\` or \`append\` from it alone writes it when it is there)`,
1831
+ optional: true,
1832
+ }
1833
+ : undefined;
1834
+ }
1835
+
1836
+ // --- rule 8: lent input shapes --------------------------------------------------------------------
1837
+
1838
+ private checkInvokeInput(
1839
+ inv: ChartInvoke,
1840
+ kind: "agent" | "minted" | "effect" | "machine" | "gate",
1841
+ path: string,
1842
+ ): void {
1843
+ const ipath = `${path}.input`;
1844
+ const input = inv.input;
1845
+ const isObject = typeof input === "object" && input !== null && !Array.isArray(input) && !("$ref" in input);
1846
+ if (kind === "machine") return;
1847
+ if (kind === "gate") {
1848
+ if (input === undefined) return;
1849
+ if (!isObject) return this.fail(8, `${ipath}: a Gate's input is an object of gate (a name) and meta`);
1850
+ for (const key of Object.keys(input)) {
1851
+ if (key !== "gate" && key !== "meta") {
1852
+ this.fail(
1853
+ 8,
1854
+ `${ipath}.${key}: a Gate's input takes gate and meta only; \`gate\` is the kit's Gate, which waits for an outside answer (a human, a webhook), not an Agent's Turn`,
1855
+ );
1856
+ }
1857
+ }
1858
+ const id = (input as Record<string, unknown>).gate;
1859
+ if (id !== undefined && (typeof id !== "string" || referencesOf(id).length)) {
1860
+ this.fail(8, `${ipath}.gate: a Gate's id is a literal name`);
1861
+ }
1862
+ return;
1863
+ }
1864
+ if (kind === "effect") {
1865
+ const logic = this.lent.actors[inv.src];
1866
+ const keys = isEffect(logic) ? inputKeysOf(logic.effect) : undefined;
1867
+ if (keys === undefined) {
1868
+ return this.fail(
1869
+ 8,
1870
+ `${ipath}: the lent Effect "${inv.src}" has no input keys the door can read (an Open argv nobody bound)`,
1871
+ );
1872
+ }
1873
+ if (input === undefined && keys.length === 0) return;
1874
+ if (!isObject) {
1875
+ return this.fail(8, `${ipath}: the lent Effect "${inv.src}" takes an object of exactly ${names(keys)}`);
1876
+ }
1877
+ const given = Object.keys(input);
1878
+ const missing = keys.filter((k) => !given.includes(k));
1879
+ const extra = given.filter((k) => !keys.includes(k));
1880
+ if (missing.length || extra.length) {
1881
+ this.fail(
1882
+ 8,
1883
+ `${ipath}: the lent Effect "${inv.src}" takes exactly ${names(keys)}` +
1884
+ (missing.length ? `; missing: ${missing.join(", ")}` : "") +
1885
+ (extra.length ? `; not its input: ${extra.join(", ")}` : ""),
1886
+ );
1887
+ }
1888
+ for (const k of given.filter((g) => keys.includes(g))) {
1889
+ const v = (input as Record<string, unknown>)[k];
1890
+ if (typeof v !== "string" && !isRef(v))
1891
+ this.fail(8, `${ipath}.${k}: an Effect's input slot takes a string or a reference`);
1892
+ }
1893
+ return;
1894
+ }
1895
+ this.checkAgentInput(inv.src, kind, input, isObject, ipath);
1896
+ }
1897
+
1898
+ /**
1899
+ * An Agent invoke's input is a Turn's Frame and Dials (ADR-0057): `prompt` (a string), and
1900
+ * optionally `cwd`, `continue: true`, a `model` the Open body allows, and a `thinkingLevel`.
1901
+ * A Chart carries no url (ADR-0070): a Turn's placement, `endpoint` or `sandbox`, is refused by
1902
+ * name, and the Open body places each Turn from its own input.
1903
+ */
1904
+ private checkAgentInput(
1905
+ src: string,
1906
+ kind: "agent" | "minted",
1907
+ input: unknown,
1908
+ isObject: boolean,
1909
+ ipath: string,
1910
+ ): void {
1911
+ const who = kind === "agent" ? `the lent Agent "${src}"` : `the Chart's Agent "${src}"`;
1912
+ if (!isObject) return this.fail(8, `${ipath}: ${who} takes an object with a \`prompt\``);
1913
+ const obj = input as Record<string, unknown>;
1914
+ for (const key of Object.keys(obj)) {
1915
+ if ((PLACEMENT_INPUT_KEYS as readonly string[]).includes(key)) {
1916
+ this.fail(
1917
+ 8,
1918
+ `${ipath}.${key}: a Chart names no placement; the Open body places each Turn its Chart starts (ADR-0070)`,
1919
+ );
1920
+ } else if (!(AGENT_INPUT_KEYS as readonly string[]).includes(key)) {
1921
+ this.fail(
1922
+ 8,
1923
+ `${ipath}.${key}: not an Agent's Turn input; it takes prompt, and cwd, continue, model, thinkingLevel`,
1924
+ );
1925
+ }
1926
+ }
1927
+ if (obj.prompt === undefined) this.fail(8, `${ipath}: ${who} takes a \`prompt\``);
1928
+ else if (typeof obj.prompt !== "string")
1929
+ this.fail(8, `${ipath}.prompt: a prompt is a string (placeholders inside it are fine)`);
1930
+ if (obj.cwd !== undefined && typeof obj.cwd !== "string") this.fail(8, `${ipath}.cwd: a cwd is a string`);
1931
+ if (obj.continue !== undefined && obj.continue !== true)
1932
+ this.fail(8, `${ipath}.continue: \`continue\` is true or absent`);
1933
+ if (obj.model !== undefined) {
1934
+ if (typeof obj.model !== "string" || referencesOf(obj.model).length) {
1935
+ this.fail(
1936
+ 8,
1937
+ `${ipath}.model: a model is a literal the Open body allows; allowed: ${names(this.decl.allows.models)}`,
1938
+ );
1939
+ } else if (!this.decl.allows.models.includes(obj.model)) {
1940
+ this.fail(
1941
+ 8,
1942
+ `${ipath}.model: "${obj.model}" is not a model this Open body allows; allowed: ${names(this.decl.allows.models)}`,
1943
+ );
1944
+ }
1945
+ }
1946
+ if (obj.thinkingLevel !== undefined && !(THINKING as readonly unknown[]).includes(obj.thinkingLevel)) {
1947
+ this.fail(8, `${ipath}.thinkingLevel: one of ${THINKING.join(", ")}`);
1948
+ }
1949
+ }
1950
+ }
1951
+
1952
+ /**
1953
+ * The JSON Schema keywords a Chart event's payload may use (ADR-0070, rule 4). The Orchestrator
1954
+ * checks every pick against this schema in its own process, before the Allowed check, so a keyword
1955
+ * whose cost the door cannot bound is refused by name: `pattern` (a backtracking regex),
1956
+ * `patternProperties`, `$ref`, the combinators and the conditionals.
1957
+ */
1958
+ const PAYLOAD_KEYWORDS = new Set([
1959
+ "type",
1960
+ "properties",
1961
+ "required",
1962
+ "items",
1963
+ "enum",
1964
+ "const",
1965
+ "description",
1966
+ "title",
1967
+ "additionalProperties",
1968
+ "minimum",
1969
+ "maximum",
1970
+ "exclusiveMinimum",
1971
+ "exclusiveMaximum",
1972
+ "minLength",
1973
+ "maxLength",
1974
+ "minItems",
1975
+ "maxItems",
1976
+ ]);
1977
+
1978
+ /**
1979
+ * The faults of an event's payload schema, before zod reads it: every keyword not in
1980
+ * {@link PAYLOAD_KEYWORDS}, a list-form `items`, and a top-level field named `type`, which would let
1981
+ * the payload name the event a Chart receives (registration.ts builds `{ type, ...payload }`).
1982
+ */
1983
+ function payloadSchemaFaults(schema: unknown): Array<{ path: PropertyKey[]; message: string }> {
1984
+ const out: Array<{ path: PropertyKey[]; message: string }> = [];
1985
+ const walk = (node: unknown, path: PropertyKey[]): void => {
1986
+ if (typeof node === "boolean" && path.length && path[path.length - 1] === "additionalProperties") return;
1987
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
1988
+ out.push({ path, message: "a schema here is an object" });
1989
+ return;
1990
+ }
1991
+ for (const [key, value] of Object.entries(node)) {
1992
+ if (key === "$schema" && path.length === 0) continue;
1993
+ if (!PAYLOAD_KEYWORDS.has(key)) {
1994
+ out.push({
1995
+ path: [...path, key],
1996
+ message: `"${key}" is not a keyword a Chart event's payload may use; it may use ${[...PAYLOAD_KEYWORDS].join(", ")}`,
1997
+ });
1998
+ continue;
1999
+ }
2000
+ if (key === "properties") {
2001
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
2002
+ out.push({ path: [...path, key], message: "`properties` is an object of schemas" });
2003
+ continue;
2004
+ }
2005
+ for (const [field, sub] of Object.entries(value)) walk(sub, [...path, key, field]);
2006
+ } else if (key === "items") {
2007
+ if (Array.isArray(value)) out.push({ path: [...path, key], message: "`items` is one schema, not a list" });
2008
+ else walk(value, [...path, key]);
2009
+ } else if (key === "additionalProperties") {
2010
+ walk(value, [...path, key]);
2011
+ }
2012
+ }
2013
+ };
2014
+ walk(schema, []);
2015
+ const props = (schema as { properties?: unknown } | null)?.properties;
2016
+ if (typeof props === "object" && props !== null && Object.hasOwn(props, "type")) {
2017
+ out.push({
2018
+ path: ["properties", "type"],
2019
+ message: "a payload field named `type` is refused: jr2 writes the event's type, and the payload never names it",
2020
+ });
2021
+ }
2022
+ return out;
2023
+ }
2024
+
2025
+ /** A reference's fault, or undefined when it is always supplied. `optional`: the one fault is a
2026
+ * field that may be absent, which a `set` or `append` from it alone may read. */
2027
+ type RefCheck = { fault: string; optional?: true } | undefined;
2028
+
2029
+ /** The reference a value is, when it is one reference alone. */
2030
+ function wholeRefOf(v: unknown): string | undefined {
2031
+ if (typeof v === "string") return WHOLE_PLACEHOLDER.exec(v)?.[1]!.trim();
2032
+ return isRef(v) ? (v as { $ref: string }).$ref : undefined;
2033
+ }
2034
+
2035
+ /** Is `a` the state `b` or a state above it? */
2036
+ function isAncestorOrSelf(a: Node, b: Node): boolean {
2037
+ for (let n: Node | undefined = b; n; n = n.parent) if (n === a) return true;
2038
+ return false;
2039
+ }
2040
+
2041
+ /** Is this value a reference by itself: `{ "$ref": "..." }` or `"{{...}}"`? */
2042
+ function isRef(v: unknown): boolean {
2043
+ if (typeof v === "string") return WHOLE_PLACEHOLDER.test(v);
2044
+ return (
2045
+ typeof v === "object" &&
2046
+ v !== null &&
2047
+ Object.keys(v).length === 1 &&
2048
+ typeof (v as { $ref?: unknown }).$ref === "string"
2049
+ );
2050
+ }