@jr2/orchestrator 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -5
  13. package/src/actor.ts +507 -54
  14. package/src/agent.ts +2 -2
  15. package/src/ambient.ts +2 -0
  16. package/src/assembly-checks.ts +165 -0
  17. package/src/assembly-tar.ts +494 -0
  18. package/src/assembly.ts +721 -0
  19. package/src/chart.ts +2050 -0
  20. package/src/charts.ts +720 -0
  21. package/src/config.ts +233 -22
  22. package/src/content-hash.ts +105 -0
  23. package/src/custodian.lua +313 -0
  24. package/src/custodian.ts +951 -0
  25. package/src/customize.ts +216 -25
  26. package/src/effect-checks.ts +557 -0
  27. package/src/effect.ts +905 -0
  28. package/src/effector-wire.ts +525 -0
  29. package/src/fingerprint.ts +4 -3
  30. package/src/gate.ts +8 -0
  31. package/src/git-clone.ts +65 -0
  32. package/src/git-push.ts +336 -0
  33. package/src/harness-client.ts +247 -25
  34. package/src/held-secrets.ts +643 -0
  35. package/src/holds.ts +156 -0
  36. package/src/http-effect.ts +981 -0
  37. package/src/http.ts +514 -119
  38. package/src/images.ts +58 -31
  39. package/src/index.ts +49 -4
  40. package/src/instance.ts +445 -59
  41. package/src/kube-client.ts +333 -0
  42. package/src/machine-doc.ts +88 -6
  43. package/src/model-keys.ts +92 -0
  44. package/src/mount-digest.ts +53 -0
  45. package/src/names.ts +89 -7
  46. package/src/notices.ts +88 -0
  47. package/src/open-body.ts +1444 -0
  48. package/src/open-effect.ts +144 -0
  49. package/src/open.ts +4 -4
  50. package/src/parts.ts +530 -33
  51. package/src/party.ts +468 -0
  52. package/src/pick.ts +309 -0
  53. package/src/pool.ts +1 -1
  54. package/src/registration.ts +301 -17
  55. package/src/repo-fetch.ts +61 -86
  56. package/src/repos.ts +63 -74
  57. package/src/rest.ts +133 -0
  58. package/src/run-host.ts +1841 -105
  59. package/src/sandbox-effect.ts +891 -0
  60. package/src/sandbox-effector.ts +111 -0
  61. package/src/sandbox-kube.ts +1719 -0
  62. package/src/sandbox-watch.ts +260 -0
  63. package/src/secret-placement.ts +494 -0
  64. package/src/server.ts +285 -89
  65. package/src/setup.ts +589 -70
  66. package/src/size.ts +342 -0
  67. package/src/snapshot-store.ts +64 -13
  68. package/src/stub-harness.ts +5 -0
  69. package/src/sync-client.ts +54 -0
  70. package/src/timers.ts +178 -0
  71. package/src/tokens.ts +44 -12
  72. package/src/vocabulary.ts +66 -1
  73. package/src/wire.ts +156 -2
  74. package/src/workspace.ts +529 -194
  75. package/src/sandbox-kubectl.ts +0 -1144
package/console/main.ts CHANGED
@@ -25,9 +25,28 @@
25
25
  // hand-rolled memo()/paint() machinery did. This file is the BOOTSTRAP and owns only working
26
26
  // state — the machine-doc cache, the input-schema cache, the fetch serials, the zoom readout; the
27
27
  // diagram's own working state (fold set, viewport, what is shown) lives in canvas.ts.
28
+ //
29
+ // A run that binds a Chart (ADR-0070) draws ITS Machine, not the Workflow's: the definition's doc
30
+ // has the Open body as one opaque child, and a Chart one cannot watch is a Chart one cannot trust.
31
+ // So the doc the center pane draws is chosen per selection (`machineDocSource`): the Workflow's
32
+ // Machine, or, when the selected run's observation says `bound`, the run's own from
33
+ // `GET /workflows/:name/runs/:runId/machine`, cached per run id. The live child still joins its
34
+ // subgraph by `src`, which the per-run doc keeps as the body's slot key.
28
35
 
29
36
  import { h, render } from "preact";
30
- import { applyFrame, emptyStore, type Frame, type GateCard, type ObservedRun, type Store } from "./store.ts";
37
+ import {
38
+ applyFrame,
39
+ emptyStore,
40
+ machineDocSource,
41
+ selectedRun,
42
+ type Frame,
43
+ type GateCard,
44
+ type ObservedRun,
45
+ type Store,
46
+ type HeldLine,
47
+ type WaitingLine,
48
+ type WorkflowListing,
49
+ } from "./store.ts";
31
50
  import { zoomFit, zoomIn, zoomOut, zoomReset, type MachineDoc } from "./canvas.ts";
32
51
  import { App, type AppApi, type MachineView } from "./components/app.ts";
33
52
 
@@ -49,6 +68,9 @@ function rerender(): void {
49
68
  function dispatch(frame: Frame): void {
50
69
  store = applyFrame(store, frame);
51
70
  rerender();
71
+ // A frame can change which doc the pane draws: a new selection, a run selected, a run that now
72
+ // binds a Chart. The check is a key compare; only a change fetches.
73
+ void syncMachine();
52
74
  }
53
75
 
54
76
  // ---- The token (ADR-0032) -----------------------------------------------------------------------
@@ -98,7 +120,7 @@ async function validateToken(): Promise<void> {
98
120
  dispatch({ kind: "token", state: "live" });
99
121
  const runs = (await res.json()) as Array<{ runId: string; workflow: string }>;
100
122
  if (serial !== tokenSerial) return;
101
- for (const r of runs) void refreshGates(r.runId, r.workflow);
123
+ for (const r of runs) void refreshRun(r.runId, r.workflow);
102
124
  }
103
125
 
104
126
  function setToken(value: string): void {
@@ -112,7 +134,7 @@ function setToken(value: string): void {
112
134
  void validateToken();
113
135
  }
114
136
 
115
- // ---- Gate discovery (frame-triggered — ADR-0032) ------------------------------------------------
137
+ // ---- Gate discovery and waits (frame-triggered — ADR-0032, ADR-0064) ----------------------------
116
138
 
117
139
  /** Per-run re-fetch serials — fetch bookkeeping like `docs`, not belief. The browser runs these
118
140
  * requests on parallel connections, so answers can land out of ORDER: a re-fetch triggered by the
@@ -121,21 +143,25 @@ function setToken(value: string): void {
121
143
  * emptied — pinning the ⚑ and inviting a second delivery until the run's next frame, which a run
122
144
  * parked in a long agent step may not land for minutes. Only the latest-STARTED re-fetch may
123
145
  * speak for a run; superseded answers are dropped before they reach the store. */
124
- const gateFetchSerial = new Map<string, number>();
146
+ const runFetchSerial = new Map<string, number>();
125
147
 
126
148
  /** Re-read one run's open gates off the guarded surface and fold the WHOLE answer in. Called on
127
149
  * every frame that touches the run — that is the synchronization: a gate opens with a state entry
128
150
  * and closes with its exit, and every entry/exit lands a frame. A delivery's success is the next
129
- * re-fetch coming back empty; nothing here concludes anything on its own. */
130
- async function refreshGates(runId: string, workflow: string): Promise<void> {
151
+ * re-fetch coming back empty; nothing here concludes anything on its own. The same answer carries
152
+ * why the run's Workspaces in `placing` wait (ADR-0064), folded as its own frame by the same rules:
153
+ * a Workspace enters and leaves `placing` with a frame too. */
154
+ async function refreshRun(runId: string, workflow: string): Promise<void> {
131
155
  if (store.token !== "live") return; // observer mode: the inbox does not exist
132
- const serial = (gateFetchSerial.get(runId) ?? 0) + 1;
133
- gateFetchSerial.set(runId, serial);
156
+ const serial = (runFetchSerial.get(runId) ?? 0) + 1;
157
+ runFetchSerial.set(runId, serial);
134
158
  const res = await gfetch(`/runs/${encodeURIComponent(runId)}`);
135
159
  if (!res) return; // 401 already dropped the page to observer
136
- const gates = res.ok ? (((await res.json()) as { gates?: GateCard[] }).gates ?? []) : []; // !ok: settled + evicted
137
- if (gateFetchSerial.get(runId) !== serial) return; // superseded — a newer re-fetch owns the answer
138
- dispatch({ kind: "gates", runId, workflow, gates });
160
+ // !ok: settled + evicted — no gates, and nothing waits.
161
+ const body = res.ok ? ((await res.json()) as { gates?: GateCard[]; waiting?: WaitingLine[]; held?: HeldLine[] }) : {};
162
+ if (runFetchSerial.get(runId) !== serial) return; // superseded — a newer re-fetch owns the answer
163
+ dispatch({ kind: "gates", runId, workflow, gates: body.gates ?? [] });
164
+ dispatch({ kind: "waiting", runId, workflow, waiting: body.waiting ?? [], held: body.held ?? [] });
139
165
  }
140
166
 
141
167
  // ---- The two writes (start-run + gate delivery — ADR-0033) --------------------------------------
@@ -215,34 +241,68 @@ async function selectWorkflow(
215
241
  const moved = name !== store.workflow;
216
242
  if (push && moved) history.pushState({}, "", name ? `/workflows/${encodeURIComponent(name)}` : "/");
217
243
  document.title = name ? `jr2 · ${name}` : "jr2 · Console";
218
- dispatch({ kind: "select", workflow: name });
244
+ // Re-selecting the selection (a popstate re-fire) with its diagram in hand keeps the feed.
245
+ const reconnect = moved || !view.doc;
246
+ if (reconnect) {
247
+ feed?.close();
248
+ feed = null;
249
+ // Forget the old diagram NOW, before the `select` frame: the new feed's frames start landing
250
+ // before the new doc is in hand, and they must not re-layout (or highlight) the machine the
251
+ // reader just left — the render reaches the canvas effect, which clears it.
252
+ view = { doc: null, placeholder: name ? null : "select a workflow from the fleet", note: null };
253
+ shownKey = null;
254
+ }
255
+ dispatch({ kind: "select", workflow: name }); // the dispatch starts the doc's fetch (syncMachine)
219
256
  if (runId) dispatch({ kind: "selectRun", runId });
220
- if (!moved && view.doc) return; // re-selecting the selection (a popstate re-fire): nothing to do
221
- feed?.close();
222
- feed = null;
223
- // Forget the old diagram NOW: the new feed's frames start landing before the new doc is in hand,
224
- // and they must not re-layout (or highlight) the machine the reader just left — the render below
225
- // reaches the canvas effect, which clears it.
226
- view = { doc: null, placeholder: name ? null : "select a workflow from the fleet", note: null };
227
- rerender();
228
- if (!name) return;
257
+ if (!reconnect || !name) return;
229
258
  connectFeed(name);
230
- await loadMachine(name);
259
+ await syncMachine();
231
260
  }
232
261
 
233
- /** The selected workflow's Machine docs, kept across navigations — structure does not change under
234
- * a running orchestrator, so going back to a workflow is instant. Misses are NOT cached: a name
235
- * can be registered after this page loaded, and re-selecting it should find it. */
262
+ /** Machine docs, kept across navigations and keyed by `machineDocSource`: a Workflow's doc is one
263
+ * per name (structure does not change under a running orchestrator, so going back is instant), and
264
+ * a run that binds a Chart has its own per run id (ADR-0070). Misses are NOT cached: a name can be
265
+ * registered after this page loaded, and re-selecting it should find it. */
236
266
  const docs = new Map<string, MachineDoc>();
237
267
 
238
- async function loadMachine(name: string): Promise<void> {
239
- let doc: MachineDoc | null | undefined = docs.get(name);
240
- if (doc === undefined) {
241
- const res = await fetch(`/workflows/${encodeURIComponent(name)}/machine`);
242
- doc = res.ok ? ((await res.json()) as MachineDoc) : null;
243
- if (doc) docs.set(name, doc);
268
+ /** The doc key the pane draws or is fetching — so a frame that changes nothing fetches nothing, and
269
+ * an answer for a key the reader has left is dropped. */
270
+ let shownKey: string | null = null;
271
+
272
+ async function fetchDoc(source: { key: string; path: string }): Promise<MachineDoc | null> {
273
+ const cached = docs.get(source.key);
274
+ if (cached) return cached;
275
+ try {
276
+ const res = await fetch(source.path);
277
+ const doc = res.ok ? ((await res.json()) as MachineDoc) : null;
278
+ if (doc) docs.set(source.key, doc);
279
+ return doc;
280
+ } catch {
281
+ return null; // network trouble reads as a miss; the next selection retries
282
+ }
283
+ }
284
+
285
+ /** Bring the pane's doc in line with the selection: the selected run's bound Machine when it binds
286
+ * a Chart, else the Workflow's (`machineDocSource`, ADR-0070). */
287
+ async function syncMachine(): Promise<void> {
288
+ const name = store.workflow;
289
+ if (!name) return;
290
+ const source = machineDocSource(name, selectedRun(store));
291
+ if (source.key === shownKey) return;
292
+ shownKey = source.key;
293
+ let doc = await fetchDoc(source);
294
+ if (shownKey !== source.key) return; // the reader moved on while the fetch was out
295
+ let note: MachineView["note"] = null;
296
+ if (!doc && source.key.startsWith("run:")) {
297
+ // The run's own Machine could not be read (an Orchestrator older than the route, or the run
298
+ // left the store): draw the Workflow's, and say the Chart's states are not in it.
299
+ doc = await fetchDoc(machineDocSource(name, null));
300
+ if (shownKey !== source.key) return;
301
+ note = {
302
+ text: "this run binds a Chart, and its Machine could not be read: the Chart's states are not shown",
303
+ notice: true,
304
+ };
244
305
  }
245
- if (store.workflow !== name) return; // the reader moved on while the fetch was out
246
306
  if (!doc) {
247
307
  view = { doc: null, placeholder: null, note: { text: `no workflow "${name}"`, notice: false } };
248
308
  rerender();
@@ -251,10 +311,21 @@ async function loadMachine(name: string): Promise<void> {
251
311
  // Nothing live yet: every child machine renders once, as a dimmed template (the canvas effect
252
312
  // picks the doc up from here). The first status frame of a run with children swaps those for
253
313
  // one subgraph per instance.
254
- view = { doc, placeholder: null, note: opaqueNotice(doc) };
314
+ view = { doc, placeholder: null, note: note ?? notDrawnNotice(doc) ?? opaqueNotice(doc) };
255
315
  rerender();
256
316
  }
257
317
 
318
+ /** Say so when a run's doc could not draw a Chart the run binds (ADR-0070, D-B): its Open body then
319
+ * shows the slot's own subgraph, and the server names why (`MachineDoc.chartsNotDrawn`). */
320
+ function notDrawnNotice(doc: MachineDoc): MachineView["note"] {
321
+ const missing = doc.chartsNotDrawn ?? [];
322
+ if (!missing.length) return null;
323
+ return {
324
+ notice: true,
325
+ text: `this run binds a Chart this diagram does not draw — ${missing.map((m) => `body "${m.src}" (${m.path}): ${m.reason}`).join("; ")}`,
326
+ };
327
+ }
328
+
258
329
  /** Say so when the diagram is knowingly incomplete: a child spawned inside an `enqueueActions`
259
330
  * closure cannot be found by the static walk, so those states may be missing children entirely.
260
331
  * Computed server-side (`MachineDoc.opaqueStates`) and surfaced HERE, where the reader is. */
@@ -282,12 +353,12 @@ function connectFeed(name: string): void {
282
353
  feed.addEventListener("runs", (e) => {
283
354
  const runs = JSON.parse((e as MessageEvent<string>).data) as ObservedRun[];
284
355
  dispatch({ kind: "runs", runs });
285
- for (const r of runs) void refreshGates(r.runId, name);
356
+ for (const r of runs) void refreshRun(r.runId, name);
286
357
  });
287
358
  feed.addEventListener("status", (e) => {
288
359
  const status = JSON.parse((e as MessageEvent<string>).data) as ObservedRun;
289
360
  dispatch({ kind: "status", status });
290
- void refreshGates(status.runId, name);
361
+ void refreshRun(status.runId, name);
291
362
  });
292
363
  feed.addEventListener("gone", (e) => {
293
364
  const { runId } = JSON.parse((e as MessageEvent<string>).data) as { runId: string };
@@ -295,7 +366,7 @@ function connectFeed(name: string): void {
295
366
  // The reducer already dropped the card (a gate exists only while its state is entered —
296
367
  // ADR-0011); the re-fetch is the same frame-triggered discipline as `runs`/`status`, and its
297
368
  // 404 converges on the same empty answer.
298
- void refreshGates(runId, name);
369
+ void refreshRun(runId, name);
299
370
  });
300
371
  feed.addEventListener("emit", (e) => {
301
372
  const { runId, type } = JSON.parse((e as MessageEvent<string>).data) as { runId: string; type: string };
@@ -318,7 +389,7 @@ async function snapshotWorkflow(name: string): Promise<void> {
318
389
  if (!res.ok) return;
319
390
  const runs = (await res.json()) as ObservedRun[];
320
391
  dispatch({ kind: "fleet", workflow: name, runs });
321
- for (const r of runs) void refreshGates(r.runId, name);
392
+ for (const r of runs) void refreshRun(r.runId, name);
322
393
  } catch {
323
394
  // transient — the next tick tries again
324
395
  }
@@ -327,7 +398,7 @@ async function snapshotWorkflow(name: string): Promise<void> {
327
398
  async function snapshotFleet(): Promise<void> {
328
399
  try {
329
400
  const res = await fetch("/workflows");
330
- if (res.ok) dispatch({ kind: "workflows", workflows: (await res.json()) as string[] });
401
+ if (res.ok) dispatch({ kind: "workflows", workflows: (await res.json()) as WorkflowListing[] });
331
402
  } catch {
332
403
  // transient — the next tick tries again
333
404
  }
@@ -368,7 +439,7 @@ async function boot(): Promise<void> {
368
439
  // observation), then the selection the address carries — deep links work on load.
369
440
  try {
370
441
  const res = await fetch("/workflows");
371
- if (res.ok) dispatch({ kind: "workflows", workflows: (await res.json()) as string[] });
442
+ if (res.ok) dispatch({ kind: "workflows", workflows: (await res.json()) as WorkflowListing[] });
372
443
  } catch {
373
444
  // the interval retries; the rail says "no workflows registered" until then
374
445
  }
package/console/store.ts CHANGED
@@ -24,6 +24,43 @@ export type ObservedRun = {
24
24
  status: string;
25
25
  value: unknown;
26
26
  children: Array<Record<string, unknown>>;
27
+ /** The run binds at least one Chart (ADR-0070): its Machine is the Chart wrapper's with each bound
28
+ * Open body's Chart compiled in, served per run. Absent when it binds none. */
29
+ bound?: true;
30
+ /** The hashes of the Charts the run binds now, distinct and sorted. A run can bind a second Chart
31
+ * in its life, so these, with the run id, key its doc. Absent from an older Orchestrator. */
32
+ chartHashes?: string[];
33
+ };
34
+
35
+ /** Where a Workflow came from (ADR-0070): a Machine or a Chart file in the Assembly, or a runtime
36
+ * Chart put on the Instance. */
37
+ export type WorkflowOrigin = "machine" | "file" | "runtime";
38
+
39
+ /** One entry of `GET /workflows` (ADR-0070) — mirrors `WorkflowListing` in run-host.ts. A Chart
40
+ * wrapper's named Charts are inside its entry, not beside it. */
41
+ export type WorkflowListing = {
42
+ name: string;
43
+ origin: WorkflowOrigin;
44
+ wrapper?: {
45
+ body: string;
46
+ oneShot: boolean;
47
+ charts: Array<{ name: string; origin: "file" | "runtime"; unrunnable?: string }>;
48
+ };
49
+ unrunnable?: string;
50
+ };
51
+
52
+ /** One row of the fleet rail: a Workflow, or a named Chart indented under its Chart wrapper. */
53
+ export type FleetEntry = {
54
+ name: string;
55
+ origin: WorkflowOrigin;
56
+ /** 0 for a Workflow, 1 for a Chart under its wrapper. */
57
+ depth: 0 | 1;
58
+ /** A Chart wrapper's Open body slot, and whether its door takes a Chart one-shot. */
59
+ wrapper?: { body: string; oneShot: boolean };
60
+ unrunnable?: string;
61
+ /** A runtime Chart of the same name that this Chart file shadows (ADR-0070): one row per name,
62
+ * so the rail keys stay distinct; the shadowed Chart is unrunnable, and this says why. */
63
+ shadows?: { origin: WorkflowOrigin; unrunnable?: string };
27
64
  };
28
65
 
29
66
  /** One open gate as `GET /runs/:id` reports it — mirrors `GateView` in run-host.ts. */
@@ -34,6 +71,15 @@ export type GateCard = {
34
71
  meta?: Record<string, unknown>;
35
72
  };
36
73
 
74
+ /** One Workspace waiting for capacity as `GET /runs/:id` reports it (ADR-0064) — mirrors
75
+ * `RunWaiting` in run-host.ts. Guarded: `message` is the scheduler's or the quota's own words. */
76
+ export type WaitingLine = { child: string; on: "node" | "quota"; message: string; since: string };
77
+
78
+ /** One outstanding pick as `GET /runs/:id` reports it (ADR-0024) — mirrors `RunHeld` in
79
+ * run-host.ts. There is no default time bound on a held Turn: "held since" is how a human sees one.
80
+ * Guarded like the reason a Workspace waits: a pick names what an Agent did. */
81
+ export type HeldLine = { agent: string; pick: string; since: string };
82
+
37
83
  /** The credential's state — never its value, which stays in sessionStorage (ADR-0032). */
38
84
  export type TokenState = "none" | "checking" | "live" | "invalid";
39
85
 
@@ -45,7 +91,7 @@ export type Frame =
45
91
  | { kind: "gone"; runId: string }
46
92
  | { kind: "emit"; runId: string; type: string }
47
93
  | { kind: "connection"; state: Store["connection"] }
48
- | { kind: "workflows"; workflows: string[] }
94
+ | { kind: "workflows"; workflows: ReadonlyArray<WorkflowListing | string> }
49
95
  | { kind: "select"; workflow: string | null }
50
96
  | { kind: "selectRun"; runId: string | null }
51
97
  | { kind: "selectNode"; nodeId: string | null }
@@ -53,6 +99,7 @@ export type Frame =
53
99
  | { kind: "toggleWorkflow"; workflow: string }
54
100
  | { kind: "token"; state: TokenState }
55
101
  | { kind: "gates"; runId: string; workflow: string; gates: GateCard[] }
102
+ | { kind: "waiting"; runId: string; workflow: string; waiting: WaitingLine[]; held?: HeldLine[] }
56
103
  | { kind: "inboxScope"; all: boolean }
57
104
  | { kind: "startForm"; workflow: string | null };
58
105
 
@@ -66,7 +113,10 @@ export type Frame =
66
113
  * `emits` newest-first, across the selected workflow's runs — each tagged with its run.
67
114
  * `connection` the socket's state, owned by the page, never by a frame off the wire.
68
115
  * `workflow` the selection the ADDRESS carries (`/workflows/:name`); null at `/`.
69
- * `workflows` the registered names (`GET /workflows`) — what the fleet rail lists.
116
+ * `workflows` every registered name (`GET /workflows`), Machines and named Charts alike — what the
117
+ * fleet polls and selects. A Chart's name is `<wrapper>/<name>` (ADR-0070).
118
+ * `listing` the same answer with its structure: each Workflow's origin, a Chart wrapper's Charts
119
+ * inside its entry — what the fleet rail draws ({@link fleetEntries}).
70
120
  * `fleet` REST snapshots per workflow (`GET /workflows/:name/runs`), for the workflows that do
71
121
  * NOT hold the feed. Never more than one EventSource — the rest of the fleet is polled.
72
122
  * `expanded` which workflows the rail shows runs for (the reader's folding, plus auto-expand on
@@ -76,6 +126,9 @@ export type Frame =
76
126
  * "invalid". Anything but "live" means observer mode.
77
127
  * `gates` the gate inbox: runId -> { workflow, gates } from `GET /runs/:id` re-fetches. Guarded
78
128
  * data (Instance band), so it cannot outlive the credential that read it.
129
+ * `waiting` why each run's Workspaces in `placing` wait (ADR-0064): runId -> { workflow, waiting },
130
+ * off the SAME `GET /runs/:id` re-fetch as `gates`, under the same rules — guarded, whole
131
+ * per run, gone with the credential. The open band sees `placing` lit and nothing more.
79
132
  * `inboxAll` the widen control: false = the inbox follows the selection, true = every workflow.
80
133
  * `startFormFor` which workflow's start-run form is open, if any.
81
134
  * `selectedNodeId` the diagram node the reader clicked — an elk node id (scope + state id), one at
@@ -93,10 +146,12 @@ export type Store = {
93
146
  connection: "connecting" | "live" | "retrying";
94
147
  workflow: string | null;
95
148
  workflows: string[];
149
+ listing: WorkflowListing[];
96
150
  fleet: Map<string, ObservedRun[]>;
97
151
  expanded: Set<string>;
98
152
  token: TokenState;
99
153
  gates: Map<string, { workflow: string; gates: GateCard[] }>;
154
+ waiting: Map<string, { workflow: string; waiting: WaitingLine[]; held: HeldLine[] }>;
100
155
  inboxAll: boolean;
101
156
  startFormFor: string | null;
102
157
  };
@@ -119,10 +174,12 @@ export function emptyStore(): Store {
119
174
  connection: "connecting",
120
175
  workflow: null,
121
176
  workflows: [],
177
+ listing: [],
122
178
  fleet: new Map(),
123
179
  expanded: new Set(),
124
180
  token: "none",
125
181
  gates: new Map(),
182
+ waiting: new Map(),
126
183
  inboxAll: false,
127
184
  startFormFor: null,
128
185
  };
@@ -141,6 +198,7 @@ export function applyFrame(store: Store, frame: Frame): Store {
141
198
  ...store,
142
199
  runs: new Map(frame.runs.map((r): [string, ObservedRun] => [r.runId, r])),
143
200
  gates: dropAbsent(store.gates, store.workflow, new Set(frame.runs.map((r) => r.runId))),
201
+ waiting: dropAbsent(store.waiting, store.workflow, new Set(frame.runs.map((r) => r.runId))),
144
202
  });
145
203
 
146
204
  case "status":
@@ -152,13 +210,14 @@ export function applyFrame(store: Store, frame: Frame): Store {
152
210
  // a reconnect window, which is ordinary. Its inbox card goes either way: a gate exists exactly
153
211
  // while its invoking state is entered (ADR-0011), and this run has no entered states left.
154
212
  const gates = mapWithout(store.gates, frame.runId);
213
+ const waiting = mapWithout(store.waiting, frame.runId);
155
214
  const departing = store.runs.get(frame.runId);
156
- if (!departing) return store.gates === gates ? store : { ...store, gates };
215
+ if (!departing) return store.gates === gates && store.waiting === waiting ? store : { ...store, gates, waiting };
157
216
  const runs = new Map(store.runs);
158
217
  runs.delete(frame.runId);
159
218
  const settled = new Map(store.settled).set(frame.runId, departing);
160
219
  while (settled.size > SETTLED_CAP) settled.delete(settled.keys().next().value!);
161
- return select({ ...store, runs, settled, gates });
220
+ return select({ ...store, runs, settled, gates, waiting });
162
221
  }
163
222
 
164
223
  case "emit":
@@ -167,8 +226,14 @@ export function applyFrame(store: Store, frame: Frame): Store {
167
226
  case "connection":
168
227
  return { ...store, connection: frame.state };
169
228
 
170
- case "workflows":
171
- return { ...store, workflows: frame.workflows };
229
+ case "workflows": {
230
+ // Replaced wholesale. An Orchestrator older than ADR-0070 answers bare names: each reads as
231
+ // a Machine, so the rail has one shape to draw.
232
+ const listing = frame.workflows.map(
233
+ (w): WorkflowListing => (typeof w === "string" ? { name: w, origin: "machine" } : w),
234
+ );
235
+ return { ...store, listing, workflows: fleetEntries(listing).map((e) => e.name) };
236
+ }
172
237
 
173
238
  case "select": {
174
239
  // The address moved. The feed's state belongs to the OLD selection — the new feed's opening
@@ -206,8 +271,9 @@ export function applyFrame(store: Store, frame: Frame): Store {
206
271
  // so it settles that workflow's inbox cards the same way (a run the snapshot does not carry
207
272
  // will never be re-fetched again).
208
273
  const fleet = new Map(store.fleet).set(frame.workflow, frame.runs);
209
- const gates = dropAbsent(store.gates, frame.workflow, new Set(frame.runs.map((r) => r.runId)));
210
- return { ...store, fleet, gates };
274
+ const present = new Set(frame.runs.map((r) => r.runId));
275
+ const gates = dropAbsent(store.gates, frame.workflow, present);
276
+ return { ...store, fleet, gates, waiting: dropAbsent(store.waiting, frame.workflow, present) };
211
277
  }
212
278
 
213
279
  case "toggleWorkflow": {
@@ -221,7 +287,7 @@ export function applyFrame(store: Store, frame: Frame): Store {
221
287
  // "invalid" (the any-later-401 drop of ADR-0032) empties the inbox. "checking" keeps it — a
222
288
  // re-validation of a token that turns out fine should not flash the inbox empty.
223
289
  if (frame.state === "none" || frame.state === "invalid") {
224
- return { ...store, token: frame.state, gates: new Map() };
290
+ return { ...store, token: frame.state, gates: new Map(), waiting: new Map() };
225
291
  }
226
292
  return { ...store, token: frame.state };
227
293
 
@@ -238,6 +304,24 @@ export function applyFrame(store: Store, frame: Frame): Store {
238
304
  return { ...store, gates };
239
305
  }
240
306
 
307
+ case "waiting": {
308
+ // The same guarded re-fetch's other half (ADR-0064), folded the same way: whole per run, and
309
+ // nothing folded without the credential that read it.
310
+ // The run's outstanding picks (ADR-0024) ride the same frame: one re-fetch, one fold.
311
+ if (store.token !== "live") return store;
312
+ const held = frame.held ?? [];
313
+ if (!frame.waiting.length && !held.length) {
314
+ const waiting = mapWithout(store.waiting, frame.runId);
315
+ return waiting === store.waiting ? store : { ...store, waiting };
316
+ }
317
+ const waiting = new Map(store.waiting).set(frame.runId, {
318
+ workflow: frame.workflow,
319
+ waiting: frame.waiting,
320
+ held,
321
+ });
322
+ return { ...store, waiting };
323
+ }
324
+
241
325
  case "inboxScope":
242
326
  return { ...store, inboxAll: frame.all };
243
327
 
@@ -258,15 +342,20 @@ function mapWithout<K, V>(map: Map<K, V>, key: K): Map<K, V> {
258
342
  return next;
259
343
  }
260
344
 
261
- /** Drop `workflow`'s inbox entries for runs a whole-set frame did not mention. Those runs will
262
- * never be re-fetched again (frames and snapshots are per-run triggers), so a card kept here would
263
- * be stale forever — this is the level-triggered idiom applied to the inbox, not bookkeeping. */
264
- function dropAbsent(gates: Store["gates"], workflow: string | null, present: Set<string>): Store["gates"] {
265
- if (!workflow) return gates;
266
- let next = gates;
267
- for (const [runId, entry] of gates) {
345
+ /** Drop `workflow`'s guarded entries (inbox cards, waits) for runs a whole-set frame did not
346
+ * mention. Those runs will never be re-fetched again (frames and snapshots are per-run triggers),
347
+ * so an entry kept here would be stale forever — this is the level-triggered idiom applied to the
348
+ * guarded maps, not bookkeeping. */
349
+ function dropAbsent<V extends { workflow: string }>(
350
+ entries: Map<string, V>,
351
+ workflow: string | null,
352
+ present: Set<string>,
353
+ ): Map<string, V> {
354
+ if (!workflow) return entries;
355
+ let next = entries;
356
+ for (const [runId, entry] of entries) {
268
357
  if (entry.workflow !== workflow || present.has(runId)) continue;
269
- if (next === gates) next = new Map(gates);
358
+ if (next === entries) next = new Map(entries);
270
359
  next.delete(runId);
271
360
  }
272
361
  return next;
@@ -310,6 +399,71 @@ export function fleetRuns(store: Store, workflow: string): Array<{ run: Observed
310
399
  return (store.fleet.get(workflow) ?? []).map((run) => ({ run, settled: false }));
311
400
  }
312
401
 
402
+ /**
403
+ * The fleet rail's rows (ADR-0070): each Workflow in the listing's order, a Chart wrapper's named
404
+ * Charts indented under it as `<wrapper>/<name>`. A Chart the listing also gives at the top level
405
+ * is drawn once, under its wrapper; one whose wrapper the listing does not give stays at the top.
406
+ */
407
+ export function fleetEntries(listing: readonly WorkflowListing[]): FleetEntry[] {
408
+ const full = (wrapper: string, name: string): string => (name.includes("/") ? name : `${wrapper}/${name}`);
409
+ const nested = new Set<string>();
410
+ for (const w of listing) for (const c of w.wrapper?.charts ?? []) nested.add(full(w.name, c.name));
411
+ const rows: FleetEntry[] = [];
412
+ for (const w of listing) {
413
+ if (w.origin !== "machine" && nested.has(w.name)) continue;
414
+ rows.push({
415
+ name: w.name,
416
+ origin: w.origin,
417
+ depth: 0,
418
+ ...(w.wrapper ? { wrapper: { body: w.wrapper.body, oneShot: w.wrapper.oneShot } } : {}),
419
+ ...(w.unrunnable ? { unrunnable: w.unrunnable } : {}),
420
+ });
421
+ for (const c of w.wrapper?.charts ?? []) {
422
+ const name = full(w.name, c.name);
423
+ // A Chart file and a runtime Chart of one name: the listing gives both, the file wins
424
+ // (ADR-0070). One row per name, the shadowed Chart a note on it, so no two rows share a key.
425
+ const twin = rows.find((r) => r.depth === 1 && r.name === name);
426
+ if (twin) {
427
+ const fileFirst = twin.origin === "file" || c.origin !== "file";
428
+ const shadowed = fileFirst ? c : { origin: twin.origin, unrunnable: twin.unrunnable };
429
+ if (!fileFirst) {
430
+ twin.origin = c.origin;
431
+ if (c.unrunnable) twin.unrunnable = c.unrunnable;
432
+ else delete twin.unrunnable;
433
+ }
434
+ twin.shadows = { origin: shadowed.origin, ...(shadowed.unrunnable ? { unrunnable: shadowed.unrunnable } : {}) };
435
+ continue;
436
+ }
437
+ rows.push({
438
+ name,
439
+ origin: c.origin,
440
+ depth: 1,
441
+ ...(c.unrunnable ? { unrunnable: c.unrunnable } : {}),
442
+ });
443
+ }
444
+ }
445
+ return rows;
446
+ }
447
+
448
+ /**
449
+ * Which Machine doc the center pane draws (ADR-0070), as a cache key and the route that serves it.
450
+ * A run that binds a Chart draws its own Machine, the Chart compiled into the Open body, so its doc
451
+ * is per run id; every other selection draws the Workflow's Machine, shared by all its runs. The
452
+ * Workflow name rides the route as one encoded segment (`<wrapper>%2F<name>` for a named Chart).
453
+ */
454
+ export function machineDocSource(
455
+ workflow: string,
456
+ run: Pick<ObservedRun, "runId" | "bound" | "chartHashes"> | null,
457
+ ): { key: string; path: string } {
458
+ const base = `/workflows/${encodeURIComponent(workflow)}`;
459
+ if (run?.bound === true) {
460
+ // The run id and the Charts it binds now: a second Chart in the run's life is a new doc.
461
+ const charts = run.chartHashes?.length ? `:${run.chartHashes.join(",")}` : "";
462
+ return { key: `run:${run.runId}${charts}`, path: `${base}/runs/${encodeURIComponent(run.runId)}/machine` };
463
+ }
464
+ return { key: `workflow:${workflow}`, path: `${base}/machine` };
465
+ }
466
+
313
467
  /** The inbox as the Attention tab draws it: every card, or the selection's slice. The inbox itself
314
468
  * is GLOBAL (every workflow the fleet knows) — the filter is presentation, which is why it lives
315
469
  * in a selector and not in `applyFrame`. */
@@ -327,6 +481,56 @@ export function selectedRunGates(store: Store): GateCard[] {
327
481
  return store.gates.get(store.selectedRunId)?.gates ?? [];
328
482
  }
329
483
 
484
+ /** Why the SELECTED run's Workspaces wait for capacity (ADR-0064) — empty in observer mode, where
485
+ * the diagram still lights `placing` but the reason is not the page's to know. */
486
+ export function selectedRunWaiting(store: Store): WaitingLine[] {
487
+ if (store.selectedRunId === null) return [];
488
+ return store.waiting.get(store.selectedRunId)?.waiting ?? [];
489
+ }
490
+
491
+ /** The SELECTED run's outstanding picks, each with since when it is held (ADR-0024) — empty in
492
+ * observer mode. */
493
+ export function selectedRunHeld(store: Store): HeldLine[] {
494
+ if (store.selectedRunId === null) return [];
495
+ return store.waiting.get(store.selectedRunId)?.held ?? [];
496
+ }
497
+
498
+ /** One party's seat on one state (ADR-0015): who may send which names there — mirrors
499
+ * `MachineSeat` in vocabulary.ts. */
500
+ export type Seat = { state: string; party: "agent" | "gate"; agent?: string; names: string[] };
501
+
502
+ /** The part of a Machine doc the seats are read from: each body's `seats` and `warnings`, and the
503
+ * child Machines below its states (canvas.ts's `MachineBodyDoc`, structurally). */
504
+ export type SeatedBody = {
505
+ seats?: Seat[];
506
+ warnings?: string[];
507
+ root: SeatedState;
508
+ };
509
+ type SeatedState = { states: SeatedState[]; children: Array<{ machine?: SeatedBody }> };
510
+
511
+ /**
512
+ * Who may send what, per state, and what the build found suspect (ADR-0015) — for the Machine and
513
+ * every Machine it composes, in the order `jr2 up` prints them: the root's body, then each child
514
+ * Machine's, depth-first in state order. A function of the doc, not of the store: the doc is
515
+ * structure the server serves (app.ts's `MachineView`), and this is the one piece of it worth a
516
+ * unit test outside the canvas.
517
+ */
518
+ export function docSeats(doc: SeatedBody | null): { seats: Seat[]; warnings: string[] } {
519
+ const seats: Seat[] = [];
520
+ const warnings: string[] = [];
521
+ const body = (b: SeatedBody): void => {
522
+ seats.push(...(b.seats ?? []));
523
+ warnings.push(...(b.warnings ?? []));
524
+ state(b.root);
525
+ };
526
+ const state = (s: SeatedState): void => {
527
+ for (const child of s.children) if (child.machine) body(child.machine);
528
+ s.states.forEach(state);
529
+ };
530
+ if (doc) body(doc);
531
+ return { seats, warnings };
532
+ }
533
+
330
534
  /** What the nav badge counts: every open gate everywhere — attention is global even when the
331
535
  * inbox view is filtered. */
332
536
  export function gateCount(store: Store): number {