@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/src/charts.ts ADDED
@@ -0,0 +1,720 @@
1
+ // Named Charts (ADR-0070): a Chart under a name is a Workflow and a child of its Chart wrapper,
2
+ // named `<wrapper>/<name>`, from one of two origins. A Chart FILE is the Assembly's own:
3
+ // `workflows/<wrapper>/<name>.chart.json`, one level below the flat `workflows/*.ts` (ADR-0050),
4
+ // published by `jr2 up` and admitted by the door. A RUNTIME Chart is data a caller `PUT`s behind the
5
+ // Instance token: it lives on the Orchestrator's volume at `.jr2/charts/<wrapper>/<name>.chart.json`,
6
+ // beside the Assemblies, and survives restarts.
7
+ //
8
+ // This file holds three things: the volume layout and the Chart-file discovery; what makes a
9
+ // Machine a Chart wrapper (the two facts jr2 already reads: an Open body in its `actors`, and a
10
+ // door field that is `chartSchema`); and the registry of named Charts that the routes and each
11
+ // publish change. The registry tells the RunHost about every RUNNABLE named Chart (`registerChart`)
12
+ // and keeps the unrunnable ones itself, with the reason, so that a Chart whose wrapper the Assembly
13
+ // dropped still lists and is never deleted (ADR-0070: "it is someone else's").
14
+
15
+ import { mkdir, readdir, readFile, rm, rmdir } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import type { AnyStateMachine } from "xstate";
18
+ import { DURABLE_TMP, fsyncPath, writeDurably } from "./assembly.ts";
19
+ import { chartHash, ChartRefusal, isChartSchema, narrowedChartSchema, type AdmittedChart } from "./chart.ts";
20
+ import type { ChartText } from "./chart.ts";
21
+ import {
22
+ chartInputOf,
23
+ admitBodyChart,
24
+ chartWrapperMachineOf,
25
+ isOpenBody,
26
+ lentIn,
27
+ openBodyDeclarationOf,
28
+ type LentParts,
29
+ type OpenBodyDeclaration,
30
+ type OpenBodyLogic,
31
+ } from "./open-body.ts";
32
+ import type { RunHost, WorkflowListing } from "./run-host.ts";
33
+ import { inputSchemaOf } from "./vocabulary.ts";
34
+
35
+ /** Where a named Chart came from (ADR-0070): the Assembly's own file, or a `PUT` at run time. */
36
+ export type ChartOrigin = "file" | "runtime";
37
+
38
+ /** A Chart under a name. `unrunnable` says why it cannot start now; it is kept, never deleted. */
39
+ export type NamedChart = {
40
+ wrapper: string;
41
+ name: string;
42
+ text: ChartText;
43
+ hash: string;
44
+ origin: ChartOrigin;
45
+ unrunnable?: string;
46
+ };
47
+
48
+ /** The short name of a named Chart: lower case, digits and `-`, starting with a letter or digit,
49
+ * at most 63 characters, so its file name on the volume is always one the filesystem takes. */
50
+ export const CHART_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
51
+
52
+ /** The suffix of a Chart file, in the Assembly and on the volume. */
53
+ export const CHART_FILE = ".chart.json";
54
+
55
+ /** The runtime Charts' directory on the volume: `<dir>/.jr2/charts`, beside the Assemblies. */
56
+ export function chartsDir(dir: string): string {
57
+ return join(dir, ".jr2", "charts");
58
+ }
59
+
60
+ /** A named Chart's Workflow name: `<wrapper>/<name>`. A Machine's name holds no slash; this always does. */
61
+ export function chartName(wrapper: string, name: string): string {
62
+ return `${wrapper}/${name}`;
63
+ }
64
+
65
+ /** Split a Workflow name into its wrapper and its Chart name; undefined for a Machine's name. */
66
+ export function splitChartName(workflow: string): { wrapper: string; name: string } | undefined {
67
+ const at = workflow.indexOf("/");
68
+ if (at <= 0 || at === workflow.length - 1) return undefined;
69
+ return { wrapper: workflow.slice(0, at), name: workflow.slice(at + 1) };
70
+ }
71
+
72
+ /** Why `name` is not a Chart name, or undefined when it is one. */
73
+ export function chartNameFault(name: string): string | undefined {
74
+ return CHART_NAME.test(name)
75
+ ? undefined
76
+ : `"${name}" is not a Chart name: lower-case letters, digits and "-", starting with a letter or a digit, at most 63 characters`;
77
+ }
78
+
79
+ // --- Chart files ---------------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Every `workflows/<wrapper>/<name>.chart.json` of an Assembly root (ADR-0070), sorted. Only one
83
+ * level below the flat `workflows/*.ts`; a directory whose name starts with `_` or `.` is a helper
84
+ * directory and is not read. Any other file in a wrapper directory is ignored and named to
85
+ * `warn` (default: `console.warn`), because a directory named after its wrapper holds nothing
86
+ * else jr2 reads. A Chart file whose stem is not a Chart name is still returned: the door refuses
87
+ * it by name.
88
+ */
89
+ export async function discoverChartFiles(
90
+ root: string,
91
+ warn: (line: string) => void = (line) => console.warn(line),
92
+ ): Promise<Array<{ wrapper: string; name: string; file: string }>> {
93
+ const workflows = join(root, "workflows");
94
+ let entries: import("node:fs").Dirent[];
95
+ try {
96
+ entries = await readdir(workflows, { withFileTypes: true });
97
+ } catch (err) {
98
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
99
+ throw err;
100
+ }
101
+ const found: Array<{ wrapper: string; name: string; file: string }> = [];
102
+ // A Chart file at the flat level is the easy mistake: it is in the Assembly, and no Chart is
103
+ // registered from it. Named, as a stray file in a wrapper's directory is.
104
+ for (const e of entries) {
105
+ if (e.isFile() && e.name.endsWith(CHART_FILE)) {
106
+ warn(
107
+ `workflows/${e.name}: ignored — a Chart file sits in workflows/<wrapper>/<name>${CHART_FILE}, ` +
108
+ `below the Chart wrapper it runs under (ADR-0070)`,
109
+ );
110
+ }
111
+ }
112
+ for (const dirent of entries.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
113
+ const wrapper = dirent.name;
114
+ if (wrapper.startsWith("_") || wrapper.startsWith(".")) continue;
115
+ const dir = join(workflows, wrapper);
116
+ for (const f of (await readdir(dir)).sort()) {
117
+ if (f.endsWith(CHART_FILE) && f.length > CHART_FILE.length) {
118
+ found.push({ wrapper, name: f.slice(0, -CHART_FILE.length), file: join(dir, f) });
119
+ } else {
120
+ warn(
121
+ `workflows/${wrapper}/${f}: ignored — a wrapper's directory holds Chart files ` +
122
+ `(<name>${CHART_FILE}) and nothing else jr2 reads (ADR-0070)`,
123
+ );
124
+ }
125
+ }
126
+ }
127
+ return found;
128
+ }
129
+
130
+ /** One Chart file, read: its text, or why it could not be read as JSON. */
131
+ export type ChartFile = {
132
+ wrapper: string;
133
+ name: string;
134
+ /** The path relative to the Assembly root: `workflows/<wrapper>/<name>.chart.json`. */
135
+ path: string;
136
+ text: unknown;
137
+ /** Set when the file is not JSON; `text` is then undefined. */
138
+ error?: string;
139
+ };
140
+
141
+ /** Discover and read every Chart file of an Assembly root. A file that is not JSON is returned with
142
+ * its `error`, for the door to refuse by name. */
143
+ export async function readChartFiles(root: string, warn?: (line: string) => void): Promise<ChartFile[]> {
144
+ const out: ChartFile[] = [];
145
+ for (const { wrapper, name, file } of await discoverChartFiles(root, warn)) {
146
+ const path = `workflows/${wrapper}/${name}${CHART_FILE}`;
147
+ const raw = await readFile(file, "utf8");
148
+ try {
149
+ out.push({ wrapper, name, path, text: JSON.parse(raw) as unknown });
150
+ } catch (err) {
151
+ out.push({ wrapper, name, path, text: undefined, error: `not JSON: ${(err as Error).message}` });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ // --- Runtime Charts on the volume ----------------------------------------------------------------
158
+
159
+ /** `<dir>/.jr2/charts/<wrapper>/<name>.chart.json`. */
160
+ function runtimeChartPath(dir: string, wrapper: string, name: string): string {
161
+ return join(chartsDir(dir), wrapper, `${name}${CHART_FILE}`);
162
+ }
163
+
164
+ /**
165
+ * Every runtime Chart on the volume, sorted by name. A file that is not JSON, or whose name is not
166
+ * a Chart name, is returned `unrunnable` with the reason: it is someone else's, so it is reported
167
+ * and never deleted.
168
+ */
169
+ export async function readRuntimeCharts(dir: string): Promise<NamedChart[]> {
170
+ const root = chartsDir(dir);
171
+ let wrappers: import("node:fs").Dirent[];
172
+ try {
173
+ wrappers = await readdir(root, { withFileTypes: true });
174
+ } catch (err) {
175
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
176
+ throw err;
177
+ }
178
+ const out: NamedChart[] = [];
179
+ for (const w of wrappers.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
180
+ for (const f of (await readdir(join(root, w.name))).sort()) {
181
+ if (!f.endsWith(CHART_FILE) || f.length === CHART_FILE.length) continue; // a temp file of a write
182
+ const name = f.slice(0, -CHART_FILE.length);
183
+ const raw = await readFile(join(root, w.name, f), "utf8");
184
+ let text: ChartText;
185
+ try {
186
+ text = JSON.parse(raw) as ChartText;
187
+ } catch (err) {
188
+ out.push({
189
+ wrapper: w.name,
190
+ name,
191
+ text: {} as ChartText,
192
+ hash: "",
193
+ origin: "runtime",
194
+ unrunnable: `the file on the volume is not JSON: ${(err as Error).message}`,
195
+ });
196
+ continue;
197
+ }
198
+ let hash = "";
199
+ try {
200
+ hash = chartHash(text);
201
+ } catch {
202
+ // Not hashable: the admission that follows names the fault.
203
+ }
204
+ const fault = chartNameFault(name);
205
+ out.push({ wrapper: w.name, name, text, hash, origin: "runtime", ...(fault ? { unrunnable: fault } : {}) });
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /** Write a runtime Chart durably (temp file, fsync, rename, fsync the directory), as the
212
+ * Assemblies are written: a crash leaves the old text or the new, never a part. A directory the
213
+ * write created has its own entry made durable too, up to `.jr2`. The file holds the Chart text
214
+ * alone. */
215
+ export async function writeRuntimeChart(dir: string, chart: NamedChart): Promise<void> {
216
+ const target = runtimeChartPath(dir, chart.wrapper, chart.name);
217
+ const parent = join(chartsDir(dir), chart.wrapper);
218
+ const first = await mkdir(parent, { recursive: true });
219
+ await writeDurably(target, `${JSON.stringify(chart.text, null, 2)}\n`);
220
+ if (first !== undefined) {
221
+ // `first` is the top directory mkdir made: fsync each parent from the wrapper's up to it.
222
+ for (let at = parent; at.length >= first.length; at = join(at, "..")) await fsyncPath(join(at, ".."));
223
+ }
224
+ }
225
+
226
+ /** Remove the temp files a crashed {@link writeRuntimeChart} left (ADR-0070), as `removeStaging`
227
+ * does for the Assemblies. */
228
+ async function removeStaleTemps(dir: string): Promise<void> {
229
+ const root = chartsDir(dir);
230
+ let wrappers: import("node:fs").Dirent[];
231
+ try {
232
+ wrappers = await readdir(root, { withFileTypes: true });
233
+ } catch (err) {
234
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
235
+ throw err;
236
+ }
237
+ for (const w of wrappers.filter((e) => e.isDirectory())) {
238
+ for (const f of await readdir(join(root, w.name))) {
239
+ if (DURABLE_TMP.test(f)) await rm(join(root, w.name, f), { force: true });
240
+ }
241
+ }
242
+ }
243
+
244
+ /** Remove a runtime Chart from the volume. False when there was none. An emptied wrapper
245
+ * directory is removed too. */
246
+ export async function removeRuntimeChart(dir: string, wrapper: string, name: string): Promise<boolean> {
247
+ const target = runtimeChartPath(dir, wrapper, name);
248
+ try {
249
+ await rm(target);
250
+ } catch (err) {
251
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
252
+ throw err;
253
+ }
254
+ const parent = join(chartsDir(dir), wrapper);
255
+ await fsyncPath(parent);
256
+ await rmdir(parent).catch(() => {}); // not empty: other Charts of this wrapper stay
257
+ return true;
258
+ }
259
+
260
+ // --- Chart wrappers ------------------------------------------------------------------------------
261
+
262
+ /** One Open body of a Chart wrapper: its slot key, its declaration, the lent set it has now. */
263
+ export type OpenBodyOf = { slot: string; declaration: OpenBodyDeclaration; lent: LentParts };
264
+
265
+ /**
266
+ * What makes a Machine a Chart wrapper (ADR-0070), read from the two facts jr2 already reads: the
267
+ * Open bodies in its `actors`, and its door's fields that are `chartSchema`.
268
+ */
269
+ export type ChartWrapper = {
270
+ /** The first Open body's slot key. */
271
+ body: string;
272
+ /** Every Open body, in `actors` order. A named Chart is admitted when one of them admits it,
273
+ * the rule the start door (`admitWrapperChart`) uses for a Chart field. */
274
+ bodies: OpenBodyOf[];
275
+ /** The door's fields that are `chartSchema`: a named Chart supplies each of them. Empty for a
276
+ * wrapper for in-Machine Charts only. */
277
+ chartFields: string[];
278
+ /** The door's other fields: the Chart's `input` keys the door can see (ADR-0070 rule 7). */
279
+ inputKeys: string[];
280
+ };
281
+
282
+ /**
283
+ * The lent set an Open body has under `machine`: the Machine's own implementations by the lent
284
+ * names, read each time. Never the stamp on the slot's logic: one `openBody()` value used in two
285
+ * Machines holds one stamp, the last `createMachine`'s. A name the Machine does not implement is
286
+ * left out, so the door refuses a Chart that uses it.
287
+ */
288
+ function lentSetOf(machine: AnyStateMachine, logic: OpenBodyLogic): LentParts {
289
+ return lentIn(machine.implementations as never, openBodyDeclarationOf(logic));
290
+ }
291
+
292
+ /** The Chart wrapper a Machine is, or undefined when it declares no Open body. The bodies and the
293
+ * lent set are seen through jr2's transparent wrappers (a Chart wrapper that is a `workspace()`'s
294
+ * body), and the door is `machine`'s own. */
295
+ export function chartWrapperOf(machine: AnyStateMachine): ChartWrapper | undefined {
296
+ const inner = chartWrapperMachineOf(machine).machine;
297
+ const actors = (inner.implementations?.actors ?? {}) as Record<string, unknown>;
298
+ const bodies: OpenBodyOf[] = [];
299
+ for (const [slot, logic] of Object.entries(actors)) {
300
+ if (!isOpenBody(logic)) continue;
301
+ bodies.push({ slot, declaration: openBodyDeclarationOf(logic), lent: lentSetOf(inner, logic) });
302
+ }
303
+ if (!bodies.length) return undefined;
304
+ const shape = (inputSchemaOf(machine)?.shape ?? {}) as Record<string, unknown>;
305
+ const chartFields = Object.keys(shape).filter((k) => isChartSchema(shape[k]));
306
+ // The door's other fields, less the body's placement: a Chart reads no url (open-body.ts).
307
+ const inputKeys = Object.keys(chartInputOf(shape)).filter((k) => !chartFields.includes(k));
308
+ return { body: bodies[0]!.slot, bodies, chartFields, inputKeys };
309
+ }
310
+
311
+ /**
312
+ * Admit a named Chart's text under its wrapper (ADR-0070), with the door's other fields as the
313
+ * Chart's `input` keys. The door cannot tell which Open body a Chart field feeds, so the Chart is
314
+ * admitted when one body admits it: the rule the start door (`admitWrapperChart`) uses, so a
315
+ * named Chart and the same text sent one-shot get one verdict. With more than one body, each
316
+ * report line names the body it is about. Throws {@link ChartRefusal}.
317
+ */
318
+ export function admitUnder(wrapper: ChartWrapper, text: unknown): AdmittedChart {
319
+ const report: string[] = [];
320
+ for (const body of wrapper.bodies) {
321
+ try {
322
+ return admitBodyChart(text, body.declaration, body.lent, { inputKeys: wrapper.inputKeys });
323
+ } catch (err) {
324
+ if (!(err instanceof ChartRefusal)) throw err;
325
+ const prefix = wrapper.bodies.length > 1 ? `body "${body.slot}": ` : "";
326
+ report.push(...err.report.map((line) => `${prefix}${line}`));
327
+ }
328
+ }
329
+ throw new ChartRefusal(report);
330
+ }
331
+
332
+ /** One Machine an Assembly registers, as the registry reads it. */
333
+ export type ChartMachine = { name: string; machine: AnyStateMachine };
334
+
335
+ /**
336
+ * Why a named Chart cannot be under `wrapper` in these Workflows, or undefined when it can
337
+ * (ADR-0070): the wrapper must be a registered Machine, a Chart wrapper, and have a Chart door.
338
+ */
339
+ export function wrapperFault(
340
+ workflows: readonly ChartMachine[],
341
+ wrapper: string,
342
+ ): { status: 404 | 409; error: string } | undefined {
343
+ const found = workflows.find((w) => w.name === wrapper);
344
+ if (!found) return { status: 404, error: `no workflow "${wrapper}"` };
345
+ const w = chartWrapperOf(found.machine);
346
+ if (!w) {
347
+ return {
348
+ status: 409,
349
+ error: `workflow "${wrapper}" is no Chart wrapper: it declares no Open body (openBody() in its actors), so no Chart runs under it (ADR-0070)`,
350
+ };
351
+ }
352
+ if (!w.chartFields.length) {
353
+ return {
354
+ status: 409,
355
+ error: `workflow "${wrapper}" has no Chart door: no field of its input is chartSchema, so its Open body binds only Charts its own states supply (ADR-0070)`,
356
+ };
357
+ }
358
+ return undefined;
359
+ }
360
+
361
+ /**
362
+ * The door's refusals for an Assembly's Chart files (ADR-0070), one line per fault, each naming the
363
+ * file: a bad name, a wrapper that is no registered Machine, no Chart wrapper or no Chart door, a
364
+ * file that is not JSON, and every line of the Chart door's report.
365
+ */
366
+ export function chartFileRefusals(files: readonly ChartFile[], workflows: readonly ChartMachine[]): string[] {
367
+ const lines: string[] = [];
368
+ for (const f of files) {
369
+ const nameFault = chartNameFault(f.name);
370
+ if (nameFault) {
371
+ lines.push(`${f.path}: ${nameFault} (ADR-0070)`);
372
+ continue;
373
+ }
374
+ const fault = wrapperFault(workflows, f.wrapper);
375
+ if (fault) {
376
+ lines.push(
377
+ `${f.path}: ${fault.status === 404 ? `no workflows/${f.wrapper}.ts registers the Chart wrapper "${f.wrapper}" this Chart file is under` : fault.error}`,
378
+ );
379
+ continue;
380
+ }
381
+ if (f.error !== undefined) {
382
+ lines.push(`${f.path}: ${f.error}`);
383
+ continue;
384
+ }
385
+ const w = chartWrapperOf(workflows.find((x) => x.name === f.wrapper)!.machine)!;
386
+ try {
387
+ admitUnder(w, f.text);
388
+ } catch (err) {
389
+ if (!(err instanceof ChartRefusal)) throw err;
390
+ lines.push(...err.report.map((line) => `${f.path}: Chart refused: ${line}`));
391
+ }
392
+ }
393
+ return lines;
394
+ }
395
+
396
+ // --- The registry --------------------------------------------------------------------------------
397
+
398
+ /** The RunHost methods the registry speaks through (seam B owns them). */
399
+ export type ChartHost = Pick<RunHost, "registerChart" | "unregisterChart">;
400
+
401
+ /** What `PUT` and `DELETE` refuse with: an HTTP status and a body. */
402
+ export class NamedChartError extends Error {
403
+ readonly status: 400 | 404 | 409 | 422;
404
+ readonly body: Record<string, unknown>;
405
+ constructor(status: 400 | 404 | 409 | 422, body: Record<string, unknown>) {
406
+ super(typeof body.error === "string" ? body.error : JSON.stringify(body));
407
+ this.name = "NamedChartError";
408
+ this.status = status;
409
+ this.body = body;
410
+ }
411
+ }
412
+
413
+ /** The Chart counts the announce line names (ADR-0070). */
414
+ export type ChartCounts = { files: number; runtime: number; unrunnable: Array<{ name: string; reason: string }> };
415
+
416
+ /** The Workflows a registry binds to: one Assembly's Machines and Chart files. */
417
+ export type ChartBinding = {
418
+ workflows: readonly ChartMachine[];
419
+ files: readonly ChartFile[];
420
+ /** The Assembly's hash, stamped on each named Chart's def (ADR-0069). */
421
+ assembly?: string;
422
+ };
423
+
424
+ /**
425
+ * The named Charts of one Instance (ADR-0070). `bind` re-admits every named Chart against an
426
+ * Assembly (at boot and at each publish); `put` and `remove` are the runtime routes. Each runnable
427
+ * Chart is registered on the host as a Workflow named `<wrapper>/<name>`; an unrunnable one is kept
428
+ * here with its reason and listed, and a start of it is refused with that reason.
429
+ */
430
+ export class NamedCharts {
431
+ private readonly dir: string;
432
+ private readonly host: ChartHost;
433
+ /** The bound Assembly's Chart files, by Workflow name. */
434
+ private files = new Map<string, NamedChart>();
435
+ /** The runtime Charts on the volume, by Workflow name. */
436
+ private readonly runtime = new Map<string, NamedChart>();
437
+ /** The runtime Charts whose file on the volume is not JSON: nothing to admit, the reason stays. */
438
+ private readonly broken = new Set<string>();
439
+ /** The names registered on the host now. */
440
+ private readonly registered = new Set<string>();
441
+ private binding: ChartBinding = { workflows: [], files: [] };
442
+ /** `put` and `remove` run one at a time: each awaits the volume, and a DELETE that ran inside a
443
+ * PUT's write could answer 204 while the PUT then registers a Chart with no file. */
444
+ private queue: Promise<unknown> = Promise.resolve();
445
+ private pending = 0;
446
+
447
+ constructor(opts: { dir: string; host: ChartHost }) {
448
+ this.dir = opts.dir;
449
+ this.host = opts.host;
450
+ }
451
+
452
+ /** Read the runtime Charts on the volume (a boot). They are admitted at the next `bind`. */
453
+ async load(): Promise<void> {
454
+ await removeStaleTemps(this.dir);
455
+ for (const c of await readRuntimeCharts(this.dir)) {
456
+ const name = chartName(c.wrapper, c.name);
457
+ this.runtime.set(name, c);
458
+ if (c.hash === "" && c.unrunnable !== undefined) this.broken.add(name);
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Bind an Assembly (ADR-0070): take its Chart files in place of the last one's, and re-admit
464
+ * every named Chart against its Machines. A Chart file the door admitted admits here too; a
465
+ * runtime Chart that fails now (a lent slot or its wrapper is gone) is marked unrunnable and
466
+ * kept. A runtime Chart whose name a Chart file of this Assembly holds is unrunnable too: the
467
+ * file is the Instance's own. Returns the unrunnable ones, for the publish's answer.
468
+ */
469
+ bind(binding: ChartBinding): ChartCounts["unrunnable"] {
470
+ this.binding = binding;
471
+ this.files = new Map();
472
+ for (const f of binding.files) {
473
+ let hash = "";
474
+ try {
475
+ hash = chartHash(f.text as ChartText);
476
+ } catch {
477
+ // Not hashable: the admission below names the fault.
478
+ }
479
+ const chart: NamedChart = { wrapper: f.wrapper, name: f.name, text: f.text as ChartText, hash, origin: "file" };
480
+ this.files.set(chartName(f.wrapper, f.name), this.admit(chart));
481
+ }
482
+ for (const [name, c] of this.runtime) {
483
+ if (this.broken.has(name)) continue;
484
+ this.runtime.set(name, this.judgeRuntime(c));
485
+ }
486
+ const live = new Set<string>();
487
+ for (const [name, c] of [...this.files, ...this.runtime]) {
488
+ if (c.unrunnable !== undefined || live.has(name)) continue;
489
+ this.registerOnHost(c);
490
+ live.add(name);
491
+ }
492
+ for (const name of [...this.registered]) {
493
+ if (live.has(name)) continue;
494
+ const { wrapper, name: short } = splitChartName(name)!;
495
+ this.host.unregisterChart(wrapper, short);
496
+ this.registered.delete(name);
497
+ }
498
+ return this.unrunnable();
499
+ }
500
+
501
+ /** A runtime Chart judged against the bound Assembly: unrunnable when a Chart file of it holds
502
+ * the same name (the file is the Instance's own), else as {@link admit} judges it. */
503
+ private judgeRuntime(c: NamedChart): NamedChart {
504
+ const name = chartName(c.wrapper, c.name);
505
+ return this.files.has(name)
506
+ ? {
507
+ ...withoutReason(c),
508
+ unrunnable: `the Assembly's Chart file workflows/${c.wrapper}/${c.name}${CHART_FILE} holds this name (ADR-0070)`,
509
+ }
510
+ : this.admit(c);
511
+ }
512
+
513
+ /** The Chart admitted against the bound Machines: runnable, or unrunnable with the reason. */
514
+ private admit(c: NamedChart): NamedChart {
515
+ const chart = withoutReason(c);
516
+ const nameFault = chartNameFault(c.name);
517
+ if (nameFault) return { ...chart, unrunnable: nameFault };
518
+ const fault = wrapperFault(this.binding.workflows, c.wrapper);
519
+ if (fault) {
520
+ return {
521
+ ...chart,
522
+ unrunnable:
523
+ fault.status === 404
524
+ ? `the Chart wrapper "${c.wrapper}" is not in the current Assembly (ADR-0070)`
525
+ : fault.error,
526
+ };
527
+ }
528
+ const wrapper = this.wrapperOf(c.wrapper);
529
+ if (!wrapper) return { ...chart, unrunnable: `the Workflow "${c.wrapper}" is no Chart wrapper (ADR-0070)` };
530
+ try {
531
+ const admitted = admitUnder(wrapper, c.text);
532
+ return { ...chart, text: admitted.text, hash: admitted.hash };
533
+ } catch (err) {
534
+ if (!(err instanceof ChartRefusal)) throw err;
535
+ return { ...chart, unrunnable: `Chart refused: ${err.report.join("; ")}` };
536
+ }
537
+ }
538
+
539
+ /** Register a Chart that {@link admit} found runnable against the bound Assembly. A wrapper that
540
+ * is gone registers nothing: the caller judged the Chart against this binding, so it cannot be. */
541
+ private registerOnHost(c: NamedChart): void {
542
+ const found = this.binding.workflows.find((w) => w.name === c.wrapper);
543
+ const wrapper = found && chartWrapperOf(found.machine);
544
+ if (!found || !wrapper) return;
545
+ const name = chartName(c.wrapper, c.name);
546
+ this.host.registerChart({
547
+ name,
548
+ machine: found.machine,
549
+ // Nothing to inject (ADR-0011), as for every Workflow an Assembly registers.
550
+ provide: () => ({}),
551
+ ...(this.binding.assembly !== undefined ? { assembly: this.binding.assembly } : {}),
552
+ wrapper: { body: wrapper.body, chartFields: wrapper.chartFields },
553
+ chart: { wrapper: c.wrapper, name: c.name, text: c.text, hash: c.hash, origin: c.origin },
554
+ });
555
+ this.registered.add(name);
556
+ }
557
+
558
+ /** The named Chart under this Workflow name: the Chart file, before a runtime Chart it shadows. */
559
+ get(workflow: string): NamedChart | undefined {
560
+ return this.files.get(workflow) ?? this.runtime.get(workflow);
561
+ }
562
+
563
+ /** Every named Chart that cannot start now, with the reason, sorted by name then origin. */
564
+ unrunnable(): ChartCounts["unrunnable"] {
565
+ return [...this.files.values(), ...this.runtime.values()]
566
+ .filter((c) => c.unrunnable !== undefined)
567
+ .map((c) => ({ name: chartName(c.wrapper, c.name), reason: c.unrunnable! }))
568
+ .sort((a, b) => a.name.localeCompare(b.name));
569
+ }
570
+
571
+ /** What the announce line names. */
572
+ counts(): ChartCounts {
573
+ return { files: this.files.size, runtime: this.runtime.size, unrunnable: this.unrunnable() };
574
+ }
575
+
576
+ /**
577
+ * The host's listing with every unrunnable named Chart added (ADR-0070): inside its wrapper's
578
+ * entry when the wrapper is registered, else as an entry of its own. The host lists the runnable
579
+ * ones, since it registers them.
580
+ */
581
+ listing(base: WorkflowListing[]): WorkflowListing[] {
582
+ const out = base.map((w) => (w.wrapper ? { ...w, wrapper: { ...w.wrapper, charts: [...w.wrapper.charts] } } : w));
583
+ for (const c of [...this.files.values(), ...this.runtime.values()]) {
584
+ if (c.unrunnable === undefined) continue;
585
+ const name = chartName(c.wrapper, c.name);
586
+ const entry = out.find((w) => w.name === c.wrapper && w.wrapper);
587
+ if (entry?.wrapper) {
588
+ const charts = entry.wrapper.charts.filter((x) => !(x.name === c.name && x.origin === c.origin));
589
+ charts.push({ name: c.name, origin: c.origin, unrunnable: c.unrunnable });
590
+ entry.wrapper.charts = charts.sort((a, b) => a.name.localeCompare(b.name) || a.origin.localeCompare(b.origin));
591
+ } else if (!out.some((w) => w.name === name && w.origin === c.origin)) {
592
+ out.push({ name, origin: c.origin, unrunnable: c.unrunnable });
593
+ }
594
+ }
595
+ // A named Chart lists inside its wrapper's entry, not beside it (ADR-0070): an entry of its own
596
+ // stays only for a Chart whose wrapper the listing does not give.
597
+ const inside = new Set(
598
+ out.flatMap((w) => (w.wrapper ? w.wrapper.charts.map((c) => `${chartName(w.name, c.name)} ${c.origin}`) : [])),
599
+ );
600
+ return out.filter((w) => w.origin === "machine" || !inside.has(`${w.name} ${w.origin}`));
601
+ }
602
+
603
+ /** The Chart wrapper a bound Workflow is; undefined for a Machine with no Open body or an
604
+ * unknown name. */
605
+ wrapperOf(workflow: string): ChartWrapper | undefined {
606
+ const found = this.binding.workflows.find((w) => w.name === workflow);
607
+ return found && chartWrapperOf(found.machine);
608
+ }
609
+
610
+ /** The narrowed grammar of a Chart wrapper (`GET /workflows/:wrapper/chart-schema`); undefined
611
+ * when `wrapper` is no Chart wrapper. The first Open body's, when it declares more than one. */
612
+ schema(wrapper: string): Record<string, unknown> | undefined {
613
+ const w = this.wrapperOf(wrapper);
614
+ return w ? narrowedChartSchema(w.bodies[0]!.declaration) : undefined;
615
+ }
616
+
617
+ /**
618
+ * `PUT /workflows/:wrapper/charts/:name` (ADR-0070): admit the text under the wrapper, write it to
619
+ * the volume durably, and register it. `created` is false when it replaced a runtime Chart of the
620
+ * same name; a live run of the old text keeps its own. Throws {@link NamedChartError}.
621
+ */
622
+ put(
623
+ wrapper: string,
624
+ name: string,
625
+ text: unknown,
626
+ ): Promise<{ created: boolean; name: string; hash: string; unrunnable?: string }> {
627
+ return this.serial(() => this.putNow(wrapper, name, text));
628
+ }
629
+
630
+ private async putNow(
631
+ wrapper: string,
632
+ name: string,
633
+ text: unknown,
634
+ ): Promise<{ created: boolean; name: string; hash: string; unrunnable?: string }> {
635
+ const nameFault = chartNameFault(name);
636
+ if (nameFault) throw new NamedChartError(400, { error: nameFault });
637
+ const fault = wrapperFault(this.binding.workflows, wrapper);
638
+ if (fault) throw new NamedChartError(fault.status, { error: fault.error });
639
+ const full = chartName(wrapper, name);
640
+ if (this.files.has(full)) {
641
+ throw new NamedChartError(409, {
642
+ error: `"${full}" is a Chart file of the Assembly (workflows/${wrapper}/${name}${CHART_FILE}): change it there and \`jr2 up\` (ADR-0070)`,
643
+ });
644
+ }
645
+ const w = this.wrapperOf(wrapper)!;
646
+ let admitted: AdmittedChart;
647
+ try {
648
+ admitted = admitUnder(w, text);
649
+ } catch (err) {
650
+ if (err instanceof ChartRefusal) throw new NamedChartError(422, { error: "refused", report: err.report });
651
+ throw err;
652
+ }
653
+ const chart: NamedChart = { wrapper, name, text: admitted.text, hash: admitted.hash, origin: "runtime" };
654
+ await writeRuntimeChart(this.dir, chart);
655
+ // A publish can bind a new Assembly while the write awaits. The text is judged again, with no
656
+ // await, against the binding there is NOW, so this Chart is registered only under a wrapper
657
+ // that admits it, and is kept unrunnable with the reason when that wrapper does not.
658
+ const created = !this.runtime.has(full);
659
+ const judged = this.judgeRuntime(chart);
660
+ this.runtime.set(full, judged);
661
+ this.broken.delete(full);
662
+ if (judged.unrunnable === undefined) this.registerOnHost(judged);
663
+ else if (this.registered.delete(full)) this.host.unregisterChart(wrapper, name);
664
+ return {
665
+ created,
666
+ name: full,
667
+ hash: admitted.hash,
668
+ ...(judged.unrunnable !== undefined ? { unrunnable: judged.unrunnable } : {}),
669
+ };
670
+ }
671
+
672
+ /** Run `step` after every `put` and `remove` before it has settled; at once when none is. */
673
+ private serial<T>(step: () => Promise<T>): Promise<T> {
674
+ const run = this.pending === 0 ? step() : this.queue.then(step, step);
675
+ this.pending++;
676
+ this.queue = run.then(
677
+ () => void this.pending--,
678
+ () => void this.pending--,
679
+ );
680
+ return run;
681
+ }
682
+
683
+ /**
684
+ * `DELETE /workflows/:wrapper/charts/:name` (ADR-0070): remove a runtime Chart from the volume and
685
+ * the registry. A live run of it keeps its text. Throws {@link NamedChartError}: 409 for a Chart
686
+ * file, which is the Instance's own; 404 when there is no such runtime Chart.
687
+ */
688
+ remove(wrapper: string, name: string): Promise<void> {
689
+ return this.serial(() => this.removeNow(wrapper, name));
690
+ }
691
+
692
+ private async removeNow(wrapper: string, name: string): Promise<void> {
693
+ const full = chartName(wrapper, name);
694
+ if (this.files.has(full)) {
695
+ throw new NamedChartError(409, {
696
+ error: `a Chart file is the Instance's own: remove workflows/${wrapper}/${name}${CHART_FILE} and \`jr2 up\` (ADR-0070)`,
697
+ });
698
+ }
699
+ const held = this.runtime.has(full);
700
+ // Only a path that names a Chart reaches the volume: a wrapper is a Workflow's filename stem,
701
+ // never `..`, a separator or a NUL. Any other fault of the volume here is "no such Chart".
702
+ const onVolume = !chartNameFault(name) && !/^\.|[/\\\0]/.test(wrapper);
703
+ const removed = onVolume
704
+ ? await removeRuntimeChart(this.dir, wrapper, name).catch((err: unknown) => {
705
+ if (held) throw err; // a Chart this registry holds: its file's fault is a real one
706
+ return false;
707
+ })
708
+ : false;
709
+ if (!held && !removed) throw new NamedChartError(404, { error: `no named Chart "${full}"` });
710
+ this.runtime.delete(full);
711
+ this.broken.delete(full);
712
+ if (this.registered.delete(full)) this.host.unregisterChart(wrapper, name);
713
+ }
714
+ }
715
+
716
+ /** A named Chart without its `unrunnable` reason, to be judged again. */
717
+ function withoutReason(c: NamedChart): NamedChart {
718
+ const { unrunnable: _, ...rest } = c;
719
+ return rest;
720
+ }