@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.
- package/LICENSE +21 -0
- package/README.md +23 -0
- package/bin/server.ts +23 -0
- package/console/canvas.ts +843 -0
- package/console/components/app.ts +79 -0
- package/console/components/drawer.ts +131 -0
- package/console/components/fleet.ts +117 -0
- package/console/components/machine-pane.ts +85 -0
- package/console/components/nav.ts +81 -0
- package/console/components/schema-form.ts +137 -0
- package/console/main.ts +383 -0
- package/console/page.html +28 -0
- package/console/store.ts +336 -0
- package/console/style.css +700 -0
- package/console/tsconfig.json +18 -0
- package/package.json +61 -0
- package/src/actor.ts +562 -0
- package/src/agent.ts +124 -0
- package/src/ambient.ts +50 -0
- package/src/config.ts +297 -0
- package/src/customize.ts +348 -0
- package/src/durability.ts +135 -0
- package/src/fingerprint.ts +92 -0
- package/src/gate.ts +76 -0
- package/src/harness-client.ts +503 -0
- package/src/http.ts +753 -0
- package/src/images.ts +303 -0
- package/src/index.ts +40 -0
- package/src/instance.ts +294 -0
- package/src/machine-doc.ts +334 -0
- package/src/names.ts +78 -0
- package/src/open.ts +17 -0
- package/src/parts.ts +500 -0
- package/src/pool.ts +284 -0
- package/src/registration.ts +340 -0
- package/src/repo-fetch.ts +259 -0
- package/src/repo-identity.ts +145 -0
- package/src/repos.ts +330 -0
- package/src/run-host.ts +1095 -0
- package/src/sandbox-kubectl.ts +1136 -0
- package/src/server.ts +220 -0
- package/src/setup.ts +360 -0
- package/src/snapshot-store.ts +150 -0
- package/src/stub-harness.ts +217 -0
- package/src/tokens.ts +126 -0
- package/src/vocabulary.ts +99 -0
- package/src/wire.ts +103 -0
- package/src/workspace.ts +874 -0
- package/tsconfig.instance.json +26 -0
package/console/main.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// The jr2 Console — one shell, master–detail (ADR-0032). The left rail is the FLEET (every
|
|
2
|
+
// registered workflow, each expandable to its runs); the center is the selected workflow's Machine,
|
|
3
|
+
// fetched from GET /workflows/:name/machine, laid out with elkjs (loaded as a UMD script ->
|
|
4
|
+
// window.ELK), rendered as nested SVG (canvas.ts) and live-highlighted by the selected run; the
|
|
5
|
+
// right drawer is Attention (the gate inbox) and Activity (the emit log).
|
|
6
|
+
//
|
|
7
|
+
// The ADDRESS carries the selection: `/` and `/workflows/:name` serve these same bytes, the path
|
|
8
|
+
// says which workflow is selected, pushState moves it on click and popstate restores it. ONE
|
|
9
|
+
// EventSource, ever, and it belongs to the SELECTION (GET /workflows/:name/events — ADR-0022);
|
|
10
|
+
// the rest of the fleet is REST snapshots on an interval and on tab focus. Selecting a RUN is a
|
|
11
|
+
// local act: it re-renders from state already in hand and touches no socket. `onerror` means
|
|
12
|
+
// "retrying", never "give up" — EventSource reconnects on its own, and the level-triggered feed
|
|
13
|
+
// makes reattach convergent with no cursor (store.ts folds every frame idempotently).
|
|
14
|
+
//
|
|
15
|
+
// The page as SERVED is ADR-0014's observer: open structure and observation, no credential in the
|
|
16
|
+
// bytes. Control is EARNED per tab: the human types the Instance token into the nav (sessionStorage
|
|
17
|
+
// — survives a reload, dies with the tab, never a cookie), and only then does the page speak on the
|
|
18
|
+
// guarded surface: POST /workflows/:name/runs (start), GET /runs/:id (gate discovery, re-fetched on
|
|
19
|
+
// each frame), POST /runs/:id/gates/:gate/events (delivery). Any later 401 drops the whole page
|
|
20
|
+
// back to observer mode. That is ADR-0032 end to end — the bands themselves never moved.
|
|
21
|
+
//
|
|
22
|
+
// STORE-FIRST, VDOM-PAINTED (ADR-0034): everything the page believes lives in the pure reducer
|
|
23
|
+
// (store.ts) and arrives there as frames — wire frames and page facts alike. `dispatch` is the one
|
|
24
|
+
// door: fold the frame, render the App from the top, and let Preact's diff do what main.js's
|
|
25
|
+
// hand-rolled memo()/paint() machinery did. This file is the BOOTSTRAP and owns only working
|
|
26
|
+
// state — the machine-doc cache, the input-schema cache, the fetch serials, the zoom readout; the
|
|
27
|
+
// diagram's own working state (fold set, viewport, what is shown) lives in canvas.ts.
|
|
28
|
+
|
|
29
|
+
import { h, render } from "preact";
|
|
30
|
+
import { applyFrame, emptyStore, type Frame, type GateCard, type ObservedRun, type Store } from "./store.ts";
|
|
31
|
+
import { zoomFit, zoomIn, zoomOut, zoomReset, type MachineDoc } from "./canvas.ts";
|
|
32
|
+
import { App, type AppApi, type MachineView } from "./components/app.ts";
|
|
33
|
+
|
|
34
|
+
let store: Store = emptyStore();
|
|
35
|
+
/** The center pane's working state — see {@link MachineView}; the doc counterpart of old `shown`. */
|
|
36
|
+
let view: MachineView = { doc: null, placeholder: null, note: null };
|
|
37
|
+
/** The nav's zoom readout — canvas.ts reports through `api.onScale`; a render carries it. */
|
|
38
|
+
let zoomPct = 100;
|
|
39
|
+
|
|
40
|
+
const root = document.getElementById("root")!;
|
|
41
|
+
|
|
42
|
+
/** One render from the top, whatever moved — store or working state; the vdom diffs the rest. */
|
|
43
|
+
function rerender(): void {
|
|
44
|
+
render(h(App, { store, view, zoomPct, initialToken, schemas: inputSchemas, api }), root);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Fold one frame and re-render. Every state change in this file goes through here — a page that
|
|
48
|
+
* writes anywhere else holds a private belief, which is the bug class store.ts exists to end. */
|
|
49
|
+
function dispatch(frame: Frame): void {
|
|
50
|
+
store = applyFrame(store, frame);
|
|
51
|
+
rerender();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- The token (ADR-0032) -----------------------------------------------------------------------
|
|
55
|
+
// The VALUE lives here (sessionStorage) and rides only in Authorization headers; the store holds
|
|
56
|
+
// its STATE. Entry is validated with the cheapest guarded read (`GET /runs`), whose response also
|
|
57
|
+
// happens to be the full run list — which seeds the gate inbox without waiting for a frame.
|
|
58
|
+
|
|
59
|
+
const TOKEN_KEY = "jr2.console.token";
|
|
60
|
+
const token = (): string => sessionStorage.getItem(TOKEN_KEY) ?? "";
|
|
61
|
+
/** Seeds the (uncontrolled) nav input once; every later read goes to sessionStorage. */
|
|
62
|
+
const initialToken = token();
|
|
63
|
+
|
|
64
|
+
/** A guarded fetch. The ONE place a 401 is turned into observer mode — every control widget calls
|
|
65
|
+
* through here, so none of them needs its own fallback story. */
|
|
66
|
+
async function gfetch(
|
|
67
|
+
path: string,
|
|
68
|
+
init: { method?: string; headers?: Record<string, string>; body?: string } = {},
|
|
69
|
+
): Promise<Response | null> {
|
|
70
|
+
const res = await fetch(path, {
|
|
71
|
+
...init,
|
|
72
|
+
headers: { ...(init.headers ?? {}), authorization: `Bearer ${token()}` },
|
|
73
|
+
});
|
|
74
|
+
if (res.status === 401) {
|
|
75
|
+
dispatch({ kind: "token", state: "invalid" });
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return res;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Which token entry is CURRENT. Two quick edits launch two unsequenced validations, and without
|
|
82
|
+
* this the older response could dispatch last and leave the badge (and inbox seed) speaking for a
|
|
83
|
+
* token the reader already replaced. Bumped by every entry AND by clearing, so an in-flight
|
|
84
|
+
* validation can never overwrite "none". */
|
|
85
|
+
let tokenSerial = 0;
|
|
86
|
+
|
|
87
|
+
async function validateToken(): Promise<void> {
|
|
88
|
+
const serial = ++tokenSerial;
|
|
89
|
+
dispatch({ kind: "token", state: "checking" });
|
|
90
|
+
let res: Response | null;
|
|
91
|
+
try {
|
|
92
|
+
res = await fetch("/runs", { headers: { authorization: `Bearer ${token()}` } });
|
|
93
|
+
} catch {
|
|
94
|
+
res = null; // network trouble reads as invalid; re-entering the token retries
|
|
95
|
+
}
|
|
96
|
+
if (serial !== tokenSerial) return; // a newer entry owns the badge — this answer is nobody's
|
|
97
|
+
if (!res?.ok) return dispatch({ kind: "token", state: "invalid" });
|
|
98
|
+
dispatch({ kind: "token", state: "live" });
|
|
99
|
+
const runs = (await res.json()) as Array<{ runId: string; workflow: string }>;
|
|
100
|
+
if (serial !== tokenSerial) return;
|
|
101
|
+
for (const r of runs) void refreshGates(r.runId, r.workflow);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setToken(value: string): void {
|
|
105
|
+
if (!value) {
|
|
106
|
+
tokenSerial++; // strand any in-flight validation of the token this just cleared
|
|
107
|
+
sessionStorage.removeItem(TOKEN_KEY);
|
|
108
|
+
dispatch({ kind: "token", state: "none" });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
sessionStorage.setItem(TOKEN_KEY, value);
|
|
112
|
+
void validateToken();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- Gate discovery (frame-triggered — ADR-0032) ------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** Per-run re-fetch serials — fetch bookkeeping like `docs`, not belief. The browser runs these
|
|
118
|
+
* requests on parallel connections, so answers can land out of ORDER: a re-fetch triggered by the
|
|
119
|
+
* delivery's own transition frame (gates now empty) can resolve before the one an earlier frame
|
|
120
|
+
* launched (gate still open), and folding the straggler would resurrect a card the server already
|
|
121
|
+
* emptied — pinning the ⚑ and inviting a second delivery until the run's next frame, which a run
|
|
122
|
+
* parked in a long agent step may not land for minutes. Only the latest-STARTED re-fetch may
|
|
123
|
+
* speak for a run; superseded answers are dropped before they reach the store. */
|
|
124
|
+
const gateFetchSerial = new Map<string, number>();
|
|
125
|
+
|
|
126
|
+
/** Re-read one run's open gates off the guarded surface and fold the WHOLE answer in. Called on
|
|
127
|
+
* every frame that touches the run — that is the synchronization: a gate opens with a state entry
|
|
128
|
+
* 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> {
|
|
131
|
+
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);
|
|
134
|
+
const res = await gfetch(`/runs/${encodeURIComponent(runId)}`);
|
|
135
|
+
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 });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- The two writes (start-run + gate delivery — ADR-0033) --------------------------------------
|
|
142
|
+
// Both resolve to an inline error string for the shared form to render, or null when the write
|
|
143
|
+
// landed — the components never see a Response.
|
|
144
|
+
|
|
145
|
+
async function startRun(name: string, body: Record<string, unknown>): Promise<string | null> {
|
|
146
|
+
const res = await gfetch(`/workflows/${encodeURIComponent(name)}/runs`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "content-type": "application/json" },
|
|
149
|
+
body: JSON.stringify(body),
|
|
150
|
+
});
|
|
151
|
+
if (!res) return "unauthorized — the page dropped to observer";
|
|
152
|
+
if (!res.ok) return ((await res.json()) as { error?: string }).error ?? `HTTP ${res.status}`;
|
|
153
|
+
dispatch({ kind: "startForm", workflow: null });
|
|
154
|
+
// The run ANNOUNCES itself: the feed if this workflow is selected, the next snapshot if not —
|
|
155
|
+
// but a reader who just started a run should not wait 10s to see it in the rail.
|
|
156
|
+
if (name !== store.workflow) void snapshotWorkflow(name);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function deliverGate(
|
|
161
|
+
runId: string,
|
|
162
|
+
gate: string,
|
|
163
|
+
event: string,
|
|
164
|
+
body: Record<string, unknown>,
|
|
165
|
+
): Promise<string | null> {
|
|
166
|
+
const res = await gfetch(`/runs/${encodeURIComponent(runId)}/gates/${encodeURIComponent(gate)}/events`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "content-type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ type: event, ...body }),
|
|
170
|
+
});
|
|
171
|
+
if (!res) return "unauthorized — the page dropped to observer";
|
|
172
|
+
if (!res.ok) return ((await res.json()) as { error?: string }).error ?? `HTTP ${res.status}`;
|
|
173
|
+
// Deliberately NO local bookkeeping: the delivery moves the Machine, the move lands a frame, the
|
|
174
|
+
// frame triggers the re-fetch, and the re-fetch empties the card (ADR-0032).
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- Start-run schemas (ADR-0033) ---------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/** The workflow-detail input schemas (`GET /workflows/:name` JSON — ADR-0033), fetched when a
|
|
181
|
+
* start form first opens. `null` is a real answer (no declared input → raw JSON textarea). */
|
|
182
|
+
const inputSchemas = new Map<string, unknown>();
|
|
183
|
+
|
|
184
|
+
async function toggleStartForm(name: string): Promise<void> {
|
|
185
|
+
if (store.startFormFor === name) return dispatch({ kind: "startForm", workflow: null });
|
|
186
|
+
dispatch({ kind: "startForm", workflow: name });
|
|
187
|
+
if (!inputSchemas.has(name)) {
|
|
188
|
+
const res = await fetch(`/workflows/${encodeURIComponent(name)}`, { headers: { accept: "application/json" } });
|
|
189
|
+
inputSchemas.set(name, res.ok ? (((await res.json()) as { input?: unknown }).input ?? null) : null);
|
|
190
|
+
rerender(); // the form was rendered as "loading" — now the schema is in hand
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- Selection: the address, the feed, the diagram ----------------------------------------------
|
|
195
|
+
|
|
196
|
+
/** The workflow the address names — `/workflows/:name`, or null at `/`. */
|
|
197
|
+
function workflowFromPath(): string | null {
|
|
198
|
+
const match = /^\/workflows\/([^/]+)\/?$/.exec(location.pathname);
|
|
199
|
+
return match ? decodeURIComponent(match[1]!) : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let feed: EventSource | null = null; // the ONE EventSource — the selection's, closed only by a new selection
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Move the selection: address, store, feed, diagram, in that order. `push` is a click (writes
|
|
206
|
+
* history) — but only a click that MOVES: re-clicking the selection would otherwise push a
|
|
207
|
+
* duplicate entry and make the next Back press appear to do nothing. Popstate and boot restore
|
|
208
|
+
* without writing. `runId` rides along when a run row or an inbox card was the click —
|
|
209
|
+
* unvalidated, the feed's first frame confirms or moves on.
|
|
210
|
+
*/
|
|
211
|
+
async function selectWorkflow(
|
|
212
|
+
name: string | null,
|
|
213
|
+
{ push = false, runId = null }: { push?: boolean; runId?: string | null } = {},
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
const moved = name !== store.workflow;
|
|
216
|
+
if (push && moved) history.pushState({}, "", name ? `/workflows/${encodeURIComponent(name)}` : "/");
|
|
217
|
+
document.title = name ? `jr2 · ${name}` : "jr2 · Console";
|
|
218
|
+
dispatch({ kind: "select", workflow: name });
|
|
219
|
+
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;
|
|
229
|
+
connectFeed(name);
|
|
230
|
+
await loadMachine(name);
|
|
231
|
+
}
|
|
232
|
+
|
|
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. */
|
|
236
|
+
const docs = new Map<string, MachineDoc>();
|
|
237
|
+
|
|
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);
|
|
244
|
+
}
|
|
245
|
+
if (store.workflow !== name) return; // the reader moved on while the fetch was out
|
|
246
|
+
if (!doc) {
|
|
247
|
+
view = { doc: null, placeholder: null, note: { text: `no workflow "${name}"`, notice: false } };
|
|
248
|
+
rerender();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Nothing live yet: every child machine renders once, as a dimmed template (the canvas effect
|
|
252
|
+
// picks the doc up from here). The first status frame of a run with children swaps those for
|
|
253
|
+
// one subgraph per instance.
|
|
254
|
+
view = { doc, placeholder: null, note: opaqueNotice(doc) };
|
|
255
|
+
rerender();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Say so when the diagram is knowingly incomplete: a child spawned inside an `enqueueActions`
|
|
259
|
+
* closure cannot be found by the static walk, so those states may be missing children entirely.
|
|
260
|
+
* Computed server-side (`MachineDoc.opaqueStates`) and surfaced HERE, where the reader is. */
|
|
261
|
+
function opaqueNotice(doc: MachineDoc): MachineView["note"] {
|
|
262
|
+
const opaque = doc.opaqueStates ?? [];
|
|
263
|
+
if (!opaque.length) return null;
|
|
264
|
+
return {
|
|
265
|
+
notice: true,
|
|
266
|
+
text: `${opaque.join(", ")} ${opaque.length === 1 ? "runs" : "run"} an enqueueActions closure — any child machine spawned inside one is NOT shown in this diagram (keep \`spawnChild\` a top-level action to see it).`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The selection's feed. Opened by `selectWorkflow`, closed only by the next one.
|
|
272
|
+
*
|
|
273
|
+
* `onerror` only reports: EventSource reconnects on its own, and closing here (as this page used
|
|
274
|
+
* to) is what made a single blip permanent. There is nothing to re-sync afterwards — the reattached
|
|
275
|
+
* feed opens with the whole live set, which folds in idempotently.
|
|
276
|
+
*
|
|
277
|
+
* Each run-touching frame ALSO triggers that run's gate re-fetch (ADR-0032): a gate opens with a
|
|
278
|
+
* state entry and closes with its exit, and every entry/exit lands a frame here.
|
|
279
|
+
*/
|
|
280
|
+
function connectFeed(name: string): void {
|
|
281
|
+
feed = new EventSource(`/workflows/${encodeURIComponent(name)}/events`);
|
|
282
|
+
feed.addEventListener("runs", (e) => {
|
|
283
|
+
const runs = JSON.parse((e as MessageEvent<string>).data) as ObservedRun[];
|
|
284
|
+
dispatch({ kind: "runs", runs });
|
|
285
|
+
for (const r of runs) void refreshGates(r.runId, name);
|
|
286
|
+
});
|
|
287
|
+
feed.addEventListener("status", (e) => {
|
|
288
|
+
const status = JSON.parse((e as MessageEvent<string>).data) as ObservedRun;
|
|
289
|
+
dispatch({ kind: "status", status });
|
|
290
|
+
void refreshGates(status.runId, name);
|
|
291
|
+
});
|
|
292
|
+
feed.addEventListener("gone", (e) => {
|
|
293
|
+
const { runId } = JSON.parse((e as MessageEvent<string>).data) as { runId: string };
|
|
294
|
+
dispatch({ kind: "gone", runId });
|
|
295
|
+
// The reducer already dropped the card (a gate exists only while its state is entered —
|
|
296
|
+
// ADR-0011); the re-fetch is the same frame-triggered discipline as `runs`/`status`, and its
|
|
297
|
+
// 404 converges on the same empty answer.
|
|
298
|
+
void refreshGates(runId, name);
|
|
299
|
+
});
|
|
300
|
+
feed.addEventListener("emit", (e) => {
|
|
301
|
+
const { runId, type } = JSON.parse((e as MessageEvent<string>).data) as { runId: string; type: string };
|
|
302
|
+
dispatch({ kind: "emit", runId, type });
|
|
303
|
+
});
|
|
304
|
+
feed.onopen = () => dispatch({ kind: "connection", state: "live" });
|
|
305
|
+
feed.onerror = () => dispatch({ kind: "connection", state: "retrying" });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- Fleet snapshots ----------------------------------------------------------------------------
|
|
309
|
+
// The unselected workflows have no feed — never more than one EventSource. They get REST snapshots
|
|
310
|
+
// (`GET /workflows/:name/runs`) on an interval and on tab focus, folded through the same reducer
|
|
311
|
+
// (each snapshot also re-triggers its runs' gate re-fetches, so the inbox is fleet-wide).
|
|
312
|
+
|
|
313
|
+
const SNAPSHOT_MS = 10_000;
|
|
314
|
+
|
|
315
|
+
async function snapshotWorkflow(name: string): Promise<void> {
|
|
316
|
+
try {
|
|
317
|
+
const res = await fetch(`/workflows/${encodeURIComponent(name)}/runs`);
|
|
318
|
+
if (!res.ok) return;
|
|
319
|
+
const runs = (await res.json()) as ObservedRun[];
|
|
320
|
+
dispatch({ kind: "fleet", workflow: name, runs });
|
|
321
|
+
for (const r of runs) void refreshGates(r.runId, name);
|
|
322
|
+
} catch {
|
|
323
|
+
// transient — the next tick tries again
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function snapshotFleet(): Promise<void> {
|
|
328
|
+
try {
|
|
329
|
+
const res = await fetch("/workflows");
|
|
330
|
+
if (res.ok) dispatch({ kind: "workflows", workflows: (await res.json()) as string[] });
|
|
331
|
+
} catch {
|
|
332
|
+
// transient — the next tick tries again
|
|
333
|
+
}
|
|
334
|
+
for (const name of store.workflows) {
|
|
335
|
+
if (name === store.workflow) continue; // the selection's feed is fresher than any snapshot
|
|
336
|
+
await snapshotWorkflow(name);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- Boot ---------------------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
/** Every write path the components may take, in one object — handed to the App on every render. */
|
|
343
|
+
const api: AppApi = {
|
|
344
|
+
dispatch,
|
|
345
|
+
selectWorkflow,
|
|
346
|
+
toggleStartForm,
|
|
347
|
+
startRun,
|
|
348
|
+
deliverGate,
|
|
349
|
+
setToken,
|
|
350
|
+
onScale: (pct) => {
|
|
351
|
+
if (pct === zoomPct) return;
|
|
352
|
+
zoomPct = pct;
|
|
353
|
+
rerender();
|
|
354
|
+
},
|
|
355
|
+
zoomIn,
|
|
356
|
+
zoomOut,
|
|
357
|
+
zoomReset,
|
|
358
|
+
zoomFit,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
async function boot(): Promise<void> {
|
|
362
|
+
addEventListener("popstate", () => void selectWorkflow(workflowFromPath(), { push: false }));
|
|
363
|
+
addEventListener("focus", () => void snapshotFleet());
|
|
364
|
+
setInterval(() => void snapshotFleet(), SNAPSHOT_MS);
|
|
365
|
+
rerender(); // the shell first — the fetches below fill it in
|
|
366
|
+
|
|
367
|
+
// The fleet first (the rail names everything), then the token (unlock is async and must not gate
|
|
368
|
+
// observation), then the selection the address carries — deep links work on load.
|
|
369
|
+
try {
|
|
370
|
+
const res = await fetch("/workflows");
|
|
371
|
+
if (res.ok) dispatch({ kind: "workflows", workflows: (await res.json()) as string[] });
|
|
372
|
+
} catch {
|
|
373
|
+
// the interval retries; the rail says "no workflows registered" until then
|
|
374
|
+
}
|
|
375
|
+
if (token()) void validateToken();
|
|
376
|
+
await selectWorkflow(workflowFromPath(), { push: false });
|
|
377
|
+
void snapshotFleet();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
boot().catch((err: unknown) => {
|
|
381
|
+
view = { doc: null, placeholder: null, note: { text: String(err), notice: false } };
|
|
382
|
+
rerender();
|
|
383
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>jr2 · Console</title>
|
|
7
|
+
<link rel="stylesheet" href="/assets/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<!-- The mount root, and nothing else: the components render the whole shell (ADR-0034) — the
|
|
11
|
+
same ids and classes the browser tier addresses, minted by the vdom instead of a static
|
|
12
|
+
skeleton. -->
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
<!-- The import map binds Preact's bare specifiers to the self-served vendor files
|
|
15
|
+
(ADR-0034) — it must precede any module script, and it covers the vendored modules'
|
|
16
|
+
own imports too (hooks.module.js imports "preact"). -->
|
|
17
|
+
<script type="importmap">
|
|
18
|
+
{
|
|
19
|
+
"imports": {
|
|
20
|
+
"preact": "/assets/vendor/preact.module.js",
|
|
21
|
+
"preact/hooks": "/assets/vendor/hooks.module.js"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
<script src="/assets/elk.js"></script>
|
|
26
|
+
<script type="module" src="/assets/main.ts"></script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|