@jr2/orchestrator 0.3.0 → 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 -4
- package/src/actor.ts +502 -49
- 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 +152 -46
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +6 -5
- package/src/custodian.ts +71 -19
- 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 +245 -23
- package/src/held-secrets.ts +82 -26
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +496 -104
- package/src/images.ts +47 -21
- package/src/index.ts +43 -2
- package/src/instance.ts +444 -58
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +66 -8
- 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 +60 -85
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1832 -98
- 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 +281 -84
- 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 +35 -4
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1108
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = (
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
259
|
+
await syncMachine();
|
|
231
260
|
}
|
|
232
261
|
|
|
233
|
-
/**
|
|
234
|
-
* a running orchestrator, so going back
|
|
235
|
-
*
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
|
210
|
-
|
|
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
|
|
262
|
-
* never be re-fetched again (frames and snapshots are per-run triggers),
|
|
263
|
-
* be stale forever — this is the level-triggered idiom applied to the
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 ===
|
|
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 {
|