@jr2/orchestrator 0.3.0 → 0.4.1

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 (73) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -4
  13. package/src/actor.ts +502 -49
  14. package/src/ambient.ts +2 -0
  15. package/src/assembly-checks.ts +165 -0
  16. package/src/assembly-tar.ts +494 -0
  17. package/src/assembly.ts +721 -0
  18. package/src/chart.ts +2050 -0
  19. package/src/charts.ts +720 -0
  20. package/src/config.ts +152 -46
  21. package/src/content-hash.ts +105 -0
  22. package/src/custodian.lua +6 -5
  23. package/src/custodian.ts +71 -19
  24. package/src/customize.ts +216 -25
  25. package/src/effect-checks.ts +557 -0
  26. package/src/effect.ts +905 -0
  27. package/src/effector-wire.ts +525 -0
  28. package/src/fingerprint.ts +4 -3
  29. package/src/gate.ts +8 -0
  30. package/src/git-clone.ts +65 -0
  31. package/src/git-push.ts +336 -0
  32. package/src/harness-client.ts +245 -23
  33. package/src/held-secrets.ts +82 -26
  34. package/src/holds.ts +156 -0
  35. package/src/http-effect.ts +981 -0
  36. package/src/http.ts +496 -104
  37. package/src/images.ts +47 -21
  38. package/src/index.ts +43 -2
  39. package/src/instance.ts +444 -58
  40. package/src/kube-client.ts +333 -0
  41. package/src/machine-doc.ts +88 -6
  42. package/src/mount-digest.ts +53 -0
  43. package/src/names.ts +66 -8
  44. package/src/notices.ts +88 -0
  45. package/src/open-body.ts +1444 -0
  46. package/src/open-effect.ts +144 -0
  47. package/src/open.ts +4 -4
  48. package/src/parts.ts +530 -33
  49. package/src/party.ts +468 -0
  50. package/src/pick.ts +309 -0
  51. package/src/pool.ts +1 -1
  52. package/src/registration.ts +301 -17
  53. package/src/repo-fetch.ts +60 -85
  54. package/src/repos.ts +63 -74
  55. package/src/rest.ts +133 -0
  56. package/src/run-host.ts +1832 -98
  57. package/src/sandbox-effect.ts +891 -0
  58. package/src/sandbox-effector.ts +111 -0
  59. package/src/sandbox-kube.ts +1719 -0
  60. package/src/sandbox-watch.ts +260 -0
  61. package/src/secret-placement.ts +494 -0
  62. package/src/server.ts +281 -84
  63. package/src/setup.ts +589 -70
  64. package/src/size.ts +342 -0
  65. package/src/snapshot-store.ts +64 -13
  66. package/src/stub-harness.ts +5 -0
  67. package/src/sync-client.ts +54 -0
  68. package/src/timers.ts +178 -0
  69. package/src/tokens.ts +35 -4
  70. package/src/vocabulary.ts +66 -1
  71. package/src/wire.ts +156 -2
  72. package/src/workspace.ts +529 -194
  73. package/src/sandbox-kubectl.ts +0 -1108
package/bin/server.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // The instance image's entrypoint (ADR-0019): boot the orchestrator described by env + cwd and
3
- // serve until SIGINT/SIGTERM. A zero-build `.ts` shebang like the `jr2` bin; the e2e tier spawns
4
- // this exact process as its per-scenario fixture (ADR-0010). Logic lives in `serverMain` — this
5
- // file owns only what a process owns: env, cwd, stdout, signals, exit.
2
+ // The Orchestrator Kit image's entrypoint (ADR-0019, ADR-0069): boot the orchestrator described
3
+ // by env + cwd and serve until SIGINT/SIGTERM. The cwd holds the state volume, and the Workflows
4
+ // come from the current Assembly on it; the folder itself is never loaded, so a process with no
5
+ // published Assembly serves none until `jr2 up` publishes one. A zero-build `.ts` shebang like
6
+ // the `jr2` bin; the e2e tier spawns this exact process as its per-scenario fixture and publishes
7
+ // through the same door (ADR-0010). Logic lives in `serverMain` — this file owns only what a
8
+ // process owns: env, cwd, stdout, signals, exit.
6
9
 
7
10
  import { serverMain } from "../src/server.ts";
8
11
 
package/console/canvas.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // Working state only, never belief: the fold set, the viewport transform, and what is SHOWN live
9
9
  // here — everything the page believes stays in store.ts and arrives as `updateCanvas` arguments.
10
10
 
11
- import type { GateCard, ObservedRun } from "./store.ts";
11
+ import type { GateCard, ObservedRun, Seat } from "./store.ts";
12
12
 
13
13
  // ---- The machine doc, as `GET /workflows/:name/machine` serves it -------------------------------
14
14
  // Mirrors machine-doc.ts (the server's serializer) the way store.ts mirrors `RunObservation`: the
@@ -57,7 +57,8 @@ export type ChildMachineDoc = {
57
57
  export type MachineEventDoc = {
58
58
  name: string;
59
59
  description?: string;
60
- audience: "agent" | "external" | "any";
60
+ /** Absent: each writing's state decides who may send it (ADR-0015). */
61
+ audience?: "agent" | "external" | "any";
61
62
  /** The def's input schema as JSON Schema. */
62
63
  input: unknown;
63
64
  };
@@ -70,6 +71,10 @@ export type MachineBodyDoc = {
70
71
  /** The events THIS Machine declares, and only this one (ADR-0049): a nested Machine's ride its
71
72
  * own body doc, because event names are scoped to the Machine that declared them. */
72
73
  events: MachineEventDoc[];
74
+ /** Who may send what, per state (ADR-0015) — mirrors `MachineSeat`. */
75
+ seats: Seat[];
76
+ /** What the build found legal and suspect (ADR-0015). */
77
+ warnings: string[];
73
78
  };
74
79
 
75
80
  /** The serialized structure of a workflow's Machine — mirrors `MachineDoc`. */
@@ -77,6 +82,8 @@ export type MachineDoc = MachineBodyDoc & {
77
82
  workflow: string;
78
83
  /** States whose child list may be incomplete (enqueueActions closures) — surfaced as a notice. */
79
84
  opaqueStates?: string[];
85
+ /** A run's doc only: each bound Chart it could not draw, and why (ADR-0070) — surfaced as a notice. */
86
+ chartsNotDrawn?: Array<{ path: string; src: string; reason: string }>;
80
87
  };
81
88
 
82
89
  /** One live child actor as the observation band reports it — `ObservedRun.children`, deepened
@@ -138,9 +145,11 @@ export function initCanvas(els: { canvas: HTMLElement; svg: SVGSVGElement }, can
138
145
  svg = els.svg;
139
146
  hooks = canvasHooks;
140
147
  wireCanvasNavigation();
141
- addEventListener("resize", () => {
142
- if (fitting) fitToWidth();
143
- });
148
+ // The pane changes size with the window AND with the strips above the diagram (a wait, a held
149
+ // pick, the seats opening) — a fit follows either.
150
+ new ResizeObserver(() => {
151
+ if (fitting) fitWhole();
152
+ }).observe(svg);
144
153
  }
145
154
 
146
155
  const SVG_NS = "http://www.w3.org/2000/svg";
@@ -498,8 +507,8 @@ let layoutSize = { width: 0, height: 0 };
498
507
  let scale = 1;
499
508
  let panX = 0;
500
509
  let panY = 0;
501
- /** Fit the Machine's width to the canvas, and keep fitting it as the diagram grows — until the
502
- * reader takes the view into their own hands, after which it is theirs. */
510
+ /** Fit the whole Machine to the canvas, and keep fitting it as the diagram or the pane changes —
511
+ * until the reader takes the view into their own hands, after which it is theirs. */
503
512
  let fitting = true;
504
513
  /** The <g> the current render mounted — the transform target; each re-render swaps it in and
505
514
  * re-applies the view, so a relayout never resets where the reader was looking. */
@@ -513,11 +522,15 @@ function applyView(): void {
513
522
  hooks.onScale(Math.round(scale * 100)); // the nav readout follows every path
514
523
  }
515
524
 
516
- /** Scale so the whole width lands in the pane, centered. Never magnifies — a small Machine stays
517
- * 1:1. */
518
- function fitToWidth(): void {
519
- if (!layoutSize.width || svg.clientWidth <= 0) return applyView();
520
- scale = Math.min(1, Math.max(0.2, (svg.clientWidth - FIT_PAD * 2) / layoutSize.width));
525
+ /** Scale so the whole Machine — its width AND its height — lands in the pane, centered across.
526
+ * Never magnifies (a small Machine stays 1:1), and never below the floor: a Machine larger than the
527
+ * floor allows pans. Fitting the width alone left a tall Machine's lower states — a `workspace()`
528
+ * body under the wrapper's chain — below the pane, where no reader looks for them. */
529
+ function fitWhole(): void {
530
+ if (!layoutSize.width || svg.clientWidth <= 0 || svg.clientHeight <= 0) return applyView();
531
+ const across = (svg.clientWidth - FIT_PAD * 2) / layoutSize.width;
532
+ const down = (svg.clientHeight - FIT_PAD * 2) / layoutSize.height;
533
+ scale = Math.min(1, Math.max(0.2, Math.min(across, down)));
521
534
  panX = Math.max(FIT_PAD, (svg.clientWidth - layoutSize.width * scale) / 2);
522
535
  panY = FIT_PAD;
523
536
  applyView();
@@ -547,7 +560,7 @@ export function zoomReset(): void {
547
560
  }
548
561
  export function zoomFit(): void {
549
562
  fitting = true;
550
- fitToWidth();
563
+ fitWhole();
551
564
  }
552
565
 
553
566
  // ---- Direct navigation: drag pans, wheel zooms --------------------------------------------------
@@ -638,7 +651,7 @@ async function renderMachine(doc: MachineBodyDoc, live: RunChild[]): Promise<voi
638
651
  renderEdges(layout, rootG);
639
652
  // The fresh <g> becomes the viewport, wearing the view the reader already had (or a fit).
640
653
  viewportG = rootG;
641
- fitting ? fitToWidth() : applyView();
654
+ fitting ? fitWhole() : applyView();
642
655
  }
643
656
 
644
657
  // ---- Live runs ----------------------------------------------------------------------------------
@@ -6,7 +6,14 @@
6
6
 
7
7
  import { Fragment, h, type JSX } from "preact";
8
8
  import { useState } from "preact/hooks";
9
- import { selectedRun, selectedRunGates, type Frame, type Store } from "../store.ts";
9
+ import {
10
+ selectedRun,
11
+ selectedRunGates,
12
+ selectedRunHeld,
13
+ selectedRunWaiting,
14
+ type Frame,
15
+ type Store,
16
+ } from "../store.ts";
10
17
  import type { MachineDoc } from "../canvas.ts";
11
18
  import { Nav } from "./nav.ts";
12
19
  import { Fleet } from "./fleet.ts";
@@ -71,6 +78,8 @@ export function App(props: {
71
78
  run: selectedRun(store),
72
79
  selectedNodeId: store.selectedNodeId,
73
80
  gates: selectedRunGates(store),
81
+ waiting: selectedRunWaiting(store),
82
+ held: selectedRunHeld(store),
74
83
  api,
75
84
  }),
76
85
  h(Drawer, { store, chosenTab, onChooseTab: setChosenTab, api }),
@@ -2,14 +2,18 @@
2
2
  // master–detail shell (ADR-0032). Every row derives from store selectors (`fleetRuns`), never a
3
3
  // component-local cache; the vdom's keyed diff is what keeps a half-typed start form alive under
4
4
  // the status frames that used to demand main.js's hand-rolled `memo()`.
5
+ //
6
+ // A named Chart is a Workflow (ADR-0070): it lists under its Chart wrapper, indented, with its origin
7
+ // (`chart file` or `chart`) and, when a publish no longer admits it, `unrunnable` with the reason.
5
8
 
6
9
  import { h, type JSX } from "preact";
7
- import { fleetRuns, type ObservedRun, type Store } from "../store.ts";
10
+ import { fleetEntries, fleetRuns, type FleetEntry, type ObservedRun, type Store } from "../store.ts";
8
11
  import { SchemaForm } from "./schema-form.ts";
9
12
  import type { AppApi } from "./app.ts";
10
13
 
11
14
  export function Fleet(props: { store: Store; schemas: ReadonlyMap<string, unknown>; api: AppApi }): JSX.Element {
12
15
  const { store, schemas, api } = props;
16
+ const entries = fleetEntries(store.listing);
13
17
  return h(
14
18
  "aside",
15
19
  { id: "fleet" },
@@ -17,18 +21,38 @@ export function Fleet(props: { store: Store; schemas: ReadonlyMap<string, unknow
17
21
  h(
18
22
  "ul",
19
23
  { id: "workflow-list" },
20
- store.workflows.length
21
- ? store.workflows.map((name) => workflowItem(name, store, schemas, api))
24
+ entries.length
25
+ ? entries.map((entry) => workflowItem(entry, store, schemas, api))
22
26
  : h("li", { class: "empty" }, "no workflows registered"),
23
27
  ),
24
28
  );
25
29
  }
26
30
 
27
- function workflowItem(name: string, store: Store, schemas: ReadonlyMap<string, unknown>, api: AppApi): JSX.Element {
31
+ /** How the rail names an origin (ADR-0070); a Machine that is no Chart wrapper shows none. */
32
+ function originTag(entry: FleetEntry): string | null {
33
+ if (entry.origin === "file") return "chart file";
34
+ if (entry.origin === "runtime") return "chart";
35
+ if (entry.wrapper) return entry.wrapper.oneShot ? "chart wrapper, one-shot" : "chart wrapper";
36
+ return null;
37
+ }
38
+
39
+ function workflowItem(
40
+ entry: FleetEntry,
41
+ store: Store,
42
+ schemas: ReadonlyMap<string, unknown>,
43
+ api: AppApi,
44
+ ): JSX.Element {
45
+ const name = entry.name;
28
46
  const rows = fleetRuns(store, name);
47
+ const tag = originTag(entry);
29
48
  return h(
30
49
  "li",
31
- { key: name, class: name === store.workflow ? "wf wf--selected" : "wf" },
50
+ {
51
+ key: name,
52
+ class: `${name === store.workflow ? "wf wf--selected" : "wf"}${entry.depth ? " wf--chart" : ""}`,
53
+ // A Chart indents under its wrapper; the rail's stylesheet has no rule for depth yet.
54
+ style: entry.depth ? { marginLeft: "14px" } : undefined,
55
+ },
32
56
  h(
33
57
  "div",
34
58
  { class: "wf-head", onClick: () => void api.selectWorkflow(name, { push: true }) },
@@ -45,8 +69,17 @@ function workflowItem(name: string, store: Store, schemas: ReadonlyMap<string, u
45
69
  store.expanded.has(name) ? "▾" : "▸",
46
70
  ),
47
71
  h("span", { class: "wf-name" }, name),
72
+ tag ? h("span", { class: "wf-count wf-origin" }, tag) : null,
73
+ entry.unrunnable ? h("span", { class: "wf-count wf-unrunnable", title: entry.unrunnable }, "unrunnable") : null,
74
+ entry.shadows
75
+ ? h(
76
+ "span",
77
+ { class: "wf-count wf-unrunnable", title: entry.shadows.unrunnable ?? "a Chart of this name is shadowed" },
78
+ entry.shadows.origin === "runtime" ? "shadows a chart" : "shadows a chart file",
79
+ )
80
+ : null,
48
81
  h("span", { class: "wf-count" }, String(rows.filter((r) => !r.settled).length)),
49
- store.token === "live"
82
+ store.token === "live" && !entry.unrunnable
50
83
  ? h(
51
84
  "button",
52
85
  {
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { h, type JSX } from "preact";
7
7
  import { useEffect, useRef } from "preact/hooks";
8
- import type { GateCard, ObservedRun } from "../store.ts";
8
+ import { docSeats, type GateCard, type HeldLine, type ObservedRun, type WaitingLine } from "../store.ts";
9
9
  import {
10
10
  clearCanvas,
11
11
  initCanvas,
@@ -21,9 +21,11 @@ export function MachinePane(props: {
21
21
  run: ObservedRun | null;
22
22
  selectedNodeId: string | null;
23
23
  gates: GateCard[];
24
+ waiting: WaitingLine[];
25
+ held: HeldLine[];
24
26
  api: AppApi;
25
27
  }): JSX.Element {
26
- const { view, run, selectedNodeId, gates, api } = props;
28
+ const { view, run, selectedNodeId, gates, waiting, held, api } = props;
27
29
  const canvasRef = useRef<HTMLElement>(null);
28
30
  const svgRef = useRef<SVGSVGElement>(null);
29
31
 
@@ -57,6 +59,9 @@ export function MachinePane(props: {
57
59
  h("div", { id: "placeholder", hidden: !view.placeholder }, view.placeholder ?? ""),
58
60
  h("div", { id: "error", hidden: !view.note, class: view.note?.notice ? "notice" : "" }, view.note?.text ?? ""),
59
61
  rootTransitionStrip(view.doc),
62
+ seatsPanel(view.doc),
63
+ waitingStrip(waiting),
64
+ heldStrip(held),
60
65
  h("svg", { id: "machine-svg", ref: svgRef }),
61
66
  );
62
67
  }
@@ -83,3 +88,85 @@ function rootTransitionStrip(doc: MachineDoc | null): JSX.Element {
83
88
  }),
84
89
  );
85
90
  }
91
+
92
+ /** Who may send what, per state, and the load warnings (ADR-0015) — what `jr2 up` prints, for the
93
+ * Machine and every Machine it composes. Structure, so tokenless too. Folded by default: the
94
+ * diagram keeps the pane, and a warning shows in the summary so it is not missed folded. */
95
+ function seatsPanel(doc: MachineDoc | null): JSX.Element | null {
96
+ const { seats, warnings } = docSeats(doc);
97
+ if (!seats.length && !warnings.length) return null;
98
+ return h(
99
+ "details",
100
+ { id: "seats" },
101
+ h(
102
+ "summary",
103
+ null,
104
+ "who may send what",
105
+ h("span", { class: "dim" }, ` · ${seats.length} seat${seats.length === 1 ? "" : "s"}`),
106
+ warnings.length
107
+ ? h("span", { class: "seats-warned" }, ` · ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`)
108
+ : null,
109
+ ),
110
+ h(
111
+ "ul",
112
+ { class: "seat-list" },
113
+ seats.map((seat) =>
114
+ h(
115
+ "li",
116
+ { key: `${seat.state} ${seat.party} ${seat.agent ?? ""}` },
117
+ h("span", { class: "seat-state" }, seat.state),
118
+ h("span", { class: "dim" }, seat.party === "agent" ? ` agent:${seat.agent}` : " gate"),
119
+ h("span", { class: "seat-names" }, ` ${seat.names.length ? seat.names.join(", ") : "—"}`),
120
+ ),
121
+ ),
122
+ ),
123
+ warnings.length
124
+ ? h(
125
+ "ul",
126
+ { class: "seat-warnings" },
127
+ warnings.map((w) => h("li", { key: w }, w)),
128
+ )
129
+ : null,
130
+ );
131
+ }
132
+
133
+ /** Why the selected run's Workspaces in `placing` wait (ADR-0064), one line each, in the
134
+ * scheduler's or the quota's own words. The diagram lights `placing` for every viewer; this reason
135
+ * is guarded, so it is here only with a live token. */
136
+ function waitingStrip(waiting: WaitingLine[]): JSX.Element {
137
+ return h(
138
+ "ul",
139
+ { id: "waiting" },
140
+ waiting.map((w) =>
141
+ h(
142
+ "li",
143
+ { key: w.child },
144
+ h(
145
+ "span",
146
+ { class: "waiting-head" },
147
+ `${w.child || "Workspace"} waits for ${w.on === "quota" ? "quota" : "a node"}`,
148
+ ),
149
+ h("span", { class: "dim", title: w.since }, ` since ${new Date(w.since).toLocaleTimeString()}`),
150
+ h("div", { class: "waiting-message" }, w.message),
151
+ ),
152
+ ),
153
+ );
154
+ }
155
+
156
+ /** The selected run's outstanding picks (ADR-0024): the Agent, its pick, and since when it is held.
157
+ * A held Turn has no default time bound, so this is where a human sees one that waits too long.
158
+ * Guarded like the waiting strip: here only with a live token. */
159
+ function heldStrip(held: HeldLine[]): JSX.Element {
160
+ return h(
161
+ "ul",
162
+ { id: "held" },
163
+ held.map((line) =>
164
+ h(
165
+ "li",
166
+ { key: `${line.agent} ${line.pick}` },
167
+ h("span", { class: "held-head" }, `${line.agent || "Agent"}'s pick ${line.pick} runs`),
168
+ h("span", { class: "dim", title: line.since }, ` held since ${new Date(line.since).toLocaleTimeString()}`),
169
+ ),
170
+ ),
171
+ );
172
+ }
@@ -50,7 +50,7 @@ export function Nav(props: {
50
50
  h("button", { id: "zoom-out", title: "zoom out", onClick: () => api.zoomOut() }, "−"),
51
51
  h("button", { id: "zoom-reset", title: "reset zoom", onClick: () => api.zoomReset() }, `${zoomPct}%`),
52
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"),
53
+ h("button", { id: "zoom-fit", title: "fit the whole Machine", onClick: () => api.zoomFit() }, "fit"),
54
54
  ),
55
55
  h(
56
56
  "button",
@@ -1,15 +1,14 @@
1
1
  // The shared form (start-run + gate delivery — ADR-0033): one component for both writes the
2
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
3
+ // marked; an object or array property is a JSON textarea, so a Chart wrapper's `chart` field —
4
+ // ADR-0070 — goes as an object, not as a string), no schema becomes a raw JSON textarea. `onSubmit` resolves to an error string to render
4
5
  // inline (the server's 400 names the accepted shape — that text IS the form's error display) or null when the write
5
6
  // landed. The inputs are UNCONTROLLED: what the reader is typing is theirs, not state, and the
6
7
  // vdom's keyed diff keeps the elements — and so the half-typed text — alive across status frames.
7
8
 
8
9
  import { h, type JSX } from "preact";
9
10
  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 };
11
+ import { jsonField, type SchemaProp } from "../json-field.ts";
13
12
 
14
13
  /** The schema's `properties`, or null when there is no schema to generate from (→ raw textarea). */
15
14
  function propsOf(schema: unknown): Record<string, SchemaProp> | null {
@@ -89,12 +88,16 @@ function field(key: string, prop: SchemaProp, required: Set<unknown>): JSX.Eleme
89
88
  );
90
89
  } else if (prop.type === "number" || prop.type === "integer") {
91
90
  input = h("input", { name: key, type: "number", step: "any" });
91
+ } else if (isJsonType(prop.type)) {
92
+ input = h("textarea", { name: key, rows: 4, placeholder: `JSON ${prop.type}` });
92
93
  } else {
93
94
  input = h("input", { name: key, type: "text" });
94
95
  }
95
96
  return h("label", { key, title: prop.description }, h("span", null, required.has(key) ? `${key} *` : key), input);
96
97
  }
97
98
 
99
+ const isJsonType = (type: string | undefined): type is "object" | "array" => type === "object" || type === "array";
100
+
98
101
  /** The submit body, read back off the DOM the fields live in. Typed fields: empty optional inputs
99
102
  * are ABSENT, not "" — the server's schema is the authority on required-ness and says so in its
100
103
  * 400. The raw textarea must parse to an object. */
@@ -116,6 +119,11 @@ function collect(form: HTMLFormElement, properties: Record<string, SchemaProp> |
116
119
  const body: Record<string, unknown> = {};
117
120
  for (const [key, prop] of Object.entries(properties)) {
118
121
  const el = form.elements.namedItem(key);
122
+ if (el instanceof HTMLTextAreaElement) {
123
+ const value = jsonField(key, prop, el.value);
124
+ if (value !== undefined) body[key] = value;
125
+ continue;
126
+ }
119
127
  if (!(el instanceof HTMLInputElement || el instanceof HTMLSelectElement)) continue;
120
128
  if (el instanceof HTMLInputElement && el.type === "checkbox") {
121
129
  body[key] = el.checked; // a required boolean: always an answer, by design
@@ -0,0 +1,23 @@
1
+ // An object or array property's textarea, parsed (ADR-0033, ADR-0070): a Chart wrapper's one-shot
2
+ // door takes `chart` as an object, so the Console's start form sends JSON of the property's type,
3
+ // never a string. DOM-free, so the Node test project can read it without the browser libs.
4
+
5
+ /** One property of a flat object schema, as far as the form reads it. */
6
+ export type SchemaProp = { type?: string; enum?: unknown[]; description?: string };
7
+
8
+ /** An object or array property's textarea, parsed: absent when empty, an error that names the
9
+ * field when the text is not JSON of the property's type. */
10
+ export function jsonField(key: string, prop: SchemaProp, text: string): unknown {
11
+ const trimmed = text.trim();
12
+ if (!trimmed) return undefined;
13
+ let parsed: unknown;
14
+ try {
15
+ parsed = JSON.parse(trimmed);
16
+ } catch {
17
+ throw new Error(`${key}: not valid JSON`);
18
+ }
19
+ const isArray = Array.isArray(parsed);
20
+ const ok = prop.type === "array" ? isArray : parsed !== null && typeof parsed === "object" && !isArray;
21
+ if (!ok) throw new Error(`${key}: must be a JSON ${prop.type}`);
22
+ return parsed;
23
+ }