@jr2/orchestrator 0.1.0

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bin/server.ts +23 -0
  4. package/console/canvas.ts +843 -0
  5. package/console/components/app.ts +79 -0
  6. package/console/components/drawer.ts +131 -0
  7. package/console/components/fleet.ts +117 -0
  8. package/console/components/machine-pane.ts +85 -0
  9. package/console/components/nav.ts +81 -0
  10. package/console/components/schema-form.ts +137 -0
  11. package/console/main.ts +383 -0
  12. package/console/page.html +28 -0
  13. package/console/store.ts +336 -0
  14. package/console/style.css +700 -0
  15. package/console/tsconfig.json +18 -0
  16. package/package.json +61 -0
  17. package/src/actor.ts +562 -0
  18. package/src/agent.ts +124 -0
  19. package/src/ambient.ts +50 -0
  20. package/src/config.ts +297 -0
  21. package/src/customize.ts +348 -0
  22. package/src/durability.ts +135 -0
  23. package/src/fingerprint.ts +92 -0
  24. package/src/gate.ts +76 -0
  25. package/src/harness-client.ts +503 -0
  26. package/src/http.ts +753 -0
  27. package/src/images.ts +303 -0
  28. package/src/index.ts +40 -0
  29. package/src/instance.ts +294 -0
  30. package/src/machine-doc.ts +334 -0
  31. package/src/names.ts +78 -0
  32. package/src/open.ts +17 -0
  33. package/src/parts.ts +500 -0
  34. package/src/pool.ts +284 -0
  35. package/src/registration.ts +340 -0
  36. package/src/repo-fetch.ts +259 -0
  37. package/src/repo-identity.ts +145 -0
  38. package/src/repos.ts +330 -0
  39. package/src/run-host.ts +1095 -0
  40. package/src/sandbox-kubectl.ts +1136 -0
  41. package/src/server.ts +220 -0
  42. package/src/setup.ts +360 -0
  43. package/src/snapshot-store.ts +150 -0
  44. package/src/stub-harness.ts +217 -0
  45. package/src/tokens.ts +126 -0
  46. package/src/vocabulary.ts +99 -0
  47. package/src/wire.ts +103 -0
  48. package/src/workspace.ts +874 -0
  49. package/tsconfig.instance.json +26 -0
@@ -0,0 +1,843 @@
1
+ // The Machine canvas — the Console's one imperative island (ADR-0034). elk's layout is async and
2
+ // pan/zoom is a 60Hz transform; neither wants a vdom, so this module keeps the hand-built SVG
3
+ // pipeline: machine doc -> elk graph -> nested <g> boxes, live-highlighted by the selected run,
4
+ // gate-pinned (ADR-0032), foldable per child-machine subgraph. A component
5
+ // (components/machine-pane.ts) owns the <svg> by ref, hands it over through `initCanvas` once, and
6
+ // re-runs `updateCanvas` in an effect; everything below that ref line is direct DOM.
7
+ //
8
+ // Working state only, never belief: the fold set, the viewport transform, and what is SHOWN live
9
+ // here — everything the page believes stays in store.ts and arrives as `updateCanvas` arguments.
10
+
11
+ import type { GateCard, ObservedRun } from "./store.ts";
12
+
13
+ // ---- The machine doc, as `GET /workflows/:name/machine` serves it -------------------------------
14
+ // Mirrors machine-doc.ts (the server's serializer) the way store.ts mirrors `RunObservation`: the
15
+ // Console typechecks as its own DOM project, so the wire shape is restated here rather than
16
+ // imported across the Node boundary.
17
+
18
+ /** One transition of the Machine, id-addressed at both ends — mirrors `MachineTransitionDoc`. */
19
+ export type MachineTransitionDoc = {
20
+ source: string;
21
+ /** Target state ids; empty = targetless (self/internal) transition. */
22
+ targets: string[];
23
+ event: string;
24
+ label: string;
25
+ guard?: string;
26
+ kind: "event" | "always" | "after" | "done" | "error";
27
+ };
28
+
29
+ /** One state of the Machine; `states` nests in document order — mirrors `MachineStateDoc`. */
30
+ export type MachineStateDoc = {
31
+ id: string;
32
+ /** Relative key — the segment that appears in a run's `status.value`. */
33
+ key: string;
34
+ type: "atomic" | "compound" | "parallel" | "final" | "history";
35
+ initial?: string;
36
+ invoke: Array<{ id: string; src: string }>;
37
+ tags: string[];
38
+ description?: string;
39
+ states: MachineStateDoc[];
40
+ /** The child MACHINES this state runs — see {@link ChildMachineDoc}. */
41
+ children: ChildMachineDoc[];
42
+ opaqueActions?: boolean;
43
+ };
44
+
45
+ /** A child Machine reached from a state. `src` is the JOIN KEY: it matches a live `RunChild.src`
46
+ * exactly, which is how run state hangs under the right subgraph — mirrors `ChildMachineDoc`. */
47
+ export type ChildMachineDoc = {
48
+ src: string;
49
+ label: string;
50
+ via: "invoke" | "spawn";
51
+ /** The child's own structure. Absent iff `recursive`. */
52
+ machine?: MachineBodyDoc;
53
+ recursive?: true;
54
+ };
55
+
56
+ /** One event a Machine declares — its Vocabulary entry (ADR-0011) — mirrors `MachineEventDoc`. */
57
+ export type MachineEventDoc = {
58
+ name: string;
59
+ description?: string;
60
+ audience: "agent" | "external" | "any";
61
+ /** The def's input schema as JSON Schema. */
62
+ input: unknown;
63
+ };
64
+
65
+ /** One Machine's structure, independent of what NAMES it — mirrors `MachineBodyDoc`. */
66
+ export type MachineBodyDoc = {
67
+ id: string;
68
+ root: MachineStateDoc;
69
+ transitions: MachineTransitionDoc[];
70
+ /** The events THIS Machine declares, and only this one (ADR-0049): a nested Machine's ride its
71
+ * own body doc, because event names are scoped to the Machine that declared them. */
72
+ events: MachineEventDoc[];
73
+ };
74
+
75
+ /** The serialized structure of a workflow's Machine — mirrors `MachineDoc`. */
76
+ export type MachineDoc = MachineBodyDoc & {
77
+ workflow: string;
78
+ /** States whose child list may be incomplete (enqueueActions closures) — surfaced as a notice. */
79
+ opaqueStates?: string[];
80
+ };
81
+
82
+ /** One live child actor as the observation band reports it — `ObservedRun.children`, deepened
83
+ * (store.ts keeps the field loose; the canvas is the one consumer that walks it). */
84
+ export type RunChild = {
85
+ id: string;
86
+ src: string;
87
+ status: string;
88
+ value: unknown;
89
+ children: RunChild[];
90
+ };
91
+
92
+ // ---- elk (window.ELK — the UMD bundle the shell loads before this module) -----------------------
93
+
94
+ type ElkPoint = { x: number; y: number };
95
+ type ElkLabel = { text: string; width?: number; height?: number; x?: number; y?: number };
96
+ type ElkEdge = {
97
+ id: string;
98
+ sources: string[];
99
+ targets: string[];
100
+ /** Our own annotation, passed through the layout untouched. */
101
+ kind: string;
102
+ labels?: ElkLabel[];
103
+ sections?: Array<{ startPoint: ElkPoint; bendPoints?: ElkPoint[]; endPoint: ElkPoint }>;
104
+ };
105
+ type ElkNode = {
106
+ id: string;
107
+ width?: number;
108
+ height?: number;
109
+ x?: number;
110
+ y?: number;
111
+ children?: ElkNode[];
112
+ edges?: ElkEdge[];
113
+ layoutOptions?: Record<string, string>;
114
+ };
115
+ /** A node as the layout returns it — every geometry field filled in. */
116
+ type LaidNode = ElkNode & { x: number; y: number; width: number; height: number };
117
+
118
+ declare const ELK: new () => {
119
+ layout(graph: ElkNode): Promise<ElkNode & { width: number; height: number }>;
120
+ };
121
+
122
+ // ---- The component seam -------------------------------------------------------------------------
123
+
124
+ /** What the canvas reports OUT: a box click (selection is the store's), and the zoom readout. */
125
+ export type CanvasHooks = {
126
+ onSelectNode(nodeId: string): void;
127
+ onScale(pct: number): void;
128
+ };
129
+
130
+ // Set once by `initCanvas`, before anything below can run — the owning component mounts first.
131
+ let canvasEl: HTMLElement;
132
+ let svg: SVGSVGElement;
133
+ let hooks: CanvasHooks;
134
+
135
+ /** Take ownership of the mounted elements and wire the direct navigation. Called once, on mount. */
136
+ export function initCanvas(els: { canvas: HTMLElement; svg: SVGSVGElement }, canvasHooks: CanvasHooks): void {
137
+ canvasEl = els.canvas;
138
+ svg = els.svg;
139
+ hooks = canvasHooks;
140
+ wireCanvasNavigation();
141
+ addEventListener("resize", () => {
142
+ if (fitting) fitToWidth();
143
+ });
144
+ }
145
+
146
+ const SVG_NS = "http://www.w3.org/2000/svg";
147
+
148
+ function svgEl<K extends keyof SVGElementTagNameMap>(
149
+ tag: K,
150
+ attrs: Record<string, string | number> = {},
151
+ parent?: Element,
152
+ ): SVGElementTagNameMap[K] {
153
+ const el = document.createElementNS(SVG_NS, tag);
154
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
155
+ if (parent) parent.appendChild(el);
156
+ return el;
157
+ }
158
+
159
+ // ---- Sizing (monospace estimate; generous so labels never clip) --------------------------------
160
+
161
+ const CHAR_W = 7.5;
162
+ const ROW_H = 16;
163
+ const textW = (s: string): number => s.length * CHAR_W;
164
+
165
+ /** A display row inside a state box. */
166
+ type Row = { text: string; cls: string };
167
+
168
+ /** A guard's suffix. An anonymous guard has no name to show (the doc reports it as "inline"), and
169
+ * nine characters of nothing is real width — an edge label's width is layer spacing. Mark it. */
170
+ const guardSuffix = (guard: string | undefined): string => (guard ? (guard === "inline" ? " [?]" : ` [${guard}]`) : "");
171
+
172
+ /** An invoke's display name. A `src` is the JOIN KEY, not a name: an anonymous actor gets xstate's
173
+ * generated key (`xstate.invoke.0.wrapper.running`), which names the STATE — which is the box the
174
+ * row is already sitting in. Only a setup() actor has a name of its own, which since ADR-0049 is
175
+ * every actor jr2's own wrappers run (`provision`, `body`, `lease`, `worker`, …). */
176
+ const actorName = (src: string): string => (src.startsWith("xstate.invoke.") ? "inline" : src);
177
+
178
+ /** The display rows inside a state box: invokes, targetless self-transitions, tags. A child MACHINE
179
+ * renders as a nested subgraph, so its invoke row would only say the same thing twice. */
180
+ function stateRows(state: MachineStateDoc, selfTransitions: MachineTransitionDoc[]): Row[] {
181
+ const nested = new Set(state.children.map((c) => c.src));
182
+ const rows: Row[] = [];
183
+ for (const inv of state.invoke) {
184
+ if (!nested.has(inv.src)) rows.push({ text: `⚙ ${actorName(inv.src)}`, cls: "state-row--invoke" });
185
+ }
186
+ for (const t of selfTransitions) {
187
+ rows.push({ text: `↺ ${t.label}${guardSuffix(t.guard)}`, cls: "state-row--self" });
188
+ }
189
+ if (state.tags.length) {
190
+ rows.push({ text: state.tags.map((t) => `#${t}`).join(" "), cls: "state-row--tags" });
191
+ }
192
+ return rows;
193
+ }
194
+
195
+ // ---- Machine doc -> elk input -------------------------------------------------------------------
196
+ //
197
+ // SCOPES. A node's elk (and SVG) id is `${scope}${state.id}`. The root machine's scope is "", and
198
+ // every child-machine subgraph opens a new one. That is the whole trick behind rendering the same
199
+ // child machine several times over: with three features in flight, `featureWorkspace` appears three
200
+ // times, under scopes "F-1/", "F-2/", "F-3/", and each copy highlights from its OWN snapshot.
201
+ //
202
+ // A child machine with nothing running gets ONE subgraph under a "~src/" scope, dimmed — the
203
+ // structure is the point, and it is exactly as true with no run as with three.
204
+ //
205
+ // The machine doc says which state runs which `src`; the run's `RunChild` tree says which instances
206
+ // exist. They meet on `src` and nowhere else.
207
+
208
+ /** Which subgraph scopes the reader has folded shut (a wide fan-out gets unreadable fast). */
209
+ const collapsed = new Set<string>();
210
+
211
+ /** What the renderer knows about one elk node beyond its geometry. */
212
+ type NodeMeta = {
213
+ state?: MachineStateDoc;
214
+ child?: { label: string; template: boolean; scope?: string };
215
+ rows: Row[];
216
+ };
217
+
218
+ function buildElkGraph(doc: MachineBodyDoc, live: RunChild[]): { meta: Map<string, NodeMeta>; graph: ElkNode } {
219
+ const meta = new Map<string, NodeMeta>();
220
+ const edges: ElkEdge[] = [];
221
+
222
+ /** One machine level: the boxes for its root's states, and its own transitions as edges. */
223
+ const machineChildren = (body: MachineBodyDoc, scope: string, instances: RunChild[]): ElkNode[] => {
224
+ const nodes = body.root.states.map((state) => toElkNode(state, scope, body, instances));
225
+ if (body.root.initial) nodes.push(initialDot(body.root, scope));
226
+ body.transitions.forEach((t, i) => {
227
+ // Machine-level transitions (source = the root, which has no box) fire from ANY state, so an
228
+ // edge would lie. At the root they render as a strip above the Machine; in a child, as rows.
229
+ if (t.source === body.root.id) return;
230
+ t.targets.forEach((target, j) => {
231
+ const label = `${t.label}${guardSuffix(t.guard)}`;
232
+ edges.push({
233
+ id: `${scope}t${i}.${j}`,
234
+ sources: [scope + t.source],
235
+ targets: [scope + target],
236
+ kind: t.kind,
237
+ labels: [{ text: label, width: textW(label) + 4, height: 14 }],
238
+ });
239
+ });
240
+ });
241
+ return nodes;
242
+ };
243
+
244
+ const initialDot = (node: MachineStateDoc, scope: string): ElkNode => {
245
+ edges.push({
246
+ id: `${scope}${node.id}::initial-edge`,
247
+ sources: [`${scope}${node.id}::initial`],
248
+ targets: [scope + node.initial!],
249
+ kind: "initial",
250
+ });
251
+ return { id: `${scope}${node.id}::initial`, width: 12, height: 12 };
252
+ };
253
+
254
+ /** A container box: header rows on top, laid-out children below.
255
+ *
256
+ * `nodeSize.minimum` is honoured for a LEAF but not for a compound node — elk sizes those from
257
+ * their children, so a box can come back narrower than its own header asked for. The renderer
258
+ * clips the text to the box it lands in ({@link fitText}) rather than let it hang over the edge. */
259
+ const container = (id: string, title: string, rows: Row[], children: ElkNode[]): ElkNode => {
260
+ const headerW = Math.max(textW(title) + 28, ...rows.map((r) => textW(r.text) + 28), 76);
261
+ const headerH = 26 + rows.length * ROW_H;
262
+ if (!children.length) return { id, width: headerW, height: Math.max(headerH + 10, 40) };
263
+ return {
264
+ id,
265
+ children,
266
+ layoutOptions: {
267
+ "elk.padding": `[top=${headerH + 8},left=16,bottom=16,right=16]`,
268
+ "elk.spacing.nodeNode": "26",
269
+ "elk.layered.spacing.nodeNodeBetweenLayers": "48",
270
+ "elk.nodeSize.constraints": "MINIMUM_SIZE",
271
+ "elk.nodeSize.minimum": `(${headerW},${headerH + 24})`,
272
+ },
273
+ };
274
+ };
275
+
276
+ /** The subgraphs for one child machine: one per live instance, or a lone template when none. */
277
+ const childMachineNodes = (cm: ChildMachineDoc, scope: string, live: RunChild[]): ElkNode[] => {
278
+ if (!cm.machine) {
279
+ // Recursive: the body is an ancestor of this point, so it is already on screen above.
280
+ const id = `${scope}~${cm.src}`;
281
+ meta.set(id, { child: { label: `▣ ${cm.label} ⟲ recursive`, template: true }, rows: [] });
282
+ return [container(id, `▣ ${cm.label} ⟲ recursive`, [], [])];
283
+ }
284
+ const instances = live.filter((c) => c.src === cm.src);
285
+ if (!instances.length) return [childMachineNode(cm, `~${cm.src}/`, scope, null)];
286
+ const nodes = instances.map((inst) => childMachineNode(cm, `${inst.id}/`, scope, inst));
287
+ // Instances of one child machine have no edges between them, and edgeless peers land in the
288
+ // SAME layer — which a DOWN layout spreads across the width. Six features would be six columns.
289
+ // A layout-only edge (never drawn) puts each instance in the layer below the last, so fan-out
290
+ // grows the axis the page scrolls and the diagram's width does not depend on how many are live.
291
+ for (let i = 1; i < nodes.length; i++) {
292
+ edges.push({
293
+ id: `${scope}~stack.${cm.src}.${i}`,
294
+ sources: [nodes[i - 1]!.id],
295
+ targets: [nodes[i]!.id],
296
+ kind: "stack",
297
+ });
298
+ }
299
+ return nodes;
300
+ };
301
+
302
+ const childMachineNode = (
303
+ cm: ChildMachineDoc,
304
+ segment: string,
305
+ parentScope: string,
306
+ inst: RunChild | null,
307
+ ): ElkNode => {
308
+ const scope = parentScope + segment;
309
+ const body = cm.machine!;
310
+ const id = scope + body.root.id;
311
+ // An INVOKED child's spawn id is its invoke id, so naming the instance would just stutter
312
+ // ("body · body"). A SPAWNED one's id is the workflow's own label for the work ("F-1").
313
+ const named = inst && inst.id !== cm.label;
314
+ const title = `▣ ${cm.label}${named ? ` · ${inst.id}` : ""}`;
315
+
316
+ const rows: Row[] = [];
317
+ if (!inst) rows.push({ text: "no live instances", cls: "state-row--dim" });
318
+ if (inst && inst.status !== "active") rows.push({ text: inst.status, cls: "state-row--tags" });
319
+ if (cm.via === "spawn") rows.push({ text: "spawned — outlives this state", cls: "state-row--dim" });
320
+ for (const t of body.transitions) {
321
+ if (t.source === body.root.id) rows.push({ text: `↺ ${t.label}`, cls: "state-row--self" });
322
+ }
323
+
324
+ meta.set(id, { child: { label: title, template: !inst, scope }, rows });
325
+ const folded = collapsed.has(scope);
326
+ return container(id, title, rows, folded ? [] : machineChildren(body, scope, inst?.children ?? []));
327
+ };
328
+
329
+ const toElkNode = (state: MachineStateDoc, scope: string, body: MachineBodyDoc, live: RunChild[]): ElkNode => {
330
+ const selfT = body.transitions.filter((t) => t.source === state.id && t.targets.length === 0);
331
+ const rows = stateRows(state, selfT);
332
+ meta.set(scope + state.id, { state, rows });
333
+
334
+ const children = state.states.map((child) => toElkNode(child, scope, body, live));
335
+ if (state.initial) children.push(initialDot(state, scope));
336
+ // The state that RUNS a child machine is the state that CONTAINS it. `discover` is atomic and
337
+ // still grows a subgraph per feature it spawned — that is the point of the diagram.
338
+ for (const cm of state.children) children.push(...childMachineNodes(cm, scope, live));
339
+
340
+ const title = state.type === "history" ? `⟲ ${state.key}` : state.key;
341
+ return container(scope + state.id, title, rows, children);
342
+ };
343
+
344
+ // The root machine has no box of its own — it renders as the page. Its states are the top level.
345
+ const rootChildren = machineChildren(doc, "", live);
346
+ meta.set(doc.root.id, { state: doc.root, rows: [] });
347
+
348
+ return {
349
+ meta,
350
+ graph: {
351
+ id: "::root",
352
+ layoutOptions: {
353
+ "elk.algorithm": "layered",
354
+ // DOWN, because a Machine's chains are its long axis and nesting stacks them: `discover` ⊃
355
+ // `featureWorkspace` ⊃ `running` ⊃ `body` ⊃ its whole pipeline, all pointing one way under
356
+ // INCLUDE_CHILDREN's single layering. RIGHT spent that on width (7000px for `coding`, in a
357
+ // 950px-tall page); DOWN spends it on the axis a browser scrolls.
358
+ "elk.direction": "DOWN",
359
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
360
+ // Report every edge's coordinates root-relative; the default (CONTAINER) is relative to the
361
+ // edge's deepest common ancestor, which the flat edge pass below doesn't track.
362
+ "elk.json.edgeCoords": "ROOT",
363
+ "elk.spacing.nodeNode": "30",
364
+ "elk.layered.spacing.nodeNodeBetweenLayers": "56",
365
+ "elk.spacing.edgeLabel": "4",
366
+ "elk.edgeLabels.placement": "CENTER",
367
+ },
368
+ children: rootChildren,
369
+ edges,
370
+ },
371
+ };
372
+ }
373
+
374
+ // ---- Layouted elk graph -> SVG ------------------------------------------------------------------
375
+
376
+ const stateEls = new Map<string, SVGGElement>(); // elk node id (scope + state id) -> <g>
377
+
378
+ /** Write text into a box, cut to the width the box actually got (elk sizes a compound node from its
379
+ * children, so the header it asked for is not always the header it gets). The whole string stays
380
+ * reachable on hover. */
381
+ function fitText(el: SVGTextElement, text: string, boxWidth: number): void {
382
+ const max = Math.max(3, Math.floor((boxWidth - 20) / CHAR_W));
383
+ el.textContent = text.length > max ? `${text.slice(0, max - 1)}…` : text;
384
+ if (text.length > max) svgEl("title", {}, el).textContent = text;
385
+ }
386
+
387
+ /** The header + rows shared by a state box and a child-machine subgraph. Clicking any box selects
388
+ * it — outline only, one node at a time, the behavior itself RESERVED (brief: nothing else hangs
389
+ * off it yet). `stopPropagation`, or a click on a leaf would select its every ancestor in turn and
390
+ * the deepest dispatch would win by accident rather than by choice. */
391
+ function renderBox(node: LaidNode, title: string, rows: Row[], parent: SVGElement, cls: string): SVGGElement {
392
+ const g = svgEl("g", { class: cls, transform: `translate(${node.x},${node.y})` }, parent);
393
+ stateEls.set(node.id, g);
394
+ svgEl("rect", { width: node.width, height: node.height, rx: 6 }, g);
395
+ fitText(svgEl("text", { class: "state-title", x: 12, y: 18 }, g), title, node.width);
396
+ rows.forEach((row, i) => {
397
+ const r = svgEl("text", { class: `state-row ${row.cls}`, x: 12, y: 18 + (i + 1) * ROW_H }, g);
398
+ fitText(r, row.text, node.width);
399
+ });
400
+ g.addEventListener("click", (e) => {
401
+ e.stopPropagation();
402
+ hooks.onSelectNode(node.id);
403
+ });
404
+ return g;
405
+ }
406
+
407
+ function renderState(node: LaidNode, meta: Map<string, NodeMeta>, parent: SVGElement): void {
408
+ if (node.id.endsWith("::initial")) {
409
+ const g = svgEl("g", { transform: `translate(${node.x},${node.y})` }, parent);
410
+ svgEl("circle", { class: "initial-dot", cx: 6, cy: 6, r: 5.5 }, g);
411
+ return;
412
+ }
413
+ const { state, child, rows } = meta.get(node.id)!;
414
+
415
+ // A child machine: its own subgraph, one per live instance (or a dimmed template). The ⊞/⊟ icon
416
+ // in its top-right corner folds it — a `maxConcurrent` of 6 is six copies of the same diagram
417
+ // otherwise. The icon and NOT the box: the box body is the click-to-select surface like any other
418
+ // node's, and a reader inspecting a subgraph must not collapse it under their own cursor.
419
+ if (child) {
420
+ const cls = `state child-machine${child.template ? " child-machine--template" : ""}${
421
+ child.scope && collapsed.has(child.scope) ? " child-machine--collapsed" : ""
422
+ }`;
423
+ const g = renderBox(node, child.label, rows, parent, cls);
424
+ for (const c of (node.children ?? []) as LaidNode[]) renderState(c, meta, g);
425
+ if (child.scope) renderFoldIcon(g, node, child.scope);
426
+ return;
427
+ }
428
+
429
+ const title = state!.type === "history" ? `⟲ ${state!.key}` : state!.key;
430
+ const g = renderBox(node, title, rows, parent, `state state--${state!.type}`);
431
+ if (state!.type === "final") {
432
+ svgEl("rect", { class: "final-inner", x: 3, y: 3, rx: 4, width: node.width - 6, height: node.height - 6 }, g);
433
+ }
434
+ for (const c of (node.children ?? []) as LaidNode[]) renderState(c, meta, g);
435
+ }
436
+
437
+ /** How much of a child-machine box's top-right corner the fold icon owns (the gate pin yields). */
438
+ const FOLD_ICON_W = 26;
439
+
440
+ /** The ⊞/⊟ fold control in a child-machine box's top-right corner. Appended after the subgraph's
441
+ * children so nothing paints over it; `stopPropagation`, or folding would also select the box. */
442
+ function renderFoldIcon(g: SVGGElement, node: LaidNode, scope: string): void {
443
+ const folded = collapsed.has(scope);
444
+ const icon = svgEl("g", { class: "fold-icon", transform: `translate(${node.width - 21},5)` }, g);
445
+ svgEl("rect", { width: 16, height: 16, rx: 3 }, icon);
446
+ const glyph = svgEl("text", { x: 8, y: 12.5, "text-anchor": "middle" }, icon);
447
+ glyph.textContent = folded ? "⊞" : "⊟";
448
+ svgEl("title", {}, icon).textContent = folded ? "unfold this child machine" : "fold this child machine";
449
+ icon.addEventListener("click", (e) => {
450
+ e.stopPropagation();
451
+ folded ? collapsed.delete(scope) : collapsed.add(scope);
452
+ void refresh();
453
+ });
454
+ }
455
+
456
+ function renderEdges(layout: ElkNode, parent: SVGElement): void {
457
+ for (const edge of layout.edges ?? []) {
458
+ if (edge.kind === "stack") continue; // layout-only: it stacks sibling instances, it is not a transition
459
+ const kind = edge.kind ?? "event";
460
+ const g = svgEl("g", { class: `edge edge--${kind}` }, parent);
461
+ for (const s of edge.sections ?? []) {
462
+ const pts = [s.startPoint, ...(s.bendPoints ?? []), s.endPoint];
463
+ const d = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
464
+ svgEl("path", { d, "marker-end": `url(#arrow-${kind})` }, g);
465
+ }
466
+ for (const label of edge.labels ?? []) {
467
+ const t = svgEl("text", { class: "edge-label", x: label.x ?? 0, y: (label.y ?? 0) + 11 }, g);
468
+ t.textContent = label.text;
469
+ }
470
+ }
471
+ }
472
+
473
+ function renderArrowDefs(target: SVGSVGElement): void {
474
+ const defs = svgEl("defs", {}, target);
475
+ for (const kind of ["event", "always", "after", "done", "error", "initial"]) {
476
+ const m = svgEl(
477
+ "marker",
478
+ {
479
+ id: `arrow-${kind}`,
480
+ class: `arrow--${kind}`,
481
+ viewBox: "0 0 10 10",
482
+ refX: 9,
483
+ refY: 5,
484
+ markerWidth: 7,
485
+ markerHeight: 7,
486
+ orient: "auto-start-reverse",
487
+ },
488
+ defs,
489
+ );
490
+ svgEl("path", { d: "M0,0 L10,5 L0,10 z" }, m);
491
+ }
492
+ }
493
+
494
+ let layoutSize = { width: 0, height: 0 };
495
+ /** The viewport: the diagram draws `scale`d with its origin at (panX, panY), as one transform on
496
+ * the root <g> the renderer mounts. A TRANSFORM, not a scroll container — scrolling cannot move a
497
+ * diagram that happens to fit its pane, and a diagram pane must pan regardless. */
498
+ let scale = 1;
499
+ let panX = 0;
500
+ let panY = 0;
501
+ /** Fit the Machine's width to the canvas, and keep fitting it as the diagram grows — until the
502
+ * reader takes the view into their own hands, after which it is theirs. */
503
+ let fitting = true;
504
+ /** The <g> the current render mounted — the transform target; each re-render swaps it in and
505
+ * re-applies the view, so a relayout never resets where the reader was looking. */
506
+ let viewportG: SVGGElement | undefined;
507
+
508
+ /** Breathing room a fresh fit leaves around the diagram. */
509
+ const FIT_PAD = 28;
510
+
511
+ function applyView(): void {
512
+ viewportG?.setAttribute("transform", `translate(${panX} ${panY}) scale(${scale})`);
513
+ hooks.onScale(Math.round(scale * 100)); // the nav readout follows every path
514
+ }
515
+
516
+ /** Scale so the whole width lands in the pane, centered. Never magnifies — a small Machine stays
517
+ * 1:1. */
518
+ function fitToWidth(): void {
519
+ if (!layoutSize.width || svg.clientWidth <= 0) return applyView();
520
+ scale = Math.min(1, Math.max(0.2, (svg.clientWidth - FIT_PAD * 2) / layoutSize.width));
521
+ panX = Math.max(FIT_PAD, (svg.clientWidth - layoutSize.width * scale) / 2);
522
+ panY = FIT_PAD;
523
+ applyView();
524
+ }
525
+
526
+ /** Re-scale about an anchor (svg px; the pane's center when none, for the nav buttons): the
527
+ * diagram point under the anchor stays under it. */
528
+ function setScale(next: number, anchor?: { x: number; y: number }): void {
529
+ fitting = false;
530
+ const a = anchor ?? { x: svg.clientWidth / 2, y: svg.clientHeight / 2 };
531
+ const clamped = Math.min(4, Math.max(0.25, next));
532
+ panX = a.x - ((a.x - panX) / scale) * clamped;
533
+ panY = a.y - ((a.y - panY) / scale) * clamped;
534
+ scale = clamped;
535
+ applyView();
536
+ }
537
+
538
+ // The nav's zoom controls — thin verbs over the viewport, exported for the components.
539
+ export function zoomIn(): void {
540
+ setScale(scale * 1.2);
541
+ }
542
+ export function zoomOut(): void {
543
+ setScale(scale / 1.2);
544
+ }
545
+ export function zoomReset(): void {
546
+ setScale(1);
547
+ }
548
+ export function zoomFit(): void {
549
+ fitting = true;
550
+ fitToWidth();
551
+ }
552
+
553
+ // ---- Direct navigation: drag pans, wheel zooms --------------------------------------------------
554
+ //
555
+ // Both are writes to the viewport transform above. View state like the zoom buttons — none of it
556
+ // is belief, none reaches the store.
557
+
558
+ /** How far a pressed pointer may wander and still be a click on a node, not a pan. */
559
+ const PAN_NUDGE_PX = 4;
560
+
561
+ /** Set when a pan just ended: the browser fires a click at the release point, and that click must
562
+ * not select (or fold) whatever box the drag happened to end on. Cleared by the very next click or
563
+ * press, so a pan that ends off-window cannot eat an unrelated later click. */
564
+ let squelchClick = false;
565
+
566
+ function wireCanvasNavigation(): void {
567
+ addEventListener(
568
+ "click",
569
+ (e) => {
570
+ if (!squelchClick) return;
571
+ squelchClick = false;
572
+ e.stopPropagation();
573
+ e.preventDefault();
574
+ },
575
+ true,
576
+ );
577
+
578
+ canvasEl.addEventListener(
579
+ "wheel",
580
+ (e) => {
581
+ e.preventDefault(); // the wheel zooms here; travel is the drag's job
582
+ const perLine = e.deltaMode === 1 ? 16 : 1; // Firefox reports lines, not pixels
583
+ const rect = svg.getBoundingClientRect();
584
+ setScale(scale * Math.exp(-e.deltaY * perLine * 0.0015), {
585
+ x: e.clientX - rect.left,
586
+ y: e.clientY - rect.top,
587
+ });
588
+ },
589
+ { passive: false },
590
+ );
591
+
592
+ canvasEl.addEventListener("pointerdown", (e) => {
593
+ if (e.button !== 0) return;
594
+ squelchClick = false;
595
+ const from = { x: e.clientX, y: e.clientY, panX, panY };
596
+ let panned = false;
597
+ const move = (ev: PointerEvent): void => {
598
+ if (!panned && Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < PAN_NUDGE_PX) return;
599
+ if (!panned) {
600
+ panned = true;
601
+ fitting = false; // the reader took the view into their own hands
602
+ canvasEl.classList.add("panning");
603
+ canvasEl.setPointerCapture(e.pointerId);
604
+ }
605
+ panX = from.panX + (ev.clientX - from.x);
606
+ panY = from.panY + (ev.clientY - from.y);
607
+ applyView();
608
+ };
609
+ const up = (): void => {
610
+ canvasEl.removeEventListener("pointermove", move);
611
+ canvasEl.removeEventListener("pointerup", up);
612
+ canvasEl.removeEventListener("pointercancel", up);
613
+ canvasEl.classList.remove("panning");
614
+ squelchClick = panned;
615
+ };
616
+ canvasEl.addEventListener("pointermove", move);
617
+ canvasEl.addEventListener("pointerup", up);
618
+ canvasEl.addEventListener("pointercancel", up);
619
+ });
620
+ }
621
+
622
+ async function renderMachine(doc: MachineBodyDoc, live: RunChild[]): Promise<void> {
623
+ const { graph, meta } = buildElkGraph(doc, live);
624
+ const layout = await new ELK().layout(graph);
625
+ // The selection may have moved while elk worked (`clearCanvas` ran, or a newer doc landed):
626
+ // painting now would put the machine the reader LEFT into the pane they arrived at. Bail before
627
+ // touching the svg — whatever should be there has its own refresh queued behind this one.
628
+ if (shown.doc !== doc) return;
629
+ svg.textContent = "";
630
+ layoutSize = { width: layout.width + 4, height: layout.height + 4 };
631
+ renderArrowDefs(svg);
632
+ const rootG = svgEl("g", {}, svg);
633
+ stateEls.clear();
634
+ stateEls.set(doc.root.id, rootG);
635
+ for (const child of (layout.children ?? []) as LaidNode[]) renderState(child, meta, rootG);
636
+ // INCLUDE_CHILDREN reports every edge's coordinates relative to the root — draw them all here,
637
+ // never inside a state group.
638
+ renderEdges(layout, rootG);
639
+ // The fresh <g> becomes the viewport, wearing the view the reader already had (or a fit).
640
+ viewportG = rootG;
641
+ fitting ? fitToWidth() : applyView();
642
+ }
643
+
644
+ // ---- Live runs ----------------------------------------------------------------------------------
645
+
646
+ /**
647
+ * Every elk id a run currently has lit: the root machine's active states, and each live child
648
+ * instance's, inside that instance's own scope.
649
+ *
650
+ * Two walks, because they answer different questions. The VALUE walk descends the state tree by key
651
+ * and lights what is active. The INSTANCE walk visits every state, active or not, because a spawned
652
+ * child outlives the state that spawned it — `coding` is parked in `settling` while three
653
+ * `featureWorkspace`s it spawned back in `discover` are still going.
654
+ */
655
+ function activeIds(
656
+ body: MachineBodyDoc,
657
+ value: unknown,
658
+ scope: string,
659
+ live: RunChild[],
660
+ ids = new Set<string>(),
661
+ ): Set<string> {
662
+ ids.add(scope + body.root.id);
663
+
664
+ const byValue = (state: MachineStateDoc, v: unknown): void => {
665
+ if (v == null) return;
666
+ const keys = typeof v === "string" ? [v] : Object.keys(v);
667
+ for (const key of keys) {
668
+ const child = state.states.find((s) => s.key === key);
669
+ if (!child) continue;
670
+ ids.add(scope + child.id);
671
+ if (typeof v !== "string") byValue(child, (v as Record<string, unknown>)[key]);
672
+ }
673
+ };
674
+ byValue(body.root, value);
675
+
676
+ const byInstance = (state: MachineStateDoc): void => {
677
+ for (const cm of state.children) {
678
+ if (!cm.machine) continue;
679
+ for (const inst of live.filter((c) => c.src === cm.src)) {
680
+ activeIds(cm.machine, inst.value, `${scope}${inst.id}/`, inst.children, ids);
681
+ }
682
+ }
683
+ state.states.forEach(byInstance);
684
+ };
685
+ byInstance(body.root);
686
+
687
+ return ids;
688
+ }
689
+
690
+ // ---- The gate pin (ADR-0032) --------------------------------------------------------------------
691
+ // `GateView.path` is the invoking state's actor path below the run root (registration.ts's
692
+ // `actorPath`, carried precisely so no caller ever parses a gate ID): every segment but the last is
693
+ // a child-machine actor id — the SAME ids the renderer mints its scopes from — and the last is the
694
+ // gate's own invoke id, which `deriveMenus` names with the invoking state's key path ("waiting.
695
+ // approval"). So the path resolves against the same two structures the diagram is drawn from, the
696
+ // machine doc and the live child tree, and lands on an elk node id with no third vocabulary.
697
+
698
+ /** The elk node id `path` names, or null when it does not resolve (an authored invoke id, a
699
+ * recursive child's elided body, an instance the run no longer has). Resolution is BEST-EFFORT by
700
+ * design: the leaf descends the state tree key by key and pins the deepest match, so a
701
+ * disambiguating ordinal suffix (two unnamed gates in one state) still finds its state, and a leaf
702
+ * that matches nothing still pins the child-machine box whose scope the earlier segments reached. */
703
+ function gateNodeId(doc: MachineBodyDoc, live: RunChild[], path: string[]): string | null {
704
+ if (!path.length) return null;
705
+ let body = doc;
706
+ let scope = "";
707
+ let children = live;
708
+ for (const seg of path.slice(0, -1)) {
709
+ const inst = children.find((c) => c.id === seg);
710
+ if (!inst) return null;
711
+ const cm = findChildMachine(body.root, inst.src);
712
+ if (!cm?.machine) return null;
713
+ scope += `${inst.id}/`;
714
+ body = cm.machine;
715
+ children = inst.children;
716
+ }
717
+ let node = body.root;
718
+ for (const key of path[path.length - 1]!.split(".")) {
719
+ const child = node.states.find((s) => s.key === key);
720
+ if (!child) break;
721
+ node = child;
722
+ }
723
+ // A machine root: in a child scope that is the subgraph's own box; at the run root there is no
724
+ // box to pin (the root machine renders as the page).
725
+ return node === body.root && !scope ? null : scope + node.id;
726
+ }
727
+
728
+ /** The `ChildMachineDoc` running `src`, anywhere under `state` — the doc side of the join key. */
729
+ function findChildMachine(state: MachineStateDoc, src: string): ChildMachineDoc | null {
730
+ for (const cm of state.children) if (cm.src === src) return cm;
731
+ for (const child of state.states) {
732
+ const hit = findChildMachine(child, src);
733
+ if (hit) return hit;
734
+ }
735
+ return null;
736
+ }
737
+
738
+ /** The deepest RENDERED box for a node id — the id itself, or (when its subgraph is folded shut)
739
+ * the ancestor the key-path spells, stripped a ".key" at a time down to the subgraph's own box.
740
+ * The pin surfaces on whatever the fold left visible instead of vanishing with the fold. */
741
+ function nearestRendered(id: string | null): string | null {
742
+ let cur = id ?? "";
743
+ while (cur && !stateEls.has(cur)) {
744
+ const dot = cur.lastIndexOf(".");
745
+ if (dot <= cur.lastIndexOf("/")) return null;
746
+ cur = cur.slice(0, dot);
747
+ }
748
+ return cur || null;
749
+ }
750
+
751
+ /** Put the ⚑ on a box, or take it off. The glyph is managed here and not in `renderBox` because it
752
+ * outlives no re-layout but must move on every `gates` frame — a class plus a lazily-added element
753
+ * keeps pin churn out of the layout path entirely. */
754
+ function setGatePin(g: SVGGElement, on: boolean): void {
755
+ const pin = g.querySelector(":scope > .gate-pin");
756
+ g.classList.toggle("gated", on);
757
+ if (!on) return pin?.remove();
758
+ if (pin) return;
759
+ const rect = g.querySelector(":scope > rect");
760
+ if (!rect) return;
761
+ const x = Number(rect.getAttribute("width")) - (g.classList.contains("child-machine") ? FOLD_ICON_W + 8 : 8);
762
+ const t = svgEl("text", { class: "gate-pin", x, y: 18, "text-anchor": "end" }, g);
763
+ t.textContent = "⚑";
764
+ svgEl("title", {}, t).textContent = "open gate — this run is waiting for external input here";
765
+ }
766
+
767
+ function highlight(): void {
768
+ if (!shown.doc) return;
769
+ const active = activeIds(shown.doc, shown.status?.value, "", shown.live);
770
+ // The pins are a VIEW over the store (selected run × its inbox card), recomputed whole on every
771
+ // pass — a `gates` frame re-runs `updateCanvas`, so a delivered gate's pin leaves with its card.
772
+ const pinned = new Set<string>();
773
+ for (const view of shown.gates) {
774
+ const target = nearestRendered(gateNodeId(shown.doc, shown.live, view.path ?? []));
775
+ if (target) pinned.add(target);
776
+ }
777
+ for (const [id, el] of stateEls) {
778
+ el.classList.toggle("active", active.has(id));
779
+ el.classList.toggle("selected", id === shown.selectedNodeId);
780
+ setGatePin(el, pinned.has(id));
781
+ }
782
+ }
783
+
784
+ /** The identity of the live child TREE — which instances of what, not where they are. Layout hangs
785
+ * on this and nothing else, so a transition inside a child is a class toggle, never a re-layout. */
786
+ function instanceKey(children: RunChild[]): string {
787
+ return children.map((c) => `${c.src}#${c.id}(${instanceKey(c.children)})`).join(",");
788
+ }
789
+
790
+ /** What the canvas has painted (or queued): the render pipeline's working state, updated as one
791
+ * value so a fold's `refresh` re-runs against exactly what the last `updateCanvas` was handed. */
792
+ type Shown = {
793
+ doc: MachineDoc | null;
794
+ live: RunChild[];
795
+ key: string | null;
796
+ status: ObservedRun | null;
797
+ selectedNodeId: string | null;
798
+ gates: GateCard[];
799
+ };
800
+ let shown: Shown = { doc: null, live: [], key: null, status: null, selectedNodeId: null, gates: [] };
801
+ let queue: Promise<void> = Promise.resolve(); // serializes re-layouts against a burst of status frames
802
+
803
+ /** Re-lay out the Machine, then restore the highlight the new boxes should be wearing. */
804
+ function refresh(): Promise<void> {
805
+ queue = queue
806
+ .then(async () => {
807
+ if (!shown.doc) return; // cleared while queued — nothing left to lay out
808
+ await renderMachine(shown.doc, shown.live);
809
+ highlight();
810
+ })
811
+ // The chain must settle fulfilled: every later refresh() chains onto `queue`, so one rejected
812
+ // link would silently kill re-layout until reload.
813
+ .catch((err: unknown) => console.error("machine layout failed", err));
814
+ return queue;
815
+ }
816
+
817
+ /**
818
+ * The component's effect lands here on every input move: re-lay out only if the DOC or the
819
+ * instance SET changed (a spawn or a stop); everything else — a transition, a node selection, a
820
+ * `gates` frame — is a highlight pass over the boxes already on screen.
821
+ */
822
+ export function updateCanvas(
823
+ doc: MachineDoc,
824
+ status: ObservedRun | null,
825
+ selectedNodeId: string | null,
826
+ gates: GateCard[],
827
+ ): void {
828
+ const live = (status?.children ?? []) as RunChild[];
829
+ const key = instanceKey(live);
830
+ const relayout = doc !== shown.doc || key !== shown.key;
831
+ shown = { doc, live, key, status, selectedNodeId, gates };
832
+ if (relayout) void refresh();
833
+ else highlight();
834
+ }
835
+
836
+ /** Forget the diagram NOW (the selection moved): frames landing before the next doc is in hand
837
+ * must not re-layout — or highlight — the machine the reader just left. */
838
+ export function clearCanvas(): void {
839
+ shown = { doc: null, live: [], key: null, status: null, selectedNodeId: null, gates: [] };
840
+ stateEls.clear();
841
+ viewportG = undefined;
842
+ svg.textContent = "";
843
+ }