@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/party.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// Parties and their writings (ADR-0015, ADR-0029). A party is who may send a workflow event into a
|
|
2
|
+
// Machine: an Agent, through its Menu, or a Gate's caller. Every event a party delivers carries
|
|
3
|
+
// `sender: { party, id }` (ADR-0011) — the Agent's slot, or the Gate's invoke id.
|
|
4
|
+
//
|
|
5
|
+
// What an author writes with it: `guard: sentBy("coder")` on a writing — one `on` key on one state.
|
|
6
|
+
// A NAMED guard with params, `{ type: "jr2.sentBy", params: { id } }`, so the load walk can read it.
|
|
7
|
+
// It is false for an event another party sent, and true for the named party's event and for the
|
|
8
|
+
// Machine's own (no sender). xstate's own rule does the rest: a false guard on the deepest writing
|
|
9
|
+
// lets selection move outward. It can only make a writing DECLINE other senders; it never gives a
|
|
10
|
+
// writing to a party — position does that (`mayTake`).
|
|
11
|
+
//
|
|
12
|
+
// What jr2 computes with it:
|
|
13
|
+
// - `mayTake`: may this sender take this writing? ADR-0015's position rule, per writing.
|
|
14
|
+
// - `takesNow`: an Agent's Allowed picks and a Gate's `acceptsNow`, payload-blind (ADR-0029).
|
|
15
|
+
// - `notTaken`: at delivery, exact — a delivery whose selection holds another party's writing is
|
|
16
|
+
// refused before the Machine sees it.
|
|
17
|
+
// - `partyWarnings`: the load walk's warnings over parties and writings.
|
|
18
|
+
|
|
19
|
+
import type { AnyActorRef, AnyEventObject, AnyMachineSnapshot, AnyStateMachine } from "xstate";
|
|
20
|
+
import { SENDER_KEY, type Sender } from "@jr2/agent-protocol";
|
|
21
|
+
import { isAgent } from "./agent.ts";
|
|
22
|
+
import { vocabularyOf } from "./vocabulary.ts";
|
|
23
|
+
|
|
24
|
+
/** The guard type `sentBy` names: dotted, so no author guard can take the name. */
|
|
25
|
+
export const SENT_BY = "jr2.sentBy";
|
|
26
|
+
|
|
27
|
+
/** A writing runs for this party's events, and for the Machine's own: `guard: sentBy("coder")`. The
|
|
28
|
+
* id is an Agent slot, or a Gate's invoke id, of this Machine — an unknown id is refused at load. */
|
|
29
|
+
export function sentBy<const Id extends string>(id: Id): { type: typeof SENT_BY; params: { id: Id } } {
|
|
30
|
+
return { type: SENT_BY, params: { id } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The implementation `jr2Setup` registers under {@link SENT_BY}. */
|
|
34
|
+
export function sentByGuard({ event }: { event: AnyEventObject }, params: { id: string }): boolean {
|
|
35
|
+
const sender = (event as { [SENDER_KEY]?: Sender })[SENDER_KEY];
|
|
36
|
+
return sender === undefined || sender.id === params.id;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The ids a writing's guard names with `sentBy`, read statically: the guard itself, or a member of
|
|
41
|
+
* xstate's `and([...])` — a conjunct, so the writing declines whatever it declines. Under `or()` or
|
|
42
|
+
* `not()` a `sentBy` says nothing certain about the writing, so it is not read (`opaque`); neither
|
|
43
|
+
* is an author's own guard that reads `event.sender`. Undefined when no `sentBy` is readable.
|
|
44
|
+
*/
|
|
45
|
+
export function sentByIds(guard: unknown): { ids: string[]; opaque: boolean } | undefined {
|
|
46
|
+
if (guard && typeof guard === "object" && (guard as { type?: unknown }).type === SENT_BY) {
|
|
47
|
+
return { ids: [String((guard as { params: { id: string } }).params.id)], opaque: false };
|
|
48
|
+
}
|
|
49
|
+
const members = typeof guard === "function" ? (guard as { guards?: unknown }).guards : undefined;
|
|
50
|
+
if (Array.isArray(members)) {
|
|
51
|
+
const named = members.map(sentByIds).filter((m) => m !== undefined);
|
|
52
|
+
if (named.length === 0) return undefined;
|
|
53
|
+
if (guard instanceof Function && guard.name !== "and") return { ids: [], opaque: true };
|
|
54
|
+
return { ids: [...new Set(named.flatMap((m) => m.ids))], opaque: named.some((m) => m.opaque) };
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Does this guard (one candidate of a writing) decline `senderId`'s events, by its `sentBy`, for
|
|
60
|
+
* sure? `and([sentBy(a), sentBy(b)])` with a ≠ b declines every party. */
|
|
61
|
+
export function declines(guard: unknown, senderId: string): boolean {
|
|
62
|
+
const named = sentByIds(guard);
|
|
63
|
+
if (!named || named.opaque) return false;
|
|
64
|
+
return named.ids.some((id) => id !== senderId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Node = AnyStateMachine["root"];
|
|
68
|
+
|
|
69
|
+
/** The parties a built state invokes, each as the sender it delivers as. */
|
|
70
|
+
export function partiesAt(node: Node, machine: AnyStateMachine): Sender[] {
|
|
71
|
+
const actors = (machine.implementations.actors ?? {}) as Record<string, unknown>;
|
|
72
|
+
const out: Sender[] = [];
|
|
73
|
+
for (const inv of node.invoke) {
|
|
74
|
+
if (typeof inv.src !== "string") continue;
|
|
75
|
+
if (inv.src === "gate") out.push({ party: "gate", id: inv.id });
|
|
76
|
+
else if (isAgent(actors[inv.src])) out.push({ party: "agent", id: inv.src });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every built state node, document order. */
|
|
82
|
+
export function allNodes(machine: AnyStateMachine): Node[] {
|
|
83
|
+
const out: Node[] = [];
|
|
84
|
+
const visit = (node: Node): void => {
|
|
85
|
+
out.push(node);
|
|
86
|
+
for (const child of Object.values(node.states)) visit(child);
|
|
87
|
+
};
|
|
88
|
+
visit(machine.root);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The Agent slots and Gate invoke ids of a Machine: every id a `sentBy` may name. */
|
|
93
|
+
export function senderIds(machine: AnyStateMachine): { agents: Set<string>; gates: Set<string> } {
|
|
94
|
+
const agents = new Set<string>();
|
|
95
|
+
const gates = new Set<string>();
|
|
96
|
+
for (const node of allNodes(machine))
|
|
97
|
+
for (const p of partiesAt(node, machine)) (p.party === "agent" ? agents : gates).add(p.id);
|
|
98
|
+
return { agents, gates };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Refuse a `sentBy` that names no party of this Machine — an Agent slot or a Gate's invoke id — at
|
|
103
|
+
* load, like an unknown event key (ADR-0015): a typo would decline every sender, and the writing
|
|
104
|
+
* would run for none.
|
|
105
|
+
*/
|
|
106
|
+
export function refuseUnknownSentBy(machine: AnyStateMachine): void {
|
|
107
|
+
const { agents, gates } = senderIds(machine);
|
|
108
|
+
for (const node of allNodes(machine)) {
|
|
109
|
+
for (const [name, candidates] of node.transitions) {
|
|
110
|
+
for (const candidate of candidates) {
|
|
111
|
+
for (const id of sentByIds((candidate as { guard?: unknown }).guard)?.ids ?? []) {
|
|
112
|
+
if (agents.has(id) || gates.has(id)) continue;
|
|
113
|
+
throw new Error(
|
|
114
|
+
`machine "${machine.id}": sentBy("${id}") on "${name}" at "${node.id}" names no party of this Machine ` +
|
|
115
|
+
`(Agent slots: ${[...agents].join(", ") || "none"}; Gate invoke ids: ${[...gates].join(", ") || "none"})`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const isAncestorOrSelf = (a: Node, b: Node): boolean => {
|
|
124
|
+
for (let n: Node | undefined = b; n; n = n.parent) if (n === a) return true;
|
|
125
|
+
return false;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** The nearest state at or above `node` that invokes a party; undefined when none does. */
|
|
129
|
+
export function ownerOf(node: Node, machine: AnyStateMachine): Node | undefined {
|
|
130
|
+
for (let n: Node | undefined = node; n; n = n.parent) if (partiesAt(n, machine).length) return n;
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The state whose invoke registered `invokeId` — the seat its registration speaks for. Two states
|
|
136
|
+
* may author one invoke id when they are never active together; the active one is the seat.
|
|
137
|
+
*/
|
|
138
|
+
export function seatOf(snapshot: AnyMachineSnapshot, invokeId: string): Node | undefined {
|
|
139
|
+
const machine = snapshot.machine as AnyStateMachine;
|
|
140
|
+
const found = allNodes(machine).filter((node) => node.invoke.some((inv) => inv.id === invokeId));
|
|
141
|
+
if (found.length <= 1) return found[0];
|
|
142
|
+
const active = new Set((snapshot as { _nodes?: Node[] })._nodes ?? []);
|
|
143
|
+
return found.find((node) => active.has(node)) ?? found[0];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* May `sender`, seated at `seat`, take the writing of `type` on `source` (ADR-0015, per writing)?
|
|
148
|
+
*
|
|
149
|
+
* In reach: the seat or a state above it, or a state below it with no party-invoking state between
|
|
150
|
+
* — an Agent's modes, and a Gate's plain children, which the same position rule gives the Gate.
|
|
151
|
+
* And owned: an unset audience gives a writing to the party of the nearest party-invoking state at
|
|
152
|
+
* or above it (none: both parties); an explicit audience gives it, by kind, to every party whose
|
|
153
|
+
* reach holds it. `sentBy` plays no part here: it can decline, never give.
|
|
154
|
+
*/
|
|
155
|
+
export function mayTake(machine: AnyStateMachine, sender: Sender, seat: Node, source: Node, type: string): boolean {
|
|
156
|
+
let inReach = isAncestorOrSelf(source, seat);
|
|
157
|
+
if (!inReach && isAncestorOrSelf(seat, source)) {
|
|
158
|
+
inReach = true;
|
|
159
|
+
for (let n: Node | undefined = source; n && n !== seat; n = n.parent)
|
|
160
|
+
if (partiesAt(n, machine).length) inReach = false;
|
|
161
|
+
}
|
|
162
|
+
if (!inReach) return false;
|
|
163
|
+
const audience = vocabularyOf(machine)?.get(type)?.audience;
|
|
164
|
+
if (audience !== undefined)
|
|
165
|
+
return audience === "any" || audience === (sender.party === "agent" ? "agent" : "external");
|
|
166
|
+
const owner = ownerOf(source, machine);
|
|
167
|
+
return owner === undefined || owner === seat;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Selection: what xstate would run, payload-blind ---------------------------------------------
|
|
171
|
+
//
|
|
172
|
+
// A copy of xstate 5.32.2's `transitionNode` / `StateNode.next` / `evaluateGuard`, with one change:
|
|
173
|
+
// each guard is asked behind a Proxy, and a guard that read the payload answers UNKNOWN, which
|
|
174
|
+
// passes. `type` and `sender` are not payload. `and` / `or` / `not` are evaluated here over their
|
|
175
|
+
// members in three-valued logic, so `and([payloadGuard, sentBy("other")])` still declines. Pinned
|
|
176
|
+
// against xstate's own `getTransitionData` in `party-xstate.test.ts`: an xstate bump that changes
|
|
177
|
+
// selection fails there first.
|
|
178
|
+
|
|
179
|
+
type Tri = true | false | "unknown";
|
|
180
|
+
type Transition = ReturnType<AnyStateMachine["getTransitionData"]>[number];
|
|
181
|
+
|
|
182
|
+
function evalGuard(
|
|
183
|
+
guard: unknown,
|
|
184
|
+
snapshot: AnyMachineSnapshot,
|
|
185
|
+
event: AnyEventObject,
|
|
186
|
+
read: { payload: boolean },
|
|
187
|
+
): Tri {
|
|
188
|
+
const machine = snapshot.machine as AnyStateMachine;
|
|
189
|
+
const isInline = typeof guard === "function";
|
|
190
|
+
const key = typeof guard === "string" ? guard : (guard as { type?: string }).type;
|
|
191
|
+
const resolved: unknown = isInline ? guard : (machine.implementations.guards as Record<string, unknown>)[key!];
|
|
192
|
+
if (!isInline && !resolved) throw new Error(`Guard '${key}' is not implemented.`);
|
|
193
|
+
if (typeof resolved !== "function") return evalGuard(resolved, snapshot, event, read);
|
|
194
|
+
const context = snapshot.context;
|
|
195
|
+
const members = (resolved as { guards?: unknown[] }).guards;
|
|
196
|
+
if ("check" in resolved && Array.isArray(members)) {
|
|
197
|
+
const values = members.map((m) => evalGuard(m, snapshot, event, read));
|
|
198
|
+
if (resolved.name === "and") return values.includes(false) ? false : values.includes("unknown") ? "unknown" : true;
|
|
199
|
+
if (resolved.name === "or") return values.includes(true) ? true : values.includes("unknown") ? "unknown" : false;
|
|
200
|
+
if (resolved.name === "not") return values[0] === "unknown" ? "unknown" : !values[0];
|
|
201
|
+
}
|
|
202
|
+
const params =
|
|
203
|
+
isInline || typeof guard === "string" || !("params" in (guard as object))
|
|
204
|
+
? undefined
|
|
205
|
+
: typeof (guard as { params: unknown }).params === "function"
|
|
206
|
+
? (guard as { params: (a: unknown) => unknown }).params({ context, event })
|
|
207
|
+
: (guard as { params: unknown }).params;
|
|
208
|
+
const before = read.payload;
|
|
209
|
+
read.payload = false;
|
|
210
|
+
const value =
|
|
211
|
+
"check" in resolved
|
|
212
|
+
? (resolved as { check: (s: unknown, a: unknown, r: unknown) => boolean }).check(
|
|
213
|
+
snapshot,
|
|
214
|
+
{ context, event },
|
|
215
|
+
resolved,
|
|
216
|
+
)
|
|
217
|
+
: (resolved as (a: unknown, p: unknown) => boolean)({ context, event }, params);
|
|
218
|
+
const looked = read.payload;
|
|
219
|
+
read.payload = before || looked;
|
|
220
|
+
return looked ? "unknown" : Boolean(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** xstate's `getCandidates` for a name with no dot: the exact writing, then the wildcard. */
|
|
224
|
+
function candidates(node: Node, type: string): Transition[] {
|
|
225
|
+
return [
|
|
226
|
+
...((node.transitions.get(type) ?? []) as Transition[]),
|
|
227
|
+
...((node.transitions.get("*") ?? []) as Transition[]),
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function next(node: Node, snapshot: AnyMachineSnapshot, event: AnyEventObject, read: { payload: boolean }) {
|
|
232
|
+
for (const candidate of candidates(node, event.type)) {
|
|
233
|
+
const guard = (candidate as { guard?: unknown }).guard;
|
|
234
|
+
if (!guard || evalGuard(guard, snapshot, event, read) !== false) return [candidate];
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function select(
|
|
240
|
+
node: Node,
|
|
241
|
+
value: unknown,
|
|
242
|
+
snapshot: AnyMachineSnapshot,
|
|
243
|
+
event: AnyEventObject,
|
|
244
|
+
read: { payload: boolean },
|
|
245
|
+
): Transition[] | undefined {
|
|
246
|
+
if (typeof value === "string") {
|
|
247
|
+
const inner = next(node.states[value]!, snapshot, event, read);
|
|
248
|
+
return inner?.length ? inner : next(node, snapshot, event, read);
|
|
249
|
+
}
|
|
250
|
+
const keys = Object.keys(value as object);
|
|
251
|
+
if (keys.length === 1) {
|
|
252
|
+
const inner = select(node.states[keys[0]!]!, (value as Record<string, unknown>)[keys[0]!], snapshot, event, read);
|
|
253
|
+
return inner?.length ? inner : next(node, snapshot, event, read);
|
|
254
|
+
}
|
|
255
|
+
const all: Transition[] = [];
|
|
256
|
+
for (const key of keys) {
|
|
257
|
+
const sub = (value as Record<string, unknown>)[key];
|
|
258
|
+
if (!sub) continue;
|
|
259
|
+
all.push(...(select(node.states[key]!, sub, snapshot, event, read) ?? []));
|
|
260
|
+
}
|
|
261
|
+
return all.length ? all : next(node, snapshot, event, read);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The writings xstate would select for `type` from `sender`, every payload-reading guard passing. */
|
|
265
|
+
export function selectPayloadBlind(
|
|
266
|
+
snapshot: AnyMachineSnapshot,
|
|
267
|
+
type: string,
|
|
268
|
+
sender: Sender | undefined,
|
|
269
|
+
): Transition[] {
|
|
270
|
+
const read = { payload: false };
|
|
271
|
+
const base: Record<string, unknown> = { type, ...(sender ? { [SENDER_KEY]: sender } : {}) };
|
|
272
|
+
const watch = (prop: string | symbol): void => {
|
|
273
|
+
if (typeof prop === "string" && prop !== "type" && prop !== SENDER_KEY) read.payload = true;
|
|
274
|
+
};
|
|
275
|
+
const probe = new Proxy(base, {
|
|
276
|
+
get(target, prop, receiver) {
|
|
277
|
+
watch(prop);
|
|
278
|
+
return Reflect.get(target, prop, receiver);
|
|
279
|
+
},
|
|
280
|
+
has(target, prop) {
|
|
281
|
+
watch(prop);
|
|
282
|
+
return Reflect.has(target, prop);
|
|
283
|
+
},
|
|
284
|
+
}) as AnyEventObject;
|
|
285
|
+
const machine = snapshot.machine as AnyStateMachine;
|
|
286
|
+
return select(machine.root, snapshot.value, snapshot, probe, read) ?? [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** xstate's `can()`: a selected writing that neither targets nor acts is not a move. */
|
|
290
|
+
const moves = (ts: Transition[]): boolean => ts.some((t) => t.target !== undefined || t.actions.length > 0);
|
|
291
|
+
|
|
292
|
+
/** What a registration needs to ask the party questions: who it sends as, its seat, its Machine. */
|
|
293
|
+
export type PartySeat = { sender?: Sender; invokeId?: string; invoker?: AnyActorRef };
|
|
294
|
+
|
|
295
|
+
function seated(reg: PartySeat): { machine: AnyStateMachine; seat: Node; snapshot: AnyMachineSnapshot } | undefined {
|
|
296
|
+
if (!reg.sender || !reg.invokeId || !reg.invoker) return undefined;
|
|
297
|
+
const snapshot = reg.invoker.getSnapshot() as AnyMachineSnapshot | undefined;
|
|
298
|
+
const machine = snapshot?.machine as AnyStateMachine | undefined;
|
|
299
|
+
if (!snapshot || !machine?.root) return undefined;
|
|
300
|
+
const seat = seatOf(snapshot, reg.invokeId);
|
|
301
|
+
return seat ? { machine, seat, snapshot } : undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Would the run take `type` from this registration's sender NOW, payload-blind (ADR-0029)? The one
|
|
306
|
+
* question behind an Agent's Allowed picks and a Gate's `acceptsNow`: xstate's selection for an
|
|
307
|
+
* event that carries the sender, a payload-reading guard counted as passing, must move something,
|
|
308
|
+
* and every writing it selects must be one the sender may take. Undefined when the question cannot
|
|
309
|
+
* be asked (no seat); the caller then asks `mayMove`. Fails open like ADR-0029's readings: a wrong
|
|
310
|
+
* `true` costs one refused send, and delivery still judges it exactly.
|
|
311
|
+
*/
|
|
312
|
+
export function takesNow(reg: PartySeat, type: string): boolean | undefined {
|
|
313
|
+
const at = seated(reg);
|
|
314
|
+
if (!at) return undefined;
|
|
315
|
+
try {
|
|
316
|
+
const selected = selectPayloadBlind(at.snapshot, type, reg.sender);
|
|
317
|
+
if (!moves(selected)) return false;
|
|
318
|
+
return selected.every((t) => mayTake(at.machine, reg.sender!, at.seat, t.source, type));
|
|
319
|
+
} catch {
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** A writing a delivery would run that its sender may not take: its state, and who owns it. */
|
|
325
|
+
export type NotTakenWriting = { state: string; owners: Sender[] };
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* At delivery, exact: the writings xstate WILL select for this event (its real payload, its real
|
|
329
|
+
* sender) that the sender may not take. Empty: deliver. Not empty: refuse before the Machine sees it.
|
|
330
|
+
*/
|
|
331
|
+
export function notTaken(reg: PartySeat, event: AnyEventObject): NotTakenWriting[] {
|
|
332
|
+
const at = seated(reg);
|
|
333
|
+
if (!at) return [];
|
|
334
|
+
try {
|
|
335
|
+
return at.machine
|
|
336
|
+
.getTransitionData(at.snapshot, event)
|
|
337
|
+
.filter((t) => !mayTake(at.machine, reg.sender!, at.seat, t.source, event.type))
|
|
338
|
+
.map((t) => {
|
|
339
|
+
const owner = ownerOf(t.source, at.machine);
|
|
340
|
+
return { state: t.source.id, owners: owner ? partiesAt(owner, at.machine) : [] };
|
|
341
|
+
});
|
|
342
|
+
} catch {
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --- The load walk's warnings over parties and writings (ADR-0015) --------------------------------
|
|
348
|
+
|
|
349
|
+
/** Neither above the other, and their nearest common state is parallel: two regions. */
|
|
350
|
+
function orthogonal(a: Node, b: Node): boolean {
|
|
351
|
+
if (isAncestorOrSelf(a, b) || isAncestorOrSelf(b, a)) return false;
|
|
352
|
+
for (let n: Node | undefined = a.parent; n; n = n.parent) if (isAncestorOrSelf(n, b)) return n.type === "parallel";
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Can the two states be active at the same time? */
|
|
357
|
+
const together = (a: Node, b: Node): boolean => isAncestorOrSelf(a, b) || isAncestorOrSelf(b, a) || orthogonal(a, b);
|
|
358
|
+
|
|
359
|
+
/** One writing: one `on` key on one state, with its candidates (an array writing has several). */
|
|
360
|
+
type Writing = { node: Node; name: string; candidates: Transition[] };
|
|
361
|
+
|
|
362
|
+
function writingsOf(machine: AnyStateMachine): Writing[] {
|
|
363
|
+
const defs = vocabularyOf(machine);
|
|
364
|
+
const out: Writing[] = [];
|
|
365
|
+
for (const node of allNodes(machine))
|
|
366
|
+
for (const [name, candidates] of node.transitions)
|
|
367
|
+
if (defs?.has(name) ?? (!name.includes(".") && name !== "*"))
|
|
368
|
+
out.push({ node, name, candidates: candidates as Transition[] });
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const targetsOf = (w: Writing): string =>
|
|
373
|
+
[...new Set(w.candidates.flatMap((t) => (t.target ?? []).map((target) => target.id)))].sort().join(", ");
|
|
374
|
+
|
|
375
|
+
/** The line the warning shows: the deeper writing, declining every sender but its own party. */
|
|
376
|
+
function correctedLine(w: Writing, id: string): string {
|
|
377
|
+
const guard = `sentBy("${id}")`;
|
|
378
|
+
const only = w.candidates.length === 1 ? w.candidates[0]! : undefined;
|
|
379
|
+
const target = only?.target?.length === 1 ? `, target: "#${only.target[0]!.id}"` : ", …";
|
|
380
|
+
const body =
|
|
381
|
+
only === undefined
|
|
382
|
+
? `[{ guard: ${guard}, … }, …] (on each candidate)`
|
|
383
|
+
: only.guard === undefined
|
|
384
|
+
? `{ guard: ${guard}${target} }`
|
|
385
|
+
: `{ guard: and([${guard}, <its guard>])${target} }`;
|
|
386
|
+
return ` ${w.node.key}: { on: { ${w.name}: ${body} } }`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The load walk's party warnings (ADR-0015). Warnings, never refusals: the Machine is legal, and
|
|
391
|
+
* delivery refuses what would misroute.
|
|
392
|
+
*
|
|
393
|
+
* - **Two parties' writings of one name that can be active together.** A seat — one party on its
|
|
394
|
+
* state — may take a writing of a name, and ANOTHER party's writing of that name can be active
|
|
395
|
+
* with the seat's state and is selected first: deeper on the same path, or in another parallel
|
|
396
|
+
* region. Unless that writing declines the seat's sender with `sentBy`, xstate selects it for
|
|
397
|
+
* the seat's event, delivery refuses the event, and the seat cannot send the name while it is
|
|
398
|
+
* active. The warning names both writings and shows the corrected line; the guard goes on the
|
|
399
|
+
* DEEPER writing, because xstate selects deepest first. A root writing is both parties', so the
|
|
400
|
+
* root-writing shape (a root `cancel`, a Gate below, the Agent's `cancel` in a mode) is the same
|
|
401
|
+
* case. Not when the seat is an Agent and the deeper writing's party is seated below the Agent's
|
|
402
|
+
* state: that state is busy while the party runs, so every Agent pick is refused there (ADR-0024).
|
|
403
|
+
* - **One def, one meaning.** One party may take two writings of one name on one path with
|
|
404
|
+
* different targets: its sender reads one description for both.
|
|
405
|
+
*
|
|
406
|
+
* `derivedGateIds` are the Gate invoke ids the walk named: the line says how to name one instead.
|
|
407
|
+
*/
|
|
408
|
+
export function partyWarnings(machine: AnyStateMachine, derivedGateIds: ReadonlySet<string> = new Set()): string[] {
|
|
409
|
+
const writings = writingsOf(machine);
|
|
410
|
+
const seats = allNodes(machine).flatMap((node) => partiesAt(node, machine).map((sender) => ({ node, sender })));
|
|
411
|
+
const warnings = new Map<string, string>();
|
|
412
|
+
for (const seat of seats) {
|
|
413
|
+
const reader =
|
|
414
|
+
seat.sender.party === "agent" ? `Agent "${seat.sender.id}"` : `the caller of Gate "${seat.sender.id}"`;
|
|
415
|
+
for (const own of writings) {
|
|
416
|
+
if (!mayTake(machine, seat.sender, seat.node, own.node, own.name)) continue;
|
|
417
|
+
for (const other of writings) {
|
|
418
|
+
if (other.name !== own.name || other === own) continue;
|
|
419
|
+
if (mayTake(machine, seat.sender, seat.node, other.node, other.name)) {
|
|
420
|
+
// One def, one meaning: the same party, one path, different targets.
|
|
421
|
+
if (!isAncestorOrSelf(own.node, other.node) || targetsOf(own) === targetsOf(other)) continue;
|
|
422
|
+
warnings.set(
|
|
423
|
+
`meaning:${seat.sender.party}:${seat.sender.id}:${own.name}:${own.node.id}:${other.node.id}`,
|
|
424
|
+
`"${own.name}" is written for ${senderText(seat.sender)} on "${own.node.id}" (→ ${targetsOf(own) || "no target"}) ` +
|
|
425
|
+
`and on "${other.node.id}" (→ ${targetsOf(other) || "no target"}): ${reader} reads one description for both ` +
|
|
426
|
+
`— make the def's description true at each writing, or give them distinct names`,
|
|
427
|
+
);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const deeper = isAncestorOrSelf(own.node, other.node) && own.node !== other.node;
|
|
431
|
+
const parallel = orthogonal(own.node, other.node);
|
|
432
|
+
if ((!deeper && !parallel) || !together(seat.node, other.node)) continue;
|
|
433
|
+
if (other.candidates.every((t) => declines((t as { guard?: unknown }).guard, seat.sender.id))) continue;
|
|
434
|
+
const ownerNode = ownerOf(other.node, machine);
|
|
435
|
+
// Under the Rest rule (ADR-0024) a party seated BELOW the Agent's state keeps that state
|
|
436
|
+
// busy while it is active, so every pick of the Agent is refused there, whatever its guard:
|
|
437
|
+
// the Agent never sends this name while the deeper writing is active, and no `sentBy` on it
|
|
438
|
+
// would change that. Nothing to warn about.
|
|
439
|
+
if (
|
|
440
|
+
seat.sender.party === "agent" &&
|
|
441
|
+
ownerNode !== undefined &&
|
|
442
|
+
ownerNode !== seat.node &&
|
|
443
|
+
isAncestorOrSelf(seat.node, ownerNode)
|
|
444
|
+
)
|
|
445
|
+
continue;
|
|
446
|
+
const owners = ownerNode ? partiesAt(ownerNode, machine) : [];
|
|
447
|
+
const owner = owners.find((p) => p.id !== seat.sender.id) ?? owners[0];
|
|
448
|
+
const named = owner?.party === "gate" && derivedGateIds.has(owner.id);
|
|
449
|
+
warnings.set(
|
|
450
|
+
`party:${seat.sender.party}:${seat.sender.id}:${other.name}:${other.node.id}`,
|
|
451
|
+
`"${own.name}" on "${own.node.id}" (${senderText(seat.sender)}) and on "${other.node.id}" ` +
|
|
452
|
+
`(${owners.map(senderText).join(" and ")}) can be active together${parallel ? " (parallel regions)" : ""}; ` +
|
|
453
|
+
`${senderText(seat.sender)}'s "${own.name}" would run ${owner ? `${senderText(owner)}'s` : "the other"} writing` +
|
|
454
|
+
`${parallel ? " too" : ""}, so jr2 refuses it while both are active. Make the ` +
|
|
455
|
+
`${parallel ? `writing on "${other.node.id}"` : "deeper writing"} decline other senders:\n` +
|
|
456
|
+
correctedLine(other, owner?.id ?? "<its party>") +
|
|
457
|
+
(named
|
|
458
|
+
? `\n(the Gate's invoke id "${owner!.id}" is derived from its state; give its invoke an \`id\` to name it)`
|
|
459
|
+
: ""),
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return [...warnings.values()];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** How a sender reads in a message: `Agent "coder"`, `Gate "review"`. */
|
|
468
|
+
export const senderText = (s: Sender): string => (s.party === "agent" ? `Agent "${s.id}"` : `Gate "${s.id}"`);
|