@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/store.ts
ADDED
|
@@ -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
|
+
}
|