@jr2/orchestrator 0.2.1 → 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 (75) 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 -5
  13. package/src/actor.ts +507 -54
  14. package/src/agent.ts +2 -2
  15. package/src/ambient.ts +2 -0
  16. package/src/assembly-checks.ts +165 -0
  17. package/src/assembly-tar.ts +494 -0
  18. package/src/assembly.ts +721 -0
  19. package/src/chart.ts +2050 -0
  20. package/src/charts.ts +720 -0
  21. package/src/config.ts +233 -22
  22. package/src/content-hash.ts +105 -0
  23. package/src/custodian.lua +313 -0
  24. package/src/custodian.ts +951 -0
  25. package/src/customize.ts +216 -25
  26. package/src/effect-checks.ts +557 -0
  27. package/src/effect.ts +905 -0
  28. package/src/effector-wire.ts +525 -0
  29. package/src/fingerprint.ts +4 -3
  30. package/src/gate.ts +8 -0
  31. package/src/git-clone.ts +65 -0
  32. package/src/git-push.ts +336 -0
  33. package/src/harness-client.ts +247 -25
  34. package/src/held-secrets.ts +643 -0
  35. package/src/holds.ts +156 -0
  36. package/src/http-effect.ts +981 -0
  37. package/src/http.ts +514 -119
  38. package/src/images.ts +58 -31
  39. package/src/index.ts +49 -4
  40. package/src/instance.ts +445 -59
  41. package/src/kube-client.ts +333 -0
  42. package/src/machine-doc.ts +88 -6
  43. package/src/model-keys.ts +92 -0
  44. package/src/mount-digest.ts +53 -0
  45. package/src/names.ts +89 -7
  46. package/src/notices.ts +88 -0
  47. package/src/open-body.ts +1444 -0
  48. package/src/open-effect.ts +144 -0
  49. package/src/open.ts +4 -4
  50. package/src/parts.ts +530 -33
  51. package/src/party.ts +468 -0
  52. package/src/pick.ts +309 -0
  53. package/src/pool.ts +1 -1
  54. package/src/registration.ts +301 -17
  55. package/src/repo-fetch.ts +61 -86
  56. package/src/repos.ts +63 -74
  57. package/src/rest.ts +133 -0
  58. package/src/run-host.ts +1841 -105
  59. package/src/sandbox-effect.ts +891 -0
  60. package/src/sandbox-effector.ts +111 -0
  61. package/src/sandbox-kube.ts +1719 -0
  62. package/src/sandbox-watch.ts +260 -0
  63. package/src/secret-placement.ts +494 -0
  64. package/src/server.ts +285 -89
  65. package/src/setup.ts +589 -70
  66. package/src/size.ts +342 -0
  67. package/src/snapshot-store.ts +64 -13
  68. package/src/stub-harness.ts +5 -0
  69. package/src/sync-client.ts +54 -0
  70. package/src/timers.ts +178 -0
  71. package/src/tokens.ts +44 -12
  72. package/src/vocabulary.ts +66 -1
  73. package/src/wire.ts +156 -2
  74. package/src/workspace.ts +529 -194
  75. package/src/sandbox-kubectl.ts +0 -1144
@@ -0,0 +1,721 @@
1
+ // The current Assembly on the Orchestrator's own volume, and the door that publishes the next one
2
+ // (ADR-0069). The Kit image holds the engine; an Instance's Workflows, its `jr2.config.ts` and
3
+ // what they import arrive as an Assembly that `jr2 up` publishes over `PUT /assembly`. This module
4
+ // owns the layout, the pointer, the publish, and which Assembly a boot loads.
5
+ //
6
+ // The layout, under the state volume (`<dir>/.jr2`, mounted at `/instance/.jr2` when deployed):
7
+ //
8
+ // assemblies/<hash>/ the tree, exactly as unpacked
9
+ // assemblies/<hash>.json { hash, kitVersion, workflows, publishedAt }, outside the tree
10
+ // assemblies/current one line: the current hash
11
+ // assemblies/.staging-* a publish in flight; removed at boot and on failure
12
+ //
13
+ // A publish is atomic. The tree lands under its hash, then one rename flips `current`. A crash
14
+ // before the flip leaves the old Assembly current; a crash after it boots the new one. The pod
15
+ // never rolls for a publish: the process reloads.
16
+ //
17
+ // The Orchestrator holds the current Assembly and every Assembly a live run holds, and lets the
18
+ // rest go by reachability (ADR-0039): `collectAssemblies` removes every Assembly no root names.
19
+ //
20
+ // There is one way in: the door. The Orchestrator never loads the folder it was started in, so a
21
+ // test, the e2e fixture and `jr2 up` all cross the same checks and write the same layout.
22
+
23
+ import { randomBytes } from "node:crypto";
24
+ import { createReadStream, createWriteStream, existsSync } from "node:fs";
25
+ import { mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
26
+ import { dirname, join } from "node:path";
27
+ import { Readable } from "node:stream";
28
+ import { pipeline } from "node:stream/promises";
29
+ import type { ReadableStream as WebReadableStream } from "node:stream/web";
30
+ import { fileURLToPath } from "node:url";
31
+ import { AssemblyTarRefusal, unpackAssembly } from "./assembly-tar.ts";
32
+ import { KIT_VERSION } from "./config.ts";
33
+ import { assemblyHash } from "./content-hash.ts";
34
+
35
+ /** The Assembly a process serves: where its tree is, its hash, and the Kit version it names. */
36
+ export type CurrentAssembly = {
37
+ /** The directory `loadConfig` and `loadWorkflows` read: `assemblies/<hash>`. */
38
+ root: string;
39
+ hash: string;
40
+ kitVersion: string;
41
+ };
42
+
43
+ /** What `<hash>.json` holds: the record of one published Assembly, written beside its tree. */
44
+ export type AssemblyRecord = { hash: string; kitVersion: string; workflows: string[]; publishedAt: string };
45
+
46
+ /** Which Assembly a boot loads, or none and why. */
47
+ export type ResolvedAssembly = {
48
+ current: CurrentAssembly | null;
49
+ /** Why there is no current Assembly when the volume says there should be one. */
50
+ error?: string;
51
+ };
52
+
53
+ /** An Assembly the process holds (ADR-0069), as `GET /assembly` lists it: the current one, and
54
+ * every one a live run holds, each with the runs that hold it. */
55
+ export type HeldAssembly = { hash: string; kitVersion: string; runs: string[] };
56
+
57
+ /** `GET /assembly`'s body. */
58
+ export type AssemblyView = {
59
+ kitVersion: string;
60
+ current: { hash: string; kitVersion: string; workflows: string[] } | null;
61
+ held: HeldAssembly[];
62
+ };
63
+
64
+ /** The Assemblies directory under an instance folder. */
65
+ export function assembliesDir(dir: string): string {
66
+ return join(dir, ".jr2", "assemblies");
67
+ }
68
+
69
+ /** The shape of an Assembly hash (`assemblyHash`'s 12 hex digits). Checked before a hash names a
70
+ * path, so a header or a pointer cannot name a directory outside `assemblies/`. */
71
+ const HASH = /^[0-9a-f]{12}$/;
72
+
73
+ /** True when `hash` has the shape of an Assembly hash. */
74
+ export function isAssemblyHash(hash: string | undefined): hash is string {
75
+ return hash !== undefined && HASH.test(hash);
76
+ }
77
+
78
+ /**
79
+ * Which Assembly a boot loads (ADR-0069): the one `assemblies/current` names, when its tree and
80
+ * record are on the volume and its Kit version is this Orchestrator's. Otherwise none, and the
81
+ * process serves with no Workflows (ADR-0048: degrade the capability, not the daemon). A pointer
82
+ * that names a missing tree, or an Assembly published for another Kit version, is that case, with
83
+ * the reason in `error`. No pointer at all is volume loss, or a Kit image before its first
84
+ * publish: no reason to give.
85
+ */
86
+ export async function resolveAssembly(dir: string): Promise<ResolvedAssembly> {
87
+ let pointed: string | undefined;
88
+ try {
89
+ pointed = await readPointer(dir);
90
+ } catch (err) {
91
+ // The same rule for the pointer itself: a `current` file that cannot be read is a fault on the
92
+ // volume, not absence, so the boot serves with no current Assembly and says why (ADR-0048).
93
+ return { current: null, error: `current cannot be read: ${err instanceof Error ? err.message : err}` };
94
+ }
95
+ if (pointed === undefined) return { current: null };
96
+ let found: Awaited<ReturnType<typeof publishedAssembly>>;
97
+ try {
98
+ found = await publishedAssembly(dir, pointed);
99
+ } catch (err) {
100
+ // A fault on the volume (EACCES, EIO, a record that does not parse) is not absence: the
101
+ // process serves with no current Assembly and says why (ADR-0048), and the pointer stays a
102
+ // root of the collection, so nothing it names is removed while it cannot be read.
103
+ return {
104
+ current: null,
105
+ error: `current names ${pointed}, which cannot be read: ${err instanceof Error ? err.message : err}`,
106
+ };
107
+ }
108
+ if ("current" in found) return { current: found.current };
109
+ return { current: null, error: found.error };
110
+ }
111
+
112
+ /** The hash `current` names, or undefined when there is no pointer. A pointer with no hash shape
113
+ * reads as a hash that is not on the volume: it is reported, never followed. */
114
+ async function readPointer(dir: string): Promise<string | undefined> {
115
+ try {
116
+ return (await readFile(join(assembliesDir(dir), "current"), "utf8")).trim();
117
+ } catch (err) {
118
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
119
+ throw err;
120
+ }
121
+ }
122
+
123
+ /** One published Assembly by hash, with its record — or why it cannot be the current one. */
124
+ async function publishedAssembly(dir: string, hash: string): Promise<{ current: CurrentAssembly } | { error: string }> {
125
+ const found = await onVolume(dir, hash);
126
+ if (found.kind === "missing") return { error: `current names ${hash}, which is not on the volume` };
127
+ if (found.kind === "no-record") {
128
+ return { error: `current names ${hash}, whose record ${hash}.json is not on the volume` };
129
+ }
130
+ // The Kit version bump of ADR-0069: `jr2 up` rolls the image first and publishes second. The new
131
+ // image boots on the old pointer, and an Assembly written against another Kit version is not one
132
+ // this process may import, so it serves with none until the publish.
133
+ if (found.record.kitVersion !== KIT_VERSION) {
134
+ return {
135
+ error:
136
+ `current names ${hash}, published for Kit version ${found.record.kitVersion}; this Orchestrator is ` +
137
+ `${KIT_VERSION} — \`jr2 up\` publishes an Assembly built against it`,
138
+ };
139
+ }
140
+ return { current: { root: found.root, hash, kitVersion: found.record.kitVersion } };
141
+ }
142
+
143
+ /**
144
+ * A held Assembly by hash (ADR-0069): the tree a live run's blob names, when its tree and record
145
+ * are on the volume and its Kit version is this Orchestrator's. Otherwise why not, worded as the
146
+ * reason a run that names it is `drifted`: the snapshot is intact, and this process can no longer
147
+ * read it. A fault while reading the volume THROWS: it is not a verdict on the Assembly, so the
148
+ * restore records it as `failed` and leaves the row live, and the row keeps its Assembly held
149
+ * (ADR-0030).
150
+ */
151
+ export async function heldAssembly(
152
+ dir: string,
153
+ hash: string,
154
+ ): Promise<{ root: string; record: AssemblyRecord } | { error: string }> {
155
+ const found = await onVolume(dir, hash);
156
+ if (found.kind === "missing") return { error: `Assembly ${hash} is not on the volume` };
157
+ if (found.kind === "no-record") {
158
+ return { error: `Assembly ${hash} is on the volume, but its record ${hash}.json is not` };
159
+ }
160
+ if (found.record.kitVersion !== KIT_VERSION) {
161
+ return {
162
+ error: `Assembly ${hash} was published for Kit version ${found.record.kitVersion}; this Orchestrator is ${KIT_VERSION}`,
163
+ };
164
+ }
165
+ return { root: found.root, record: found.record };
166
+ }
167
+
168
+ /** What the volume holds under one hash: the tree and its record, or which of them is missing.
169
+ * A hash with no hash shape names no path, so it is missing. Only absence (ENOENT, ENOTDIR) is
170
+ * missing: every other stat or read error, and a record that is present but does not parse, throws,
171
+ * so a fault on the volume is never read as an Assembly that is gone. */
172
+ async function onVolume(
173
+ dir: string,
174
+ hash: string,
175
+ ): Promise<{ kind: "found"; root: string; record: AssemblyRecord } | { kind: "missing" } | { kind: "no-record" }> {
176
+ if (!isAssemblyHash(hash)) return { kind: "missing" };
177
+ const root = join(assembliesDir(dir), hash);
178
+ try {
179
+ if (!(await stat(root)).isDirectory()) return { kind: "missing" };
180
+ } catch (err) {
181
+ if (isAbsent(err)) return { kind: "missing" };
182
+ throw err;
183
+ }
184
+ const record = await readRecord(dir, hash);
185
+ return record ? { kind: "found", root, record } : { kind: "no-record" };
186
+ }
187
+
188
+ /** True for the errors that mean "nothing is there", as opposed to "it cannot be read". */
189
+ function isAbsent(err: unknown): boolean {
190
+ const code = (err as NodeJS.ErrnoException).code;
191
+ return code === "ENOENT" || code === "ENOTDIR";
192
+ }
193
+
194
+ /** The record `<hash>.json`, or undefined when it is absent. A record that cannot be read, or does
195
+ * not parse, throws. */
196
+ async function readRecord(dir: string, hash: string): Promise<AssemblyRecord | undefined> {
197
+ const path = join(assembliesDir(dir), `${hash}.json`);
198
+ let text: string;
199
+ try {
200
+ text = await readFile(path, "utf8");
201
+ } catch (err) {
202
+ if (isAbsent(err)) return undefined;
203
+ throw err;
204
+ }
205
+ try {
206
+ return JSON.parse(text) as AssemblyRecord;
207
+ } catch (err) {
208
+ throw new Error(`the record ${hash}.json does not parse: ${err instanceof Error ? err.message : err}`);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * The Assemblies held (ADR-0069), as `GET /assembly` and the publish answer list them: the current
214
+ * one first, with the runs that hold it, then every other Assembly a live run holds, by hash.
215
+ * `holds` is the live runs by the hash they name. Only an Assembly on the volume is listed, with
216
+ * the Kit version its record names: a run that names an Assembly the volume lost is a drifted run
217
+ * at the next boot, not a hold.
218
+ */
219
+ export async function heldAssemblies(
220
+ dir: string,
221
+ current: CurrentAssembly | null,
222
+ holds: Map<string, string[]>,
223
+ ): Promise<HeldAssembly[]> {
224
+ const held: HeldAssembly[] = [];
225
+ if (current) held.push({ hash: current.hash, kitVersion: current.kitVersion, runs: holds.get(current.hash) ?? [] });
226
+ for (const hash of [...holds.keys()].sort()) {
227
+ if (hash === current?.hash) continue;
228
+ // A record this process cannot read is not listed with a guessed Kit version; the run that
229
+ // holds it is `failed` at restore, and its row keeps the Assembly from the collection.
230
+ const found = await onVolume(dir, hash).catch(() => undefined);
231
+ if (found?.kind !== "found") continue;
232
+ held.push({ hash, kitVersion: found.record.kitVersion, runs: holds.get(hash)! });
233
+ }
234
+ return held;
235
+ }
236
+
237
+ /**
238
+ * Remove every Assembly no root names (ADR-0069, by the reachability of ADR-0039), and answer the
239
+ * hashes removed, sorted. An Assembly is garbage when it is not current and no live run holds it:
240
+ * `roots` is the hashes live runs hold, and the hash `current` names is a root whatever it is, so a
241
+ * pointer this Orchestrator cannot load (a Kit bump before its publish) keeps its tree for the
242
+ * image it was published for. `.staging-*`, temp files and the pointer are never touched.
243
+ *
244
+ * The record goes first, then the tree. A crash between them leaves a tree with no record, which
245
+ * no boot can load, so the next collection removes it; a record with no tree goes the same way.
246
+ * The caller holds the door's lock: a publish renames its tree under its hash before it writes the
247
+ * record, and a collection in that window would take it.
248
+ */
249
+ export async function collectAssemblies(dir: string, roots: Set<string>): Promise<string[]> {
250
+ let names: string[];
251
+ try {
252
+ names = await readdir(assembliesDir(dir));
253
+ } catch (err) {
254
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
255
+ throw err;
256
+ }
257
+ const pointed = await readPointer(dir);
258
+ const hashes = new Set<string>();
259
+ for (const name of names) {
260
+ const hash = name.endsWith(".json") ? name.slice(0, -".json".length) : name;
261
+ if (isAssemblyHash(hash)) hashes.add(hash);
262
+ }
263
+ const collected: string[] = [];
264
+ for (const hash of [...hashes].sort()) {
265
+ if (hash === pointed || roots.has(hash)) continue;
266
+ // A failed removal is reported and skipped, never allowed to abandon the rest (ADR-0039). A
267
+ // hash whose record went and whose tree stayed is a tree with no record: the next sweep takes it.
268
+ try {
269
+ await rm(join(assembliesDir(dir), `${hash}.json`), { force: true });
270
+ await rm(join(assembliesDir(dir), hash), { recursive: true, force: true });
271
+ collected.push(hash);
272
+ } catch (err) {
273
+ console.error(`assembly collection could not remove ${hash}: ${err instanceof Error ? err.message : err}`);
274
+ }
275
+ }
276
+ if (collected.length) await fsyncDir(assembliesDir(dir));
277
+ return collected;
278
+ }
279
+
280
+ /** The temp file `writeDurably` renames over its target. A crash before the rename leaves one. */
281
+ export const DURABLE_TMP = /\.tmp-[0-9a-f]{8}$/;
282
+
283
+ /** Remove every `.staging-*` a crashed publish left, and every temp file a crashed pointer or
284
+ * record write left. A boot ignores them; this is the cleanup. */
285
+ export async function removeStaging(dir: string): Promise<void> {
286
+ let names: string[];
287
+ try {
288
+ names = await readdir(assembliesDir(dir));
289
+ } catch (err) {
290
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
291
+ throw err;
292
+ }
293
+ for (const name of names) {
294
+ if (name.startsWith(".staging-") || DURABLE_TMP.test(name)) {
295
+ await rm(join(assembliesDir(dir), name), { recursive: true, force: true });
296
+ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * What the process makes of an Assembly before it lands: the Workflow names, the walk's refusals,
302
+ * and the step that makes it current. `activate` holds the Machines and the config the import
303
+ * already loaded, so the swap after the pointer flips imports nothing and cannot half-apply.
304
+ */
305
+ export type LoadedAssembly = {
306
+ refusals: string[];
307
+ workflows: string[];
308
+ activate: (next: CurrentAssembly) => Promise<void>;
309
+ /** Fields this publish adds to its own 200 answer, read under the door's lock after `activate`
310
+ * (ADR-0070: the named Charts this Assembly left unrunnable). Per publish, never a shared slot,
311
+ * so a second publish that starts meanwhile cannot change this one's answer. */
312
+ answer?: () => Record<string, unknown>;
313
+ };
314
+
315
+ /** A publish as it reaches the door, after authentication. */
316
+ export type PublishRequest = {
317
+ /** The `jr2-kit-version` header. */
318
+ kitVersion: string | undefined;
319
+ /** The `jr2-assembly-hash` header. */
320
+ hash: string | undefined;
321
+ /** The tarball. Read only when the checks before it pass. */
322
+ body: WebReadableStream<Uint8Array> | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array> | null;
323
+ };
324
+
325
+ /** What the door answers: an HTTP status and its JSON body. */
326
+ export type PublishAnswer = { status: 200 | 400 | 409 | 422 | 500; body: Record<string, unknown> };
327
+
328
+ export type AssemblyDoorOptions = {
329
+ /** The instance folder: the Assemblies live under `<dir>/.jr2/assemblies`. */
330
+ dir: string;
331
+ /** The Assembly the process serves now. */
332
+ current: () => CurrentAssembly | null;
333
+ /** The current Assembly's own Workflow names (its record's `workflows`: the Machines and the
334
+ * Chart files), for `GET /assembly` and the publish answer. Never the runtime Charts the process
335
+ * also registers: they are not part of an Assembly (ADR-0070). */
336
+ workflows: () => string[];
337
+ /** The Assemblies held, current first (ADR-0069): what `GET /assembly` and the publish answer
338
+ * list. The instance reads them from the store's live rows and the records
339
+ * ({@link heldAssemblies}). */
340
+ holds: () => Promise<HeldAssembly[]>;
341
+ /** The hashes live runs hold (ADR-0069): the roots a collection keeps besides the current one. */
342
+ roots: () => Promise<Set<string>>;
343
+ /** Told the hashes a collection removed, when it removed any: the instance drops them from its
344
+ * import cache. */
345
+ onCollect?: (collected: string[]) => void;
346
+ /**
347
+ * Import a tree and walk it (ADR-0069): the config, every Workflow, and the refusals the
348
+ * Orchestrator can see. The tree is already at `assemblies/<hash>`, so what it imports is what
349
+ * the process keeps. A throw is an import error, which refuses the publish as a refusal line does.
350
+ */
351
+ load: (root: string) => Promise<LoadedAssembly>;
352
+ /** The package names the Kit image provides (ADR-0069). Default: {@link kitClosure}. */
353
+ kitPackages?: () => Promise<Set<string>>;
354
+ };
355
+
356
+ /** The door over one Assembly store. */
357
+ export type AssemblyDoor = {
358
+ /** `GET /assembly`. */
359
+ view: () => Promise<AssemblyView>;
360
+ /** The current Assembly, for `/healthz` and the announce line. */
361
+ current: () => CurrentAssembly | null;
362
+ /** `PUT /assembly`, after authentication: checks 2 and 3, `unpack`, then `admit`. */
363
+ publish: (req: PublishRequest) => Promise<PublishAnswer>;
364
+ /**
365
+ * Checks 4 to 6 over a staging directory that holds the unpacked `tree/`: the hash, the import
366
+ * and the walk, then the landing. `publish` calls it after the unpack. A test calls it directly on
367
+ * a copied tree, so it writes the real layout and the real pointer. The staging directory is
368
+ * removed when it returns.
369
+ */
370
+ admit: (staging: string, header: { hash: string; kitVersion: string }) => Promise<PublishAnswer>;
371
+ /**
372
+ * Remove every Assembly that is not current and that no live run holds (ADR-0069, ADR-0039),
373
+ * under the door's lock, so it never takes a tree a publish is landing. Answers the hashes
374
+ * removed. Never rejects: a failed collection is logged, and the next one tries again
375
+ * (ADR-0048: housekeeping never fails a boot, a publish or a sweep).
376
+ */
377
+ collect: () => Promise<string[]>;
378
+ };
379
+
380
+ /**
381
+ * The publish door (ADR-0069). Checks run in order, each with its own status, and the old
382
+ * Assembly stays current through every refusal:
383
+ *
384
+ * 1. authentication is the route's (401, 403);
385
+ * 2. the Kit version header is not this Orchestrator's → 409, before the body is read;
386
+ * 3. the hash is the current one → 200 `unchanged`, before the body is read;
387
+ * 4. the body is unpacked to staging and its hash recomputed; a refused entry or another hash →
388
+ * 400;
389
+ * 5. a Kit package in the tree, then the config and every Workflow imported and walked; an import
390
+ * error or a refusal → 422 with the report;
391
+ * 6. the tree is under its hash, the record is written, `current` flips, the process swaps;
392
+ * 7. every Assembly no longer held is collected → 200, naming the held and the collected.
393
+ *
394
+ * One publish at a time: a second one while the first is in flight → 409.
395
+ */
396
+ export function assemblyDoor(opts: AssemblyDoorOptions): AssemblyDoor {
397
+ const root = assembliesDir(opts.dir);
398
+ const kitPackages = opts.kitPackages ?? kitClosure;
399
+ let inFlight = false;
400
+
401
+ // The landing of a publish and a collection exclude each other. A publish renames its tree under
402
+ // its hash before it writes the record, so a collection in that window would see a tree with no
403
+ // record and take it. `inFlight` refuses a second publish; this only orders the two.
404
+ let lock: Promise<unknown> = Promise.resolve();
405
+ const exclusive = <T>(fn: () => Promise<T>): Promise<T> => {
406
+ const run = lock.then(fn);
407
+ lock = run.catch(() => {});
408
+ return run;
409
+ };
410
+
411
+ // A collection under the lock already held. Its roots are read here, after whatever landed, so a
412
+ // run started a moment ago under the previous Assembly keeps it.
413
+ const collectHeld = async (): Promise<string[]> => {
414
+ try {
415
+ const roots = await opts.roots();
416
+ const current = opts.current();
417
+ if (current) roots.add(current.hash);
418
+ const collected = await collectAssemblies(opts.dir, roots);
419
+ if (collected.length) opts.onCollect?.(collected);
420
+ return collected;
421
+ } catch (err) {
422
+ console.error(`assembly collection failed: ${message(err)}`);
423
+ return [];
424
+ }
425
+ };
426
+ const collect = () => exclusive(collectHeld);
427
+
428
+ const view = async (): Promise<AssemblyView> => {
429
+ const current = opts.current();
430
+ return {
431
+ kitVersion: KIT_VERSION,
432
+ current: current ? { hash: current.hash, kitVersion: current.kitVersion, workflows: opts.workflows() } : null,
433
+ held: await opts.holds(),
434
+ };
435
+ };
436
+
437
+ const answer = (a: CurrentAssembly) => ({ hash: a.hash, kitVersion: a.kitVersion, workflows: opts.workflows() });
438
+
439
+ // `held` in a publish answer is read after the landing. A fault in that read (the store, a
440
+ // record) never turns a publish that landed into a failed one: it is logged, and the answer
441
+ // lists the current Assembly alone, with no runs it could not read.
442
+ const heldAfterLanding = async (a: CurrentAssembly): Promise<HeldAssembly[]> => {
443
+ try {
444
+ return await opts.holds();
445
+ } catch (err) {
446
+ console.error(`the held Assemblies could not be read after the publish of ${a.hash}: ${message(err)}`);
447
+ return [{ hash: a.hash, kitVersion: a.kitVersion, runs: [] }];
448
+ }
449
+ };
450
+
451
+ const kitVersionRefusal = (kitVersion: string | undefined): PublishAnswer | undefined => {
452
+ // The exact Kit version (ADR-0056, moved to the door by ADR-0069). Not semver: the kit is 0.x
453
+ // and its minors break.
454
+ if (kitVersion === KIT_VERSION) return undefined;
455
+ return {
456
+ status: 409,
457
+ body: {
458
+ error:
459
+ `the Assembly names Kit version ${kitVersion ?? "(none)"}; this Orchestrator is ${KIT_VERSION} — ` +
460
+ "an Assembly must name the exact Kit version of the Orchestrator it is published to (ADR-0069)",
461
+ kitVersion: { assembly: kitVersion ?? null, orchestrator: KIT_VERSION },
462
+ },
463
+ };
464
+ };
465
+
466
+ const hashRefusal = (hash: string | undefined): PublishAnswer | undefined =>
467
+ isAssemblyHash(hash)
468
+ ? undefined
469
+ : {
470
+ status: 400,
471
+ body: { error: `the jr2-assembly-hash header ${JSON.stringify(hash ?? null)} is not an Assembly hash` },
472
+ };
473
+
474
+ /** Checks 4 (the hash) to 7, under the lock. */
475
+ const admitLocked = async (staging: string, hash: string): Promise<PublishAnswer> => {
476
+ const tree = join(staging, "tree");
477
+ const got = await assemblyHash(tree);
478
+ if (got !== hash) {
479
+ return {
480
+ status: 400,
481
+ body: { error: `the Assembly hashes to ${got}, not to the ${hash} its header names — nothing was published` },
482
+ };
483
+ }
484
+
485
+ // 5a. A Kit package carried in the Assembly (ADR-0054, ADR-0069). The host strips the Kit
486
+ // closure, so this is a staging bug or a hand-built archive. Refused before the import: a second
487
+ // `xstate` or `@jr2/orchestrator` would load beside the Kit image's, and its Machines would walk
488
+ // to empty Menus with no error.
489
+ const carried = [...(await kitPackages())].filter((name) => existsSync(join(tree, "node_modules", name))).sort();
490
+ if (carried.length) {
491
+ return {
492
+ status: 422,
493
+ body: {
494
+ error: "refused",
495
+ report: carried.map(
496
+ (name) =>
497
+ `the Assembly carries node_modules/${name}, which the Kit image provides — an Assembly ships no ` +
498
+ "Kit package, so its Machines resolve the Kit image's own copy (ADR-0054, ADR-0069)",
499
+ ),
500
+ },
501
+ };
502
+ }
503
+
504
+ // 6a. The tree goes under its hash BEFORE the import, so the modules the process keeps are the
505
+ // ones at the path it serves, never a second copy at a staging path. It is not current until the
506
+ // pointer flips. A tree an earlier publish left under this hash is reused only when it still
507
+ // hashes to it: a crash can leave a tree whose files never reached the disk.
508
+ const final = join(root, hash);
509
+ let created = false;
510
+ if (existsSync(final) && (await assemblyHash(final).catch(() => undefined)) !== hash) {
511
+ await rm(final, { recursive: true, force: true });
512
+ }
513
+ if (!existsSync(final)) {
514
+ await syncTree(tree);
515
+ await rename(tree, final);
516
+ await fsyncDir(root);
517
+ created = true;
518
+ }
519
+
520
+ // 5b. Import and walk. The server has no `tsc`: a type error that survives the host's
521
+ // typecheck shows here as an import error, named in the report.
522
+ let loaded: LoadedAssembly;
523
+ try {
524
+ loaded = await opts.load(final);
525
+ } catch (err) {
526
+ if (created) await rm(final, { recursive: true, force: true });
527
+ return { status: 422, body: { error: "refused", report: [message(err)] } };
528
+ }
529
+ if (loaded.refusals.length) {
530
+ if (created) await rm(final, { recursive: true, force: true });
531
+ return { status: 422, body: { error: "refused", report: loaded.refusals } };
532
+ }
533
+
534
+ // 6b. The record, then the pointer: a crash between them leaves the old pointer.
535
+ const record: AssemblyRecord = {
536
+ hash,
537
+ kitVersion: KIT_VERSION,
538
+ workflows: loaded.workflows,
539
+ publishedAt: new Date().toISOString(),
540
+ };
541
+ await writeDurably(join(root, `${hash}.json`), `${JSON.stringify(record)}\n`);
542
+ await writeDurably(join(root, "current"), `${hash}\n`);
543
+ const next: CurrentAssembly = { root: final, hash, kitVersion: KIT_VERSION };
544
+ try {
545
+ await loaded.activate(next);
546
+ } catch (err) {
547
+ // The pointer has flipped, so the next boot loads the new Assembly. Say so, rather than
548
+ // claim the old one is still current.
549
+ return {
550
+ status: 500,
551
+ body: { error: `the Assembly ${hash} is current on the volume, but the reload failed: ${message(err)}` },
552
+ };
553
+ }
554
+ // 7. The Assembly that was current may now be unheld: collected before the answer, which
555
+ // names what went.
556
+ const collected = await collectHeld();
557
+ return {
558
+ status: 200,
559
+ body: {
560
+ ...answer(next),
561
+ workflows: loaded.workflows,
562
+ ...loaded.answer?.(),
563
+ held: await heldAfterLanding(next),
564
+ collected,
565
+ },
566
+ };
567
+ };
568
+
569
+ const admit: AssemblyDoor["admit"] = async (staging, header) => {
570
+ try {
571
+ const refused = kitVersionRefusal(header.kitVersion) ?? hashRefusal(header.hash);
572
+ if (refused) return refused;
573
+ if (inFlight) return { status: 409, body: { error: "a publish is in flight" } };
574
+ inFlight = true;
575
+ try {
576
+ return await exclusive(() => admitLocked(staging, header.hash));
577
+ } finally {
578
+ inFlight = false;
579
+ }
580
+ } finally {
581
+ await rm(staging, { recursive: true, force: true });
582
+ }
583
+ };
584
+
585
+ const publish = async (req: PublishRequest): Promise<PublishAnswer> => {
586
+ // 2.
587
+ const refused = kitVersionRefusal(req.kitVersion) ?? hashRefusal(req.hash);
588
+ if (refused) return refused;
589
+ const hash = req.hash!;
590
+ // 3. A repeat publish of the current Assembly is a no-op, and costs no upload read.
591
+ const current = opts.current();
592
+ if (current?.hash === hash && existsSync(join(root, hash))) {
593
+ return {
594
+ status: 200,
595
+ body: { ...answer(current), held: await heldAfterLanding(current), collected: [], unchanged: true },
596
+ };
597
+ }
598
+ if (inFlight) return { status: 409, body: { error: "a publish is in flight" } };
599
+ inFlight = true;
600
+ const staging = join(root, `.staging-${hash}-${randomBytes(4).toString("hex")}`);
601
+ try {
602
+ // 4. Stream to staging and unpack beside the archive.
603
+ const unpacked = await unpack(req.body, staging);
604
+ if (unpacked) return unpacked;
605
+ return await exclusive(() => admitLocked(staging, hash));
606
+ } finally {
607
+ await rm(staging, { recursive: true, force: true });
608
+ inFlight = false;
609
+ }
610
+ };
611
+
612
+ return { view, current: opts.current, publish, admit, collect };
613
+ }
614
+
615
+ /**
616
+ * The body to `<staging>/assembly.tar`, unpacked beside it into `<staging>/tree`. A refused
617
+ * archive answers 400, naming the entry when there is one. Any other failure (a full volume, a
618
+ * permission) is the server's, not the publisher's: it is thrown, and the route answers 500.
619
+ */
620
+ async function unpack(body: PublishRequest["body"], staging: string): Promise<PublishAnswer | undefined> {
621
+ await mkdir(staging, { recursive: true });
622
+ const archive = join(staging, "assembly.tar");
623
+ await pipeline(bodyStream(body), createWriteStream(archive));
624
+ try {
625
+ await unpackAssembly(createReadStream(archive), join(staging, "tree"));
626
+ } catch (err) {
627
+ if (!(err instanceof AssemblyTarRefusal)) throw err;
628
+ return { status: 400, body: { error: err.message, ...(err.entry !== "" ? { entry: err.entry } : {}) } };
629
+ }
630
+ return undefined;
631
+ }
632
+
633
+ /** A request body as a Node stream. An absent body is an empty archive, which the unpack refuses. */
634
+ function bodyStream(body: PublishRequest["body"]): Readable {
635
+ if (body === null) return Readable.from([]);
636
+ if (typeof (body as ReadableStream).getReader === "function") {
637
+ return Readable.fromWeb(body as WebReadableStream<Uint8Array>);
638
+ }
639
+ return Readable.from(body as AsyncIterable<Uint8Array>);
640
+ }
641
+
642
+ /**
643
+ * The packages the Kit image provides (ADR-0069): `@jr2/orchestrator` and every package its
644
+ * `dependencies` reach, as installed beside this module. Resolved the way Node resolves a bare
645
+ * name: the nearest `node_modules/<name>` up from the dependent's own directory. A package that is
646
+ * not installed (an optional dependency) is named and not followed. Computed once per process.
647
+ */
648
+ export function kitClosure(): Promise<Set<string>> {
649
+ closure ??= computeKitClosure();
650
+ return closure;
651
+ }
652
+ let closure: Promise<Set<string>> | undefined;
653
+
654
+ async function computeKitClosure(): Promise<Set<string>> {
655
+ const names = new Set<string>(["@jr2/orchestrator"]);
656
+ const visit = async (pkgDir: string): Promise<void> => {
657
+ let manifest: { dependencies?: Record<string, string> };
658
+ try {
659
+ manifest = JSON.parse(await readFile(join(pkgDir, "package.json"), "utf8")) as typeof manifest;
660
+ } catch {
661
+ return;
662
+ }
663
+ for (const name of Object.keys(manifest.dependencies ?? {})) {
664
+ if (names.has(name)) continue;
665
+ names.add(name);
666
+ const found = await installedPackage(pkgDir, name);
667
+ if (found) await visit(found);
668
+ }
669
+ };
670
+ await visit(join(dirname(fileURLToPath(import.meta.url)), ".."));
671
+ return names;
672
+ }
673
+
674
+ /** The real directory of `name` as Node resolves it from `from`, or undefined when absent. */
675
+ async function installedPackage(from: string, name: string): Promise<string | undefined> {
676
+ for (let d = from; ; d = dirname(d)) {
677
+ const candidate = join(d, "node_modules", name);
678
+ if (existsSync(join(candidate, "package.json"))) return realpath(candidate);
679
+ if (dirname(d) === d) return undefined;
680
+ }
681
+ }
682
+
683
+ /** fsync every file and directory under `dir`, so a tree the pointer names after a power loss
684
+ * holds its bytes and not empty files. Symlinks are skipped: their target is in the inode. */
685
+ async function syncTree(dir: string): Promise<void> {
686
+ for (const e of await readdir(dir, { withFileTypes: true })) {
687
+ const p = join(dir, e.name);
688
+ if (e.isDirectory()) await syncTree(p);
689
+ else if (e.isFile()) await fsyncPath(p);
690
+ }
691
+ await fsyncPath(dir);
692
+ }
693
+
694
+ /** Write a file so that a crash leaves the old content or the new, never a part: a temp file,
695
+ * fsynced, renamed over the target, and the directory fsynced. */
696
+ export async function writeDurably(path: string, text: string): Promise<void> {
697
+ const tmp = `${path}.tmp-${randomBytes(4).toString("hex")}`;
698
+ await writeFile(tmp, text);
699
+ await fsyncPath(tmp);
700
+ await rename(tmp, path);
701
+ await fsyncDir(join(path, ".."));
702
+ }
703
+
704
+ /** Make a rename in `dir` durable. */
705
+ function fsyncDir(dir: string): Promise<void> {
706
+ return fsyncPath(dir);
707
+ }
708
+
709
+ /** fsync one file or directory. */
710
+ export async function fsyncPath(path: string): Promise<void> {
711
+ const handle = await open(path, "r");
712
+ try {
713
+ await handle.sync();
714
+ } finally {
715
+ await handle.close();
716
+ }
717
+ }
718
+
719
+ function message(err: unknown): string {
720
+ return err instanceof Error ? err.message : String(err);
721
+ }