@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,79 @@
1
+ // The Console shell (ADR-0032): nav over a master–detail main — the fleet rail on the left, the
2
+ // selected workflow's Machine in the center, the Attention/Activity drawer on the right. One pure
3
+ // render over the store plus the bootstrap's working state; Preact's diff replaces main.js's
4
+ // hand-rolled memo()/paint() machinery (ADR-0034). Which drawer tab the reader chose is view state
5
+ // like the zoom — component state, never the store's.
6
+
7
+ import { Fragment, h, type JSX } from "preact";
8
+ import { useState } from "preact/hooks";
9
+ import { selectedRun, selectedRunGates, type Frame, type Store } from "../store.ts";
10
+ import type { MachineDoc } from "../canvas.ts";
11
+ import { Nav } from "./nav.ts";
12
+ import { Fleet } from "./fleet.ts";
13
+ import { Drawer, type DrawerTab } from "./drawer.ts";
14
+ import { MachinePane } from "./machine-pane.ts";
15
+
16
+ /**
17
+ * What the bootstrap (main.ts) hands the components: every write path in one object, so no
18
+ * component ever reaches a fetch, the history API, or the canvas on its own. `dispatch` stays the
19
+ * store's one door (ADR-0032 store-first); the rest wrap the guarded surface and the viewport.
20
+ */
21
+ export type AppApi = {
22
+ /** Fold one frame into the store and re-render — the ONLY way belief changes. */
23
+ dispatch(frame: Frame): void;
24
+ /** Move the selection: address, store, feed, diagram — `push` on a click that moves. */
25
+ selectWorkflow(name: string | null, opts?: { push?: boolean; runId?: string | null }): Promise<void>;
26
+ toggleStartForm(name: string): Promise<void>;
27
+ /** POST the start body; resolves to an inline error string, or null when the run started. */
28
+ startRun(name: string, body: Record<string, unknown>): Promise<string | null>;
29
+ /** POST one gate event; same contract as {@link AppApi.startRun}. */
30
+ deliverGate(runId: string, gate: string, event: string, body: Record<string, unknown>): Promise<string | null>;
31
+ setToken(value: string): void;
32
+ /** The canvas reports its zoom readout through here (nav renders it). */
33
+ onScale(pct: number): void;
34
+ zoomIn(): void;
35
+ zoomOut(): void;
36
+ zoomReset(): void;
37
+ zoomFit(): void;
38
+ };
39
+
40
+ /** The center pane's working state — the machine doc in hand (or why there is none). Fetch
41
+ * bookkeeping like main.ts's doc cache, deliberately NOT store belief: the doc is structure the
42
+ * server serves, and `dispatch` stays the only door into what the page believes. */
43
+ export type MachineView = {
44
+ doc: MachineDoc | null;
45
+ /** `/` — nothing selected; the canvas says what to do instead. */
46
+ placeholder: string | null;
47
+ /** An error ("no workflow"), or — with `notice` — a caveat about the diagram (opaque states). */
48
+ note: { text: string; notice: boolean } | null;
49
+ };
50
+
51
+ export function App(props: {
52
+ store: Store;
53
+ view: MachineView;
54
+ zoomPct: number;
55
+ initialToken: string;
56
+ schemas: ReadonlyMap<string, unknown>;
57
+ api: AppApi;
58
+ }): JSX.Element {
59
+ const { store, view, zoomPct, initialToken, schemas, api } = props;
60
+ const [chosenTab, setChosenTab] = useState<DrawerTab>("attention");
61
+ return h(
62
+ Fragment,
63
+ null,
64
+ h(Nav, { store, machineId: view.doc?.id ?? null, zoomPct, initialToken, onChooseTab: setChosenTab, api }),
65
+ h(
66
+ "main",
67
+ null,
68
+ h(Fleet, { store, schemas, api }),
69
+ h(MachinePane, {
70
+ view,
71
+ run: selectedRun(store),
72
+ selectedNodeId: store.selectedNodeId,
73
+ gates: selectedRunGates(store),
74
+ api,
75
+ }),
76
+ h(Drawer, { store, chosenTab, onChooseTab: setChosenTab, api }),
77
+ ),
78
+ );
79
+ }
@@ -0,0 +1,131 @@
1
+ // The drawer: Attention (the gate inbox) + Activity (the emit log). Tokenless there is no
2
+ // Attention tab at all — the observer band of ADR-0014/0032 — and a card is only ever a re-fetched
3
+ // fact: it empties when the server's next answer says so, never through bookkeeping on this side.
4
+
5
+ import { h, type JSX } from "preact";
6
+ import { visibleGates, type GateCard, type Store } from "../store.ts";
7
+ import { SchemaForm } from "./schema-form.ts";
8
+ import type { AppApi } from "./app.ts";
9
+
10
+ /** Which tab the reader last chose. View state like the zoom, not belief — the store's `token`
11
+ * decides whether Attention exists at all. */
12
+ export type DrawerTab = "attention" | "activity";
13
+
14
+ export function Drawer(props: {
15
+ store: Store;
16
+ chosenTab: DrawerTab;
17
+ onChooseTab: (tab: DrawerTab) => void;
18
+ api: AppApi;
19
+ }): JSX.Element {
20
+ const { store, chosenTab, onChooseTab, api } = props;
21
+ const unlocked = store.token === "live";
22
+ const tab = unlocked ? chosenTab : "activity"; // tokenless: no Attention tab — today's observer
23
+ return h(
24
+ "aside",
25
+ { id: "drawer" },
26
+ h(
27
+ "div",
28
+ { class: "drawer-tabs" },
29
+ h(
30
+ "button",
31
+ {
32
+ id: "tab-attention",
33
+ hidden: !unlocked,
34
+ class: tab === "attention" ? "tab--active" : "",
35
+ onClick: () => onChooseTab("attention"),
36
+ },
37
+ "Attention",
38
+ ),
39
+ h(
40
+ "button",
41
+ { id: "tab-activity", class: tab === "activity" ? "tab--active" : "", onClick: () => onChooseTab("activity") },
42
+ "Activity",
43
+ ),
44
+ ),
45
+ h(
46
+ "div",
47
+ { id: "attention-panel", hidden: tab !== "attention" },
48
+ h(
49
+ "label",
50
+ { class: "inbox-scope" },
51
+ h("input", {
52
+ id: "inbox-all",
53
+ type: "checkbox",
54
+ checked: store.inboxAll,
55
+ onChange: (e: Event) =>
56
+ api.dispatch({ kind: "inboxScope", all: (e.currentTarget as HTMLInputElement).checked }),
57
+ }),
58
+ " all workflows",
59
+ ),
60
+ h("ul", { id: "gate-inbox" }, inbox(store, api)),
61
+ ),
62
+ h("div", { id: "activity-panel", hidden: tab !== "activity" }, h("ul", { id: "emit-log" }, activity(store))),
63
+ );
64
+ }
65
+
66
+ function inbox(store: Store, api: AppApi): JSX.Element | JSX.Element[] {
67
+ const cards = visibleGates(store);
68
+ if (!cards.length) {
69
+ return h(
70
+ "li",
71
+ { class: "empty" },
72
+ store.inboxAll || !store.workflow ? "no gates waiting" : "no gates waiting here",
73
+ );
74
+ }
75
+ return cards.flatMap(({ runId, workflow, gates }) =>
76
+ gates.map((view) => inboxCard(runId, workflow, view, store, api)),
77
+ );
78
+ }
79
+
80
+ /** One parked decision: meta to read, one form per accepted event (the shared ADR-0033 form). */
81
+ function inboxCard(runId: string, workflow: string, view: GateCard, store: Store, api: AppApi): JSX.Element {
82
+ return h(
83
+ "li",
84
+ { key: `${runId}/${view.gate}`, class: "gate-card" },
85
+ h(
86
+ "div",
87
+ {
88
+ class: "gate-head",
89
+ title: "show this run",
90
+ onClick: () => {
91
+ if (workflow === store.workflow) api.dispatch({ kind: "selectRun", runId });
92
+ else void api.selectWorkflow(workflow, { push: true, runId });
93
+ },
94
+ },
95
+ h("span", { class: "gate-id" }, `⚑ ${view.gate}`),
96
+ h("span", { class: "dim" }, `${workflow} · ${runId.slice(0, 8)}`),
97
+ ),
98
+ view.meta && Object.keys(view.meta).length
99
+ ? h(
100
+ "dl",
101
+ { class: "gate-meta" },
102
+ Object.entries(view.meta).flatMap(([k, v]) => [
103
+ h("dt", { key: `t:${k}` }, k),
104
+ h("dd", { key: `d:${k}` }, typeof v === "string" ? v : JSON.stringify(v)),
105
+ ]),
106
+ )
107
+ : null,
108
+ view.accepts.map((accepted) =>
109
+ h(
110
+ "div",
111
+ { key: accepted.name, class: "gate-event" },
112
+ h("div", { class: "gate-event-name", title: accepted.description }, accepted.name),
113
+ h(SchemaForm, {
114
+ schema: accepted.input,
115
+ submitLabel: "send",
116
+ // Deliberately NO local bookkeeping on success: the delivery moves the Machine, the move
117
+ // lands a frame, the frame triggers the re-fetch, and the re-fetch empties this card
118
+ // (ADR-0032) — `deliverGate` concludes nothing on its own.
119
+ onSubmit: (body) => api.deliverGate(runId, view.gate, accepted.name, body),
120
+ }),
121
+ ),
122
+ ),
123
+ );
124
+ }
125
+
126
+ function activity(store: Store): JSX.Element | JSX.Element[] {
127
+ if (!store.emits.length) return h("li", { class: "empty" }, "no emits yet");
128
+ return store.emits.map((emitted) =>
129
+ h("li", null, emitted.type, h("span", { class: "dim" }, emitted.runId.slice(0, 8))),
130
+ );
131
+ }
@@ -0,0 +1,117 @@
1
+ // The fleet rail: every registered workflow, each expandable to its runs — the master half of the
2
+ // master–detail shell (ADR-0032). Every row derives from store selectors (`fleetRuns`), never a
3
+ // component-local cache; the vdom's keyed diff is what keeps a half-typed start form alive under
4
+ // the status frames that used to demand main.js's hand-rolled `memo()`.
5
+
6
+ import { h, type JSX } from "preact";
7
+ import { fleetRuns, type ObservedRun, type Store } from "../store.ts";
8
+ import { SchemaForm } from "./schema-form.ts";
9
+ import type { AppApi } from "./app.ts";
10
+
11
+ export function Fleet(props: { store: Store; schemas: ReadonlyMap<string, unknown>; api: AppApi }): JSX.Element {
12
+ const { store, schemas, api } = props;
13
+ return h(
14
+ "aside",
15
+ { id: "fleet" },
16
+ h("div", { class: "sidebar-head" }, h("h2", null, "Fleet")),
17
+ h(
18
+ "ul",
19
+ { id: "workflow-list" },
20
+ store.workflows.length
21
+ ? store.workflows.map((name) => workflowItem(name, store, schemas, api))
22
+ : h("li", { class: "empty" }, "no workflows registered"),
23
+ ),
24
+ );
25
+ }
26
+
27
+ function workflowItem(name: string, store: Store, schemas: ReadonlyMap<string, unknown>, api: AppApi): JSX.Element {
28
+ const rows = fleetRuns(store, name);
29
+ return h(
30
+ "li",
31
+ { key: name, class: name === store.workflow ? "wf wf--selected" : "wf" },
32
+ h(
33
+ "div",
34
+ { class: "wf-head", onClick: () => void api.selectWorkflow(name, { push: true }) },
35
+ h(
36
+ "button",
37
+ {
38
+ class: "wf-caret",
39
+ title: "fold this workflow's runs",
40
+ onClick: (e: Event) => {
41
+ e.stopPropagation();
42
+ api.dispatch({ kind: "toggleWorkflow", workflow: name });
43
+ },
44
+ },
45
+ store.expanded.has(name) ? "▾" : "▸",
46
+ ),
47
+ h("span", { class: "wf-name" }, name),
48
+ h("span", { class: "wf-count" }, String(rows.filter((r) => !r.settled).length)),
49
+ store.token === "live"
50
+ ? h(
51
+ "button",
52
+ {
53
+ class: "wf-start-btn",
54
+ title: `start a ${name} run`,
55
+ onClick: (e: Event) => {
56
+ e.stopPropagation();
57
+ void api.toggleStartForm(name);
58
+ },
59
+ },
60
+ "start",
61
+ )
62
+ : null,
63
+ ),
64
+ store.startFormFor === name ? startForm(name, schemas, api) : null,
65
+ store.expanded.has(name) ? runRows(name, rows, store, api) : null,
66
+ );
67
+ }
68
+
69
+ /** The start-run form (ADR-0033), or its placeholder while the input schema is on the wire. */
70
+ function startForm(name: string, schemas: ReadonlyMap<string, unknown>, api: AppApi): JSX.Element {
71
+ if (!schemas.has(name)) return h("div", { class: "start-form" }, "loading input schema…");
72
+ return h(
73
+ "div",
74
+ { class: "start-form" },
75
+ h(SchemaForm, {
76
+ schema: schemas.get(name),
77
+ submitLabel: "start run",
78
+ onSubmit: (body) => api.startRun(name, body),
79
+ }),
80
+ );
81
+ }
82
+
83
+ function runRows(
84
+ name: string,
85
+ rows: Array<{ run: ObservedRun; settled: boolean }>,
86
+ store: Store,
87
+ api: AppApi,
88
+ ): JSX.Element {
89
+ return h(
90
+ "ul",
91
+ { class: "wf-runs" },
92
+ rows.length
93
+ ? rows.map(({ run, settled }) => runRow(name, run, settled, store, api))
94
+ : h("li", { class: "empty" }, "no live runs"),
95
+ );
96
+ }
97
+
98
+ function runRow(name: string, run: ObservedRun, settled: boolean, store: Store, api: AppApi): JSX.Element {
99
+ const gates = store.gates.get(run.runId)?.gates.length ?? 0;
100
+ const selected = name === store.workflow && run.runId === store.selectedRunId;
101
+ return h(
102
+ "li",
103
+ {
104
+ key: run.runId,
105
+ class: `${settled ? "settled" : ""}${selected ? " selected" : ""}`.trim(),
106
+ onClick: () => {
107
+ if (name === store.workflow) api.dispatch({ kind: "selectRun", runId: run.runId });
108
+ else void api.selectWorkflow(name, { push: true, runId: run.runId });
109
+ },
110
+ },
111
+ h("span", { class: "run-id" }, run.runId.slice(0, 8)),
112
+ gates
113
+ ? h("span", { class: "gate-badge", title: `${gates} open gate${gates === 1 ? "" : "s"}` }, `⚑${gates}`)
114
+ : null,
115
+ h("span", { class: `run-status status--${run.status}` }, run.status),
116
+ );
117
+ }
@@ -0,0 +1,85 @@
1
+ // The center pane: the selected workflow's Machine. The vdom renders the pane's chrome — the
2
+ // placeholder, the error/notice, the root-transition strip — and mounts one <svg> it then never
3
+ // looks inside: canvas.ts owns everything under that ref imperatively (ADR-0034 — the canvas is
4
+ // deliberately NOT vdom-ified; elk's layout is async and pan/zoom is a 60Hz transform).
5
+
6
+ import { h, type JSX } from "preact";
7
+ import { useEffect, useRef } from "preact/hooks";
8
+ import type { GateCard, ObservedRun } from "../store.ts";
9
+ import {
10
+ clearCanvas,
11
+ initCanvas,
12
+ updateCanvas,
13
+ type MachineDoc,
14
+ type MachineStateDoc,
15
+ type MachineTransitionDoc,
16
+ } from "../canvas.ts";
17
+ import type { AppApi, MachineView } from "./app.ts";
18
+
19
+ export function MachinePane(props: {
20
+ view: MachineView;
21
+ run: ObservedRun | null;
22
+ selectedNodeId: string | null;
23
+ gates: GateCard[];
24
+ api: AppApi;
25
+ }): JSX.Element {
26
+ const { view, run, selectedNodeId, gates, api } = props;
27
+ const canvasRef = useRef<HTMLElement>(null);
28
+ const svgRef = useRef<SVGSVGElement>(null);
29
+
30
+ // Mount: hand the refs to the imperative island once. Declared first, so it runs before the
31
+ // update effect below (same commit, declaration order).
32
+ useEffect(() => {
33
+ initCanvas(
34
+ { canvas: canvasRef.current!, svg: svgRef.current! },
35
+ {
36
+ onSelectNode: (nodeId) => api.dispatch({ kind: "selectNode", nodeId }),
37
+ onScale: api.onScale,
38
+ },
39
+ );
40
+ // The canvas outlives every render and there is exactly one pane — no teardown to return.
41
+ }, []);
42
+
43
+ // Re-run the imperative render when its inputs move: the doc, the selected run's status (a new
44
+ // object per frame), the node selection, the gate paths. `updateCanvas` re-lays out only when
45
+ // the doc or the instance SET changed; everything else is a highlight pass (canvas.ts). No doc
46
+ // means FORGET the diagram now — frames landing before the next doc arrives must not touch the
47
+ // machine the reader just left.
48
+ const gateKey = gates.map((g) => (g.path ?? []).join("/")).join(",");
49
+ useEffect(() => {
50
+ if (view.doc) updateCanvas(view.doc, run, selectedNodeId, gates);
51
+ else clearCanvas();
52
+ }, [view.doc, run, selectedNodeId, gateKey]);
53
+
54
+ return h(
55
+ "section",
56
+ { id: "canvas", ref: canvasRef },
57
+ h("div", { id: "placeholder", hidden: !view.placeholder }, view.placeholder ?? ""),
58
+ h("div", { id: "error", hidden: !view.note, class: view.note?.notice ? "notice" : "" }, view.note?.text ?? ""),
59
+ rootTransitionStrip(view.doc),
60
+ h("svg", { id: "machine-svg", ref: svgRef }),
61
+ );
62
+ }
63
+
64
+ /** Machine-level transitions ("from any state") as the strip above the Machine — an edge from the
65
+ * root would lie, because the root has no box (it renders as the page). */
66
+ function rootTransitionStrip(doc: MachineDoc | null): JSX.Element {
67
+ const transitions: MachineTransitionDoc[] = doc ? doc.transitions.filter((t) => t.source === doc.root.id) : [];
68
+ const keyOf = new Map<string, string>();
69
+ if (doc) {
70
+ const index = (s: MachineStateDoc): void => {
71
+ keyOf.set(s.id, s.key);
72
+ s.states.forEach(index);
73
+ };
74
+ index(doc.root);
75
+ }
76
+ return h(
77
+ "div",
78
+ { id: "root-transitions" },
79
+ transitions.map((t, i) => {
80
+ const guard = t.guard ? ` [${t.guard}]` : "";
81
+ const target = t.targets.length ? ` → ${t.targets.map((id) => keyOf.get(id) ?? id).join(", ")}` : "";
82
+ return h("span", { key: i }, `on ${t.label}${guard}${target}`);
83
+ }),
84
+ );
85
+ }
@@ -0,0 +1,81 @@
1
+ // The nav: breadcrumb (the address, made clickable), the Machine's id, the zoom controls, the
2
+ // fleet-wide attention badge, the feed state, and the token box (ADR-0032). The token INPUT is
3
+ // uncontrolled — its value is the reader's secret, never state — seeded once from sessionStorage
4
+ // and committed on the change event; the store carries only the token's STATE, which is what the
5
+ // badge renders.
6
+
7
+ import { h, type JSX } from "preact";
8
+ import { gateCount, type Store, type TokenState } from "../store.ts";
9
+ import type { AppApi } from "./app.ts";
10
+
11
+ const TOKEN_BADGES: Record<TokenState, string> = { none: "", checking: "…", live: "live", invalid: "invalid" };
12
+
13
+ export function Nav(props: {
14
+ store: Store;
15
+ machineId: string | null;
16
+ zoomPct: number;
17
+ initialToken: string;
18
+ onChooseTab: (tab: "attention") => void;
19
+ api: AppApi;
20
+ }): JSX.Element {
21
+ const { store, machineId, zoomPct, initialToken, onChooseTab, api } = props;
22
+ const connection = store.workflow ? store.connection : "idle";
23
+ const count = gateCount(store);
24
+ return h(
25
+ "header",
26
+ null,
27
+ h(
28
+ "nav",
29
+ { id: "breadcrumb" },
30
+ h(
31
+ "a",
32
+ {
33
+ id: "crumb-root",
34
+ href: "/",
35
+ title: "the fleet",
36
+ onClick: (e: Event) => {
37
+ e.preventDefault();
38
+ void api.selectWorkflow(null, { push: true });
39
+ },
40
+ },
41
+ h("span", { class: "mark" }, "jr2"),
42
+ ),
43
+ h("span", { id: "crumb-sep", class: "dim", hidden: !store.workflow }, "/"),
44
+ h("h1", { id: "workflow-name" }, store.workflow ?? ""),
45
+ ),
46
+ h("span", { id: "machine-id", class: "dim" }, machineId ? `machine: ${machineId}` : ""),
47
+ h(
48
+ "div",
49
+ { class: "zoom" },
50
+ h("button", { id: "zoom-out", title: "zoom out", onClick: () => api.zoomOut() }, "−"),
51
+ h("button", { id: "zoom-reset", title: "reset zoom", onClick: () => api.zoomReset() }, `${zoomPct}%`),
52
+ h("button", { id: "zoom-in", title: "zoom in", onClick: () => api.zoomIn() }, "+"),
53
+ h("button", { id: "zoom-fit", title: "fit to width", onClick: () => api.zoomFit() }, "fit"),
54
+ ),
55
+ h(
56
+ "button",
57
+ {
58
+ id: "attention-badge",
59
+ hidden: store.token !== "live" || count === 0,
60
+ title: "open gates across the fleet",
61
+ onClick: () => onChooseTab("attention"),
62
+ },
63
+ "⚑ ",
64
+ h("span", { id: "attention-count" }, String(count)),
65
+ ),
66
+ h("span", { id: "connection", class: `conn conn--${connection}`, title: "feed connection" }, connection),
67
+ h(
68
+ "label",
69
+ { id: "token-box", title: "Instance token — unlocks start & gates (ADR-0032)" },
70
+ h("input", {
71
+ id: "token",
72
+ type: "password",
73
+ placeholder: "instance token",
74
+ autocomplete: "off",
75
+ defaultValue: initialToken,
76
+ onChange: (e: Event) => api.setToken((e.currentTarget as HTMLInputElement).value.trim()),
77
+ }),
78
+ h("span", { id: "token-state", class: `token-state token-state--${store.token}` }, TOKEN_BADGES[store.token]),
79
+ ),
80
+ );
81
+ }
@@ -0,0 +1,137 @@
1
+ // The shared form (start-run + gate delivery — ADR-0033): one component for both writes the
2
+ // Console makes. A flat object schema becomes typed inputs (string/number/boolean/enum, required
3
+ // marked), no schema becomes a raw JSON textarea. `onSubmit` resolves to an error string to render
4
+ // inline (the server's 400 names the accepted shape — that text IS the form's error display) or null when the write
5
+ // landed. The inputs are UNCONTROLLED: what the reader is typing is theirs, not state, and the
6
+ // vdom's keyed diff keeps the elements — and so the half-typed text — alive across status frames.
7
+
8
+ import { h, type JSX } from "preact";
9
+ import { useState } from "preact/hooks";
10
+
11
+ /** One property of a flat object schema, as far as the form reads it. */
12
+ type SchemaProp = { type?: string; enum?: unknown[]; description?: string };
13
+
14
+ /** The schema's `properties`, or null when there is no schema to generate from (→ raw textarea). */
15
+ function propsOf(schema: unknown): Record<string, SchemaProp> | null {
16
+ if (!schema || typeof schema !== "object") return null;
17
+ return (schema as { properties?: Record<string, SchemaProp> }).properties ?? {};
18
+ }
19
+
20
+ function requiredOf(schema: unknown): Set<unknown> {
21
+ const required = (schema as { required?: unknown } | null)?.required;
22
+ return new Set(Array.isArray(required) ? required : []);
23
+ }
24
+
25
+ export function SchemaForm(props: {
26
+ schema: unknown;
27
+ submitLabel: string;
28
+ onSubmit: (body: Record<string, unknown>) => Promise<string | null>;
29
+ }): JSX.Element {
30
+ const { schema, submitLabel, onSubmit } = props;
31
+ const [error, setError] = useState<string | null>(null);
32
+ const [busy, setBusy] = useState(false);
33
+ const properties = propsOf(schema);
34
+ const required = requiredOf(schema);
35
+
36
+ const submit = (e: Event): void => {
37
+ e.preventDefault();
38
+ setError(null);
39
+ let body: Record<string, unknown>;
40
+ try {
41
+ body = collect(e.currentTarget as HTMLFormElement, properties);
42
+ } catch (ex) {
43
+ setError(ex instanceof Error ? ex.message : String(ex));
44
+ return;
45
+ }
46
+ setBusy(true);
47
+ void onSubmit(body)
48
+ .catch((ex: unknown) => String(ex))
49
+ .then((failure) => {
50
+ setBusy(false);
51
+ if (failure) setError(failure); // the server's 400 body, inline where the reader typed
52
+ });
53
+ };
54
+
55
+ return h(
56
+ "form",
57
+ { class: "schema-form", onSubmit: submit },
58
+ properties === null
59
+ ? h("textarea", { placeholder: "{ } — raw JSON: this workflow declares no input schema", rows: 3 })
60
+ : Object.entries(properties).map(([key, prop]) => field(key, prop, required)),
61
+ h("button", { type: "submit", disabled: busy }, submitLabel),
62
+ h("div", { class: "form-error", hidden: error === null }, error ?? ""),
63
+ );
64
+ }
65
+
66
+ function field(key: string, prop: SchemaProp, required: Set<unknown>): JSX.Element {
67
+ let input: JSX.Element;
68
+ if (Array.isArray(prop.enum)) {
69
+ // Options carry the value's INDEX, not its string: a numeric enum must round-trip as a
70
+ // number or the server 400s a form no input could ever satisfy. An optional enum gets an
71
+ // "(omit)" first option — a <select> always holds something, so absence needs a row.
72
+ input = h(
73
+ "select",
74
+ { name: key },
75
+ ...(required.has(key) ? [] : [h("option", { value: "" }, "(omit)")]),
76
+ ...prop.enum.map((v, i) => h("option", { value: String(i) }, typeof v === "string" ? v : JSON.stringify(v))),
77
+ );
78
+ } else if (prop.type === "boolean") {
79
+ // A checkbox always answers (unchecked reads as false), which would override a server
80
+ // default the schema marked optional — an optional boolean must be OMISSIBLE.
81
+ input = required.has(key)
82
+ ? h("input", { name: key, type: "checkbox" })
83
+ : h(
84
+ "select",
85
+ { name: key },
86
+ h("option", { value: "" }, "(omit)"),
87
+ h("option", { value: "true" }, "true"),
88
+ h("option", { value: "false" }, "false"),
89
+ );
90
+ } else if (prop.type === "number" || prop.type === "integer") {
91
+ input = h("input", { name: key, type: "number", step: "any" });
92
+ } else {
93
+ input = h("input", { name: key, type: "text" });
94
+ }
95
+ return h("label", { key, title: prop.description }, h("span", null, required.has(key) ? `${key} *` : key), input);
96
+ }
97
+
98
+ /** The submit body, read back off the DOM the fields live in. Typed fields: empty optional inputs
99
+ * are ABSENT, not "" — the server's schema is the authority on required-ness and says so in its
100
+ * 400. The raw textarea must parse to an object. */
101
+ function collect(form: HTMLFormElement, properties: Record<string, SchemaProp> | null): Record<string, unknown> {
102
+ if (properties === null) {
103
+ const text = (form.querySelector("textarea")?.value ?? "").trim();
104
+ if (!text) return {};
105
+ let parsed: unknown;
106
+ try {
107
+ parsed = JSON.parse(text);
108
+ } catch {
109
+ throw new Error("not valid JSON");
110
+ }
111
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
112
+ throw new Error("the body must be a JSON object");
113
+ }
114
+ return parsed as Record<string, unknown>;
115
+ }
116
+ const body: Record<string, unknown> = {};
117
+ for (const [key, prop] of Object.entries(properties)) {
118
+ const el = form.elements.namedItem(key);
119
+ if (!(el instanceof HTMLInputElement || el instanceof HTMLSelectElement)) continue;
120
+ if (el instanceof HTMLInputElement && el.type === "checkbox") {
121
+ body[key] = el.checked; // a required boolean: always an answer, by design
122
+ continue;
123
+ }
124
+ const value = el.value;
125
+ if (value === "") continue;
126
+ if (Array.isArray(prop.enum)) {
127
+ body[key] = prop.enum[Number(value)]; // the option held the index — the VALUE keeps its type
128
+ continue;
129
+ }
130
+ if (prop.type === "boolean") {
131
+ body[key] = value === "true"; // the optional-boolean select
132
+ continue;
133
+ }
134
+ body[key] = prop.type === "number" || prop.type === "integer" ? Number(value) : value;
135
+ }
136
+ return body;
137
+ }