@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,336 @@
1
+ // What the page BELIEVES, kept apart from what it has painted (canvas.ts's `shown`/`queue`).
2
+ //
3
+ // A pure reducer over everything the Console hears — the selected workflow's feed frames
4
+ // (ADR-0022), fleet snapshots, gate re-fetches (ADR-0032), the token's state, the reader's own
5
+ // selections — no DOM, no fetch, no module state — so the interesting cases are testable under
6
+ // `node:test` with no jsdom and no build step. The renderer downstream is already a pure function
7
+ // of (doc, status); this is the other half.
8
+ //
9
+ // One `.ts` read by both consumers (ADR-0034): the test suite through Node's type stripping, the
10
+ // browser through the server's erasure (`/assets/store.ts` in http.ts).
11
+ //
12
+ // The feed is LEVEL-TRIGGERED: every `status` carries a whole observation and `runs` carries the
13
+ // whole live set, so nothing here merges patches or replays a log. Re-delivery of any frame is
14
+ // idempotent by construction, which is the entire reconnect story — there is no cursor to resume
15
+ // from and no gap to detect. The gate inbox rides the same idiom one band up: every `gates` frame
16
+ // is a whole re-fetch of one run's open gates, so an emptied card is a fact the server stated,
17
+ // never bookkeeping this side did after a delivery (ADR-0032).
18
+
19
+ /** A run as the OPEN observation band reports it (ADR-0014): identity and where it is, nothing of
20
+ * what it is carrying. Mirrors `RunObservation` in run-host.ts. */
21
+ export type ObservedRun = {
22
+ runId: string;
23
+ workflow: string;
24
+ status: string;
25
+ value: unknown;
26
+ children: Array<Record<string, unknown>>;
27
+ };
28
+
29
+ /** One open gate as `GET /runs/:id` reports it — mirrors `GateView` in run-host.ts. */
30
+ export type GateCard = {
31
+ gate: string;
32
+ path: string[];
33
+ accepts: Array<{ name: string; description?: string; input: unknown }>;
34
+ meta?: Record<string, unknown>;
35
+ };
36
+
37
+ /** The credential's state — never its value, which stays in sessionStorage (ADR-0032). */
38
+ export type TokenState = "none" | "checking" | "live" | "invalid";
39
+
40
+ /** One frame folded into the store: the workflow feed's wire frames (ADR-0022), plus the page's
41
+ * own facts — fleet snapshots, gate re-fetches, token state, the reader's selections (ADR-0032). */
42
+ export type Frame =
43
+ | { kind: "runs"; runs: ObservedRun[] }
44
+ | { kind: "status"; status: ObservedRun }
45
+ | { kind: "gone"; runId: string }
46
+ | { kind: "emit"; runId: string; type: string }
47
+ | { kind: "connection"; state: Store["connection"] }
48
+ | { kind: "workflows"; workflows: string[] }
49
+ | { kind: "select"; workflow: string | null }
50
+ | { kind: "selectRun"; runId: string | null }
51
+ | { kind: "selectNode"; nodeId: string | null }
52
+ | { kind: "fleet"; workflow: string; runs: ObservedRun[] }
53
+ | { kind: "toggleWorkflow"; workflow: string }
54
+ | { kind: "token"; state: TokenState }
55
+ | { kind: "gates"; runId: string; workflow: string; gates: GateCard[] }
56
+ | { kind: "inboxScope"; all: boolean }
57
+ | { kind: "startForm"; workflow: string | null };
58
+
59
+ /**
60
+ * `runs` the SELECTED workflow's live runs by id — replaced wholesale by a `runs` frame,
61
+ * updated per-run by `status`. Only the selection holds a feed; everything else is
62
+ * `fleet`.
63
+ * `settled` runs THIS page watched leave. The client's own memory: the server does not keep a
64
+ * history for it (that would be a read-through, which stays behind the Instance token),
65
+ * so a hard reload starts empty and that is correct, not a bug.
66
+ * `emits` newest-first, across the selected workflow's runs — each tagged with its run.
67
+ * `connection` the socket's state, owned by the page, never by a frame off the wire.
68
+ * `workflow` the selection the ADDRESS carries (`/workflows/:name`); null at `/`.
69
+ * `workflows` the registered names (`GET /workflows`) — what the fleet rail lists.
70
+ * `fleet` REST snapshots per workflow (`GET /workflows/:name/runs`), for the workflows that do
71
+ * NOT hold the feed. Never more than one EventSource — the rest of the fleet is polled.
72
+ * `expanded` which workflows the rail shows runs for (the reader's folding, plus auto-expand on
73
+ * select).
74
+ * `token` the credential's STATE, never its value — the secret stays in sessionStorage
75
+ * (ADR-0032), outside anything a test would snapshot. "none" | "checking" | "live" |
76
+ * "invalid". Anything but "live" means observer mode.
77
+ * `gates` the gate inbox: runId -> { workflow, gates } from `GET /runs/:id` re-fetches. Guarded
78
+ * data (Instance band), so it cannot outlive the credential that read it.
79
+ * `inboxAll` the widen control: false = the inbox follows the selection, true = every workflow.
80
+ * `startFormFor` which workflow's start-run form is open, if any.
81
+ * `selectedNodeId` the diagram node the reader clicked — an elk node id (scope + state id), one at
82
+ * a time, outline only (the click is RESERVED — selection is the whole behavior for
83
+ * now). Diagram-scoped, so a `select` navigation clears it; a run switch does not need
84
+ * to — an id minted under another run's instance scopes simply matches no box, and the
85
+ * root-scope ids stay valid across runs, which is exactly the selection worth keeping.
86
+ */
87
+ export type Store = {
88
+ runs: Map<string, ObservedRun>;
89
+ settled: Map<string, ObservedRun>;
90
+ emits: Array<{ runId: string; type: string }>;
91
+ selectedRunId: string | null;
92
+ selectedNodeId: string | null;
93
+ connection: "connecting" | "live" | "retrying";
94
+ workflow: string | null;
95
+ workflows: string[];
96
+ fleet: Map<string, ObservedRun[]>;
97
+ expanded: Set<string>;
98
+ token: TokenState;
99
+ gates: Map<string, { workflow: string; gates: GateCard[] }>;
100
+ inboxAll: boolean;
101
+ startFormFor: string | null;
102
+ };
103
+
104
+ /** How many finished runs stay on the page. Enough to see what just happened, bounded so a page
105
+ * left open for a week does not grow without end. */
106
+ export const SETTLED_CAP = 20;
107
+
108
+ /** How many emits stay in the log, for the same reason. */
109
+ export const EMIT_CAP = 200;
110
+
111
+ /** The page before it has heard anything — every field documented on {@link Store}. */
112
+ export function emptyStore(): Store {
113
+ return {
114
+ runs: new Map(),
115
+ settled: new Map(),
116
+ emits: [],
117
+ selectedRunId: null,
118
+ selectedNodeId: null,
119
+ connection: "connecting",
120
+ workflow: null,
121
+ workflows: [],
122
+ fleet: new Map(),
123
+ expanded: new Set(),
124
+ token: "none",
125
+ gates: new Map(),
126
+ inboxAll: false,
127
+ startFormFor: null,
128
+ };
129
+ }
130
+
131
+ /** Fold one frame into the store, returning a new one. Unknown frame kinds are ignored, so a server
132
+ * that learns a new frame type does not break a page still running yesterday's script. */
133
+ export function applyFrame(store: Store, frame: Frame): Store {
134
+ switch (frame.kind) {
135
+ case "runs":
136
+ // The server's whole truth about what is live. Runs that ended during a disconnect are simply
137
+ // ABSENT — no `gone` was delivered for them and none is needed; they were never ours to settle.
138
+ // The same wholeness settles the inbox: a selected-workflow run the frame does not mention can
139
+ // never be re-fetched again, so its card leaves with it.
140
+ return select({
141
+ ...store,
142
+ runs: new Map(frame.runs.map((r): [string, ObservedRun] => [r.runId, r])),
143
+ gates: dropAbsent(store.gates, store.workflow, new Set(frame.runs.map((r) => r.runId))),
144
+ });
145
+
146
+ case "status":
147
+ return select({ ...store, runs: new Map(store.runs).set(frame.status.runId, frame.status) });
148
+
149
+ case "gone": {
150
+ // `gone` is its own fact, not an inference from a terminal status: a run can leave the live
151
+ // set without one (`jr2 stop`). A run we never saw live is a no-op — it began and ended inside
152
+ // a reconnect window, which is ordinary. Its inbox card goes either way: a gate exists exactly
153
+ // while its invoking state is entered (ADR-0011), and this run has no entered states left.
154
+ const gates = mapWithout(store.gates, frame.runId);
155
+ const departing = store.runs.get(frame.runId);
156
+ if (!departing) return store.gates === gates ? store : { ...store, gates };
157
+ const runs = new Map(store.runs);
158
+ runs.delete(frame.runId);
159
+ const settled = new Map(store.settled).set(frame.runId, departing);
160
+ while (settled.size > SETTLED_CAP) settled.delete(settled.keys().next().value!);
161
+ return select({ ...store, runs, settled, gates });
162
+ }
163
+
164
+ case "emit":
165
+ return { ...store, emits: [{ runId: frame.runId, type: frame.type }, ...store.emits].slice(0, EMIT_CAP) };
166
+
167
+ case "connection":
168
+ return { ...store, connection: frame.state };
169
+
170
+ case "workflows":
171
+ return { ...store, workflows: frame.workflows };
172
+
173
+ case "select": {
174
+ // The address moved. The feed's state belongs to the OLD selection — the new feed's opening
175
+ // `runs` frame is a whole set, so starting empty is convergent, not lossy. The inbox and the
176
+ // fleet are global and stay; the selection auto-expands in the rail (clicking a name into the
177
+ // breadcrumb and still having to unfold it would be two gestures for one intent).
178
+ if (frame.workflow === store.workflow) return store;
179
+ const expanded = new Set(store.expanded);
180
+ if (frame.workflow) expanded.add(frame.workflow);
181
+ return {
182
+ ...store,
183
+ workflow: frame.workflow,
184
+ expanded,
185
+ runs: new Map(),
186
+ settled: new Map(),
187
+ emits: [],
188
+ selectedRunId: null,
189
+ selectedNodeId: null,
190
+ connection: "connecting",
191
+ };
192
+ }
193
+
194
+ case "selectRun":
195
+ // Unvalidated on purpose: a card click can select a run the new feed has not delivered yet.
196
+ // The next frame's `select()` sweep keeps it if the run is real and moves on if it is not.
197
+ return { ...store, selectedRunId: frame.runId };
198
+
199
+ case "selectNode":
200
+ // A second click on the selected node deselects — with no other click behavior yet, the
201
+ // outline would otherwise be unremovable short of selecting something else.
202
+ return { ...store, selectedNodeId: frame.nodeId === store.selectedNodeId ? null : frame.nodeId };
203
+
204
+ case "fleet": {
205
+ // One unselected workflow's REST snapshot — the same wholeness contract as a `runs` frame,
206
+ // so it settles that workflow's inbox cards the same way (a run the snapshot does not carry
207
+ // will never be re-fetched again).
208
+ 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 };
211
+ }
212
+
213
+ case "toggleWorkflow": {
214
+ const expanded = new Set(store.expanded);
215
+ expanded.has(frame.workflow) ? expanded.delete(frame.workflow) : expanded.add(frame.workflow);
216
+ return { ...store, expanded };
217
+ }
218
+
219
+ case "token":
220
+ // Guarded data cannot outlive the credential that read it: leaving "live" for "none" or
221
+ // "invalid" (the any-later-401 drop of ADR-0032) empties the inbox. "checking" keeps it — a
222
+ // re-validation of a token that turns out fine should not flash the inbox empty.
223
+ if (frame.state === "none" || frame.state === "invalid") {
224
+ return { ...store, token: frame.state, gates: new Map() };
225
+ }
226
+ return { ...store, token: frame.state };
227
+
228
+ case "gates": {
229
+ // A whole re-fetch of one run's open gates (`GET /runs/:id` on a frame — ADR-0032). Empty
230
+ // means the card LEAVES: that is how a delivery's success shows, and the only way it does.
231
+ // No credential, no inbox: a re-fetch that was in flight when the token dropped must not
232
+ // repopulate the map the `token` frame just emptied (invisible while locked, but it would
233
+ // surface intact on the next unlock — guarded data outliving the credential that read it).
234
+ if (store.token !== "live") return store;
235
+ if (!frame.gates.length) return { ...store, gates: mapWithout(store.gates, frame.runId) };
236
+ const gates = new Map(store.gates);
237
+ gates.set(frame.runId, { workflow: frame.workflow, gates: frame.gates });
238
+ return { ...store, gates };
239
+ }
240
+
241
+ case "inboxScope":
242
+ return { ...store, inboxAll: frame.all };
243
+
244
+ case "startForm":
245
+ return { ...store, startFormFor: frame.workflow };
246
+
247
+ default:
248
+ return store;
249
+ }
250
+ }
251
+
252
+ /** A copy of `map` without `key` — or `map` itself when the key was never there, so a caller can
253
+ * cheaply tell "nothing changed". */
254
+ function mapWithout<K, V>(map: Map<K, V>, key: K): Map<K, V> {
255
+ if (!map.has(key)) return map;
256
+ const next = new Map(map);
257
+ next.delete(key);
258
+ return next;
259
+ }
260
+
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) {
268
+ if (entry.workflow !== workflow || present.has(runId)) continue;
269
+ if (next === gates) next = new Map(gates);
270
+ next.delete(runId);
271
+ }
272
+ return next;
273
+ }
274
+
275
+ /**
276
+ * Keep a selection pointed at a run the page can still say something about.
277
+ *
278
+ * This is the actual fix for the page that was opened before any run existed: with no push channel
279
+ * there was nothing to select and nothing to re-check, so the diagram stayed blank until a reload.
280
+ * A settled run still counts as selectable — a reader watching a run to its end keeps watching it.
281
+ */
282
+ function select(store: Store): Store {
283
+ if (store.selectedRunId && (store.runs.has(store.selectedRunId) || store.settled.has(store.selectedRunId))) {
284
+ return store;
285
+ }
286
+ const next = store.runs.keys().next();
287
+ return { ...store, selectedRunId: next.done ? null : next.value };
288
+ }
289
+
290
+ /** The run the page is currently showing, live or settled — whichever half still holds it. */
291
+ export function selectedRun(store: Store): ObservedRun | null {
292
+ if (store.selectedRunId === null) return null;
293
+ return store.runs.get(store.selectedRunId) ?? store.settled.get(store.selectedRunId) ?? null;
294
+ }
295
+
296
+ /** The selected workflow's run list as the rail draws it: live runs first, then what we watched
297
+ * leave (newest first). One ordered list keeps the renderer from having to know there are two
298
+ * maps behind it. */
299
+ export function runList(store: Store): Array<{ run: ObservedRun; settled: boolean }> {
300
+ return [
301
+ ...[...store.runs.values()].map((run) => ({ run, settled: false })),
302
+ ...[...store.settled.values()].reverse().map((run) => ({ run, settled: true })),
303
+ ];
304
+ }
305
+
306
+ /** One workflow's rail rows: the feed's view for the selection, the REST snapshot for the rest.
307
+ * Same shape either way, so the painter draws a run row without knowing which side fed it. */
308
+ export function fleetRuns(store: Store, workflow: string): Array<{ run: ObservedRun; settled: boolean }> {
309
+ if (workflow === store.workflow) return runList(store);
310
+ return (store.fleet.get(workflow) ?? []).map((run) => ({ run, settled: false }));
311
+ }
312
+
313
+ /** The inbox as the Attention tab draws it: every card, or the selection's slice. The inbox itself
314
+ * is GLOBAL (every workflow the fleet knows) — the filter is presentation, which is why it lives
315
+ * in a selector and not in `applyFrame`. */
316
+ export function visibleGates(store: Store): Array<{ runId: string; workflow: string; gates: GateCard[] }> {
317
+ const all = [...store.gates.entries()].map(([runId, entry]) => ({ runId, ...entry }));
318
+ if (store.inboxAll || !store.workflow) return all;
319
+ return all.filter((entry) => entry.workflow === store.workflow);
320
+ }
321
+
322
+ /** The SELECTED run's open gates — what the diagram pins (`GateView.path` resolves each one to a
323
+ * node). A selector, not reducer state: the pins are a view over the global inbox, exactly like
324
+ * {@link visibleGates}, and derive from two facts the store already holds. */
325
+ export function selectedRunGates(store: Store): GateCard[] {
326
+ if (store.selectedRunId === null) return [];
327
+ return store.gates.get(store.selectedRunId)?.gates ?? [];
328
+ }
329
+
330
+ /** What the nav badge counts: every open gate everywhere — attention is global even when the
331
+ * inbox view is filtered. */
332
+ export function gateCount(store: Store): number {
333
+ let n = 0;
334
+ for (const entry of store.gates.values()) n += entry.gates.length;
335
+ return n;
336
+ }