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