@jr2/orchestrator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bin/server.ts +23 -0
  4. package/console/canvas.ts +843 -0
  5. package/console/components/app.ts +79 -0
  6. package/console/components/drawer.ts +131 -0
  7. package/console/components/fleet.ts +117 -0
  8. package/console/components/machine-pane.ts +85 -0
  9. package/console/components/nav.ts +81 -0
  10. package/console/components/schema-form.ts +137 -0
  11. package/console/main.ts +383 -0
  12. package/console/page.html +28 -0
  13. package/console/store.ts +336 -0
  14. package/console/style.css +700 -0
  15. package/console/tsconfig.json +18 -0
  16. package/package.json +61 -0
  17. package/src/actor.ts +562 -0
  18. package/src/agent.ts +124 -0
  19. package/src/ambient.ts +50 -0
  20. package/src/config.ts +297 -0
  21. package/src/customize.ts +348 -0
  22. package/src/durability.ts +135 -0
  23. package/src/fingerprint.ts +92 -0
  24. package/src/gate.ts +76 -0
  25. package/src/harness-client.ts +503 -0
  26. package/src/http.ts +753 -0
  27. package/src/images.ts +303 -0
  28. package/src/index.ts +40 -0
  29. package/src/instance.ts +294 -0
  30. package/src/machine-doc.ts +334 -0
  31. package/src/names.ts +78 -0
  32. package/src/open.ts +17 -0
  33. package/src/parts.ts +500 -0
  34. package/src/pool.ts +284 -0
  35. package/src/registration.ts +340 -0
  36. package/src/repo-fetch.ts +259 -0
  37. package/src/repo-identity.ts +145 -0
  38. package/src/repos.ts +330 -0
  39. package/src/run-host.ts +1095 -0
  40. package/src/sandbox-kubectl.ts +1136 -0
  41. package/src/server.ts +220 -0
  42. package/src/setup.ts +360 -0
  43. package/src/snapshot-store.ts +150 -0
  44. package/src/stub-harness.ts +217 -0
  45. package/src/tokens.ts +126 -0
  46. package/src/vocabulary.ts +99 -0
  47. package/src/wire.ts +103 -0
  48. package/src/workspace.ts +874 -0
  49. package/tsconfig.instance.json +26 -0
@@ -0,0 +1,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>