@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
package/src/instance.ts CHANGED
@@ -3,10 +3,19 @@
3
3
  // slice-1/2/3 pieces into one process:
4
4
  //
5
5
  // 1. open the durable snapshot store (sqlite at `<dir>/.jr2/state.db` by default — ADR-0009);
6
- // 2. filename-discover `workflows/*.ts` (workflow name = filename, mirroring flue's
6
+ // 2. filename-discover `workflows/*.ts` in the CURRENT ASSEMBLY's root (ADR-0069: the published
7
+ // tree under `<dir>/.jr2/assemblies/<hash>`; none before the first publish), each def stamped
8
+ // with that Assembly's hash (workflow name = filename, mirroring flue's
7
9
  // `agents/<name>.ts`; module contract, ADR-0011 revised by ADR-0015: `export const machine`
8
- // — the vocabulary rides the machine object via jr2Setup); register on the RunHost;
9
- // 3. `restore()` in-flight runs from the store (reconcile against the live world — ADR-0007);
10
+ // — the vocabulary rides the machine object via jr2Setup); register on the RunHost. Then the
11
+ // named Charts (ADR-0070): the Assembly's Chart files `workflows/<wrapper>/<name>.chart.json`
12
+ // and the runtime Charts on the volume (`<dir>/.jr2/charts`), each admitted against its
13
+ // wrapper and registered as the Workflow `<wrapper>/<name>`, or kept unrunnable with why;
14
+ // 3. `restore()` in-flight runs from the store (reconcile against the live world — ADR-0007),
15
+ // each inside the Assembly its snapshot names (ADR-0069). With no current Assembly the live
16
+ // rows wait, untouched, and the first publish restores them. Then sweep finished runs older
17
+ // than a week and collect every Assembly no live row holds, and again every hour (ADR-0065,
18
+ // ADR-0069);
10
19
  // 4. serve the hono HTTP surface (`createApp`) so the CLI / humans can push + control + observe.
11
20
  //
12
21
  // The one design point worth stating (ADR-0011 static-import doctrine): a workflow module is
@@ -16,25 +25,75 @@
16
25
  // remains a seam for tests, not a wiring obligation.
17
26
 
18
27
  import { mkdir, readdir, stat } from "node:fs/promises";
28
+ import type { JR2Config } from "./config.ts";
29
+ import { loadConfig } from "./config.ts";
30
+ import {
31
+ assemblyDoor,
32
+ heldAssemblies,
33
+ heldAssembly,
34
+ removeStaging,
35
+ resolveAssembly,
36
+ type AssemblyDoor,
37
+ type CurrentAssembly,
38
+ type ResolvedAssembly,
39
+ } from "./assembly.ts";
40
+ import { assemblyRefusals, type LoadedWorkflow } from "./assembly-checks.ts";
41
+ import { chartName, NamedCharts, readChartFiles, splitChartName, type ChartCounts, type ChartFile } from "./charts.ts";
42
+ import type { ImageRefs } from "./images.ts";
19
43
  import { join } from "node:path";
20
44
  import { pathToFileURL } from "node:url";
21
45
  import type { AddressInfo } from "node:net";
22
46
  import { serve } from "@hono/node-server";
23
47
  import type { AnyStateMachine } from "xstate";
24
- import { createEchoPush } from "./harness-client.ts";
48
+ import { createEchoPush, createLiveSetPush } from "./harness-client.ts";
25
49
  import { createApp } from "./http.ts";
26
50
  import type { FetchAnswer } from "./repo-fetch.ts";
27
51
  import type { RepoStatus } from "./repos.ts";
28
52
  import { RunHost } from "./run-host.ts";
29
- import type { RunRecord } from "./run-host.ts";
53
+ import type { RunRecord, WorkflowDef } from "./run-host.ts";
30
54
  import { SqliteSnapshotStore } from "./snapshot-store.ts";
31
55
  import type { SnapshotStore } from "./snapshot-store.ts";
32
- import { createAuthenticator, harnessToken, loadSigningKey, mintInstanceToken } from "./tokens.ts";
56
+ import { createAuthenticator, effectorKey, harnessToken, loadSigningKey, mintInstanceToken } from "./tokens.ts";
33
57
  import type { SandboxPort } from "./workspace.ts";
58
+ import type { EffectorPlacement, EffectorTarget } from "./http-effect.ts";
34
59
 
35
60
  export type InstanceOptions = {
36
- /** The instance folder (holds `workflows/`, and `.jr2/state.db` unless `store` is supplied). */
61
+ /** The instance folder: the state volume `.jr2/` (its `state.db` unless `store` is supplied, and
62
+ * its published Assemblies). Nothing else in it is read: the Workflows are the current
63
+ * Assembly's (ADR-0069). */
37
64
  dir: string;
65
+ /** Which Assembly to load (ADR-0069). Default: `resolveAssembly(dir)`. The entrypoint resolves
66
+ * it first, because it reads the config from the same root before it builds the ports. */
67
+ assembly?: ResolvedAssembly;
68
+ /** The publish token (ADR-0069): the one credential `PUT /assembly` accepts. Deployed, from the
69
+ * instance Secret (`JR2_PUBLISH_TOKEN`). Default: minted per boot. */
70
+ publishToken?: string;
71
+ /** The image map, read when the door checks a publish (ADR-0069). Present only when deployed;
72
+ * absent, the door does not check `file:` image contexts. */
73
+ imageMap?: () => Promise<ImageRefs>;
74
+ /** The digests of the mounted ConfigMaps, which `GET /assembly` reports (ADR-0069). Present only
75
+ * when deployed. */
76
+ mounts?: () => Promise<Record<string, string>>;
77
+ /** Called after a publish has landed and the Workflows swapped: the new current Assembly, its
78
+ * config and its Machines, which the entrypoint swaps into the ports (ADR-0069). Synchronous, so
79
+ * the swap cannot half-apply. */
80
+ onPublish?: (published: {
81
+ current: CurrentAssembly;
82
+ config: JR2Config | undefined;
83
+ workflows: LoadedWorkflow[];
84
+ }) => void;
85
+ /** Called after the restore a boot with no current Assembly deferred, which the first publish
86
+ * that lands runs (ADR-0069): the hash it landed, and what the restore did. The entrypoint
87
+ * announces the runs it did not resume. A boot with a current Assembly never calls it: its
88
+ * outcome is `RunningInstance.restored`. */
89
+ onRestore?: (outcome: { assembly: string } & RestoreOutcome) => void;
90
+ /** Called with the hashes the hourly sweep collected, when it collected any (ADR-0069): the
91
+ * entrypoint announces them as one line. The boot's collection is `RunningInstance.collected`;
92
+ * a publish's rides its answer. Default: one log line. */
93
+ onCollect?: (collected: string[]) => void;
94
+ /** How one Assembly's Workflows are imported from its root. Default: `loadWorkflows`. A test
95
+ * seam, so a test can count the imports per Assembly that the ESM cache would hide. */
96
+ importWorkflows?: (root: string) => Promise<LoadedWorkflow[]>;
38
97
  /** Listen port. Default 0 → an ephemeral port (read back from the running instance's `url`). */
39
98
  port?: number;
40
99
  /** Listen hostname. Default `127.0.0.1`. */
@@ -44,8 +103,8 @@ export type InstanceOptions = {
44
103
  /** Probe the live world before re-attaching on restore (ADR-0007). Default: always present. */
45
104
  reconcile?: (run: RunRecord) => boolean | Promise<boolean>;
46
105
  /** The Sandbox backend for `workspace()` workflows (ADR-0012). Composed by the caller — wired
47
- * when a registered Machine composes a Sandbox and the process is deployed (ADR-0051); absent =
48
- * an instance without a data plane, whose `workspace()` runs fault pointedly. */
106
+ * whenever the process is deployed (ADR-0069); absent = an instance without a data plane, whose
107
+ * `workspace()` runs fault pointedly. */
49
108
  sandbox?: SandboxPort;
50
109
  /** Whether the instance has a data plane (ADR-0051) — what `GET /repos` reports beside the
51
110
  * Repos, so `jr2 status` can say "no Workspace runs here" instead of listing nothing. */
@@ -60,9 +119,16 @@ export type InstanceOptions = {
60
119
  * The entrypoint derives it from the pod's namespace (deterministic Service DNS); absent,
61
120
  * such a Turn without an explicit `endpoint` faults pointedly. */
62
121
  instanceHarness?: string;
122
+ /** The Instance Effector's headless Service, `host:port` (ADR-0068) — where an http Effect is sent.
123
+ * The entrypoint derives it from the pod's namespace; absent, an http Effect faults pointedly. */
124
+ instanceEffector?: string;
125
+ /** A Sandbox Effector's address, `host:port`, by its Sandbox's name (ADR-0068) — where a push, an
126
+ * file-body http or a credentialed command is sent. The entrypoint derives it from the pod's namespace and
127
+ * the Service the provision writes; absent, those Effects fault pointedly. */
128
+ sandboxEffector?: (sandbox: string) => string;
63
129
  /** The key Sandbox tokens are signed with (ADR-0013). Default: `<dir>/.jr2/secret`, minted on
64
130
  * first boot. Supply it when the instance folder must stay untouched (tests), or when the same
65
- * key must reach a `kubectlSandbox` built before this call (it mints the tokens). */
131
+ * key must reach a `kubeSandbox` built before this call (it mints the tokens). */
66
132
  signingKey?: Buffer;
67
133
  /** The Instance token to authenticate with (ADR-0013/0019). Deployed, `jr2 up` materializes it in
68
134
  * the instance's Secret and the entrypoint passes it here, so a pod restart keeps the credential
@@ -70,8 +136,8 @@ export type InstanceOptions = {
70
136
  instanceToken?: string;
71
137
  };
72
138
 
73
- /** What changed on a `reload()` — the diff against the previously-registered set. */
74
- export type ReloadResult = { added: string[]; updated: string[]; removed: string[]; workflows: string[] };
139
+ /** What one restore did with the runs it found persisted (ADR-0007, ADR-0030). */
140
+ export type RestoreOutcome = { reattached: string[]; lost: string[]; drifted: string[]; failed: string[] };
75
141
 
76
142
  export type RunningInstance = {
77
143
  host: RunHost;
@@ -84,23 +150,60 @@ export type RunningInstance = {
84
150
  * Sandbox tokens must never rotate per boot either way (see `loadSigningKey`).
85
151
  */
86
152
  instanceToken: string;
87
- /** Names of the workflows discovered + registered from `<dir>/workflows`. */
153
+ /** The publish token this boot accepts on `PUT /assembly` (ADR-0069). Fixtures read it here. */
154
+ publishToken: string;
155
+ /** The Assembly this process serves now (ADR-0069); `null` while it holds none. A publish
156
+ * replaces it. */
157
+ readonly assembly: CurrentAssembly | null;
158
+ /** Why the boot found no current Assembly where the volume named one. */
159
+ assemblyError?: string;
160
+ /** Names of the workflows discovered + registered from the Assembly's `workflows/` at boot,
161
+ * the runnable named Charts (`<wrapper>/<name>`, ADR-0070) among them. */
88
162
  workflows: string[];
89
- /** What this boot did with the runs it found persisted (ADR-0007, ADR-0030) — resumed, given up
90
- * on, refused because their Machine changed shape, or errored (left for the next boot to retry).
91
- * The entrypoint announces everything but the resumed ones. */
92
- restored: { reattached: string[]; lost: string[]; drifted: string[]; failed: string[] };
163
+ /** The named Charts (ADR-0070): the Assembly's Chart files and the runtime Charts on the volume,
164
+ * and the ones that cannot run now, with why. Read when asked: a `PUT` or a publish changes it. */
165
+ readonly charts: ChartCounts;
166
+ /** The Machines of every Assembly held now (ADR-0069): the current one's, then those of each
167
+ * Assembly a live run holds that this process can import. What the Repo pass binds: a held run's
168
+ * Workspace mounts the Repos its own Machine binds, whatever the current Assembly binds. An
169
+ * Assembly that cannot be imported lends nothing: its runs are drifted or failed. */
170
+ heldMachines: () => Promise<AnyStateMachine[]>;
171
+ /** What this boot did with the runs it found persisted (ADR-0007, ADR-0030, ADR-0069) — resumed
172
+ * inside the Assembly each names, given up on, refused because their Assembly or their Machine
173
+ * can no longer be read, or errored (left for the next boot to retry). The entrypoint announces
174
+ * everything but the resumed ones. Empty when the boot had no current Assembly: those runs are
175
+ * `waiting`. */
176
+ restored: RestoreOutcome;
93
177
  /**
94
- * Re-discover `<dir>/workflows` and re-register every file, replacing changed definitions and
95
- * dropping deleted ones. A dev/test-only affordance — the deployed entrypoint ships workflows
96
- * baked into its image and never reloads. In-flight runs keep the
97
- * definition they started on; only the next `start` sees new code (ADR-0009).
178
+ * The live runs a boot with no current Assembly left untouched (ADR-0069). No config is bound
179
+ * in that window, and at a Kit bump every held Assembly is still at the old Kit version, so a
180
+ * restore now would drift runs that an image rollback could resume. The first publish that lands
181
+ * restores them, under the config it binds (`onRestore`). Empty when the boot had a current
182
+ * Assembly.
98
183
  */
99
- reload: () => Promise<ReloadResult>;
184
+ waiting: string[];
185
+ /** The Assemblies this boot collected after its restore (ADR-0069): on the volume, not current,
186
+ * and held by no live run. Sorted. */
187
+ collected: string[];
188
+ /**
189
+ * The door's checks 4 to 6 over a staging directory that holds an unpacked `tree/` (ADR-0069):
190
+ * the hash, the import and the walk, the landing, the swap. What a publish calls after the
191
+ * unpack; a test calls it on a copied tree. In-flight runs keep the definition they started on;
192
+ * only the next `start` sees the new code (ADR-0009).
193
+ */
194
+ admit: AssemblyDoor["admit"];
100
195
  /** Stop the HTTP server and close the store. */
101
196
  close: () => Promise<void>;
102
197
  };
103
198
 
199
+ /**
200
+ * How long a finished run's row lives, and how often the sweep looks (ADR-0065). Fixed, not config,
201
+ * like the Sandbox idle timeout: jr2 is not the record of a run's results, and `jr2 status` on an
202
+ * old run is a debugging act. A week is the time a human has to read it.
203
+ */
204
+ const RETAIN_MS = 7 * 24 * 60 * 60 * 1000;
205
+ const SWEEP_EVERY_MS = 60 * 60 * 1000;
206
+
104
207
  /** Boot an instance folder into a running orchestrator (discover → restore → serve). */
105
208
  export async function startInstance(opts: InstanceOptions): Promise<RunningInstance> {
106
209
  const hostname = opts.hostname ?? "127.0.0.1";
@@ -116,54 +219,284 @@ export async function startInstance(opts: InstanceOptions): Promise<RunningInsta
116
219
  // Resolved BEFORE the host, and the signing key with it: the host derives every Harness bearer
117
220
  // from the key (ADR-0058) — admission, stream, abort, echo. The key is loaded from (or minted
118
221
  // into) the instance folder, NOT generated per process: live Sandboxes outlive a restart, their
119
- // Adapters still bear tokens this key signed, and their Harnesses still check bearers it derived.
222
+ // Custodians still hold tokens this key signed, and their Harnesses still check bearers it derived.
120
223
  // The Instance token is per-boot; the key is not. Both are served under in step 4 below.
121
224
  const instanceToken = opts.instanceToken ?? mintInstanceToken();
225
+ const publishToken = opts.publishToken ?? mintInstanceToken();
122
226
  const signingKey = opts.signingKey ?? (await loadSigningKey(opts.dir));
123
227
 
228
+ // Which Assembly this boot serves (ADR-0069). A publish that crashed before its pointer flip
229
+ // left a `.staging-*`: it is not an Assembly, so it is removed and never read.
230
+ await removeStaging(opts.dir);
231
+ const resolved = opts.assembly ?? (await resolveAssembly(opts.dir));
232
+ let current = resolved.current;
233
+
234
+ // The Machines of each Assembly this process has imported, by hash (ADR-0069): a held Assembly
235
+ // is imported once, whether one run holds it or fifty, and the current one shares the entry. A
236
+ // load that fails is not kept, so a fault on the volume is tried again by the next restore. An
237
+ // Assembly the collection removes leaves the map; Node's module cache keeps its modules, which no
238
+ // run reaches any more.
239
+ const imported = new Map<string, Promise<LoadedWorkflow[]>>();
240
+ const importWorkflows = opts.importWorkflows ?? loadWorkflows;
241
+ const importAssembly = (hash: string, root: string): Promise<LoadedWorkflow[]> => {
242
+ let loading = imported.get(hash);
243
+ if (!loading) {
244
+ loading = importWorkflows(root);
245
+ imported.set(hash, loading);
246
+ loading.catch(() => imported.delete(hash));
247
+ }
248
+ return loading;
249
+ };
250
+
251
+ /**
252
+ * A run's Workflow inside the Assembly its snapshot names (ADR-0069): the tree and the record on
253
+ * the volume, at this Orchestrator's Kit version, and its `workflows/<name>.ts` imported. Every
254
+ * other answer is drift naming the hash: the snapshot is intact, and this process can no longer
255
+ * read it. The def is not registered: the registry is the current Assembly's alone.
256
+ */
257
+ const resolveDef = async (blob: {
258
+ workflow: string;
259
+ assembly: string;
260
+ }): Promise<WorkflowDef | { drifted: string }> => {
261
+ const held = await heldAssembly(opts.dir, blob.assembly);
262
+ if ("error" in held) return { drifted: `workflow "${blob.workflow}": ${held.error}` };
263
+ let loaded: LoadedWorkflow[];
264
+ try {
265
+ loaded = await importAssembly(blob.assembly, held.root);
266
+ } catch (err) {
267
+ return {
268
+ drifted: `workflow "${blob.workflow}": the Assembly ${blob.assembly} does not load: ${err instanceof Error ? err.message : err}`,
269
+ };
270
+ }
271
+ // A named Chart's run (ADR-0070, D-B) is read by its wrapper's Machine: the Chart text it ran
272
+ // is in the body's own snapshot, so the run holds it even when the name is gone.
273
+ const machineName = splitChartName(blob.workflow)?.wrapper ?? blob.workflow;
274
+ const found = loaded.find((w) => w.name === machineName);
275
+ if (!found) {
276
+ return {
277
+ drifted: `workflow "${blob.workflow}": the Assembly ${blob.assembly} does not load: it holds no workflows/${machineName}.ts`,
278
+ };
279
+ }
280
+ // `provide` injects nothing, as at register (ADR-0011).
281
+ return { name: blob.workflow, machine: found.machine, provide: () => ({}), assembly: blob.assembly };
282
+ };
283
+
124
284
  const host = new RunHost({
125
285
  store,
126
286
  reconcile: opts.reconcile,
287
+ resolveDef,
127
288
  sandbox: opts.sandbox,
128
289
  instanceHarness: opts.instanceHarness,
129
290
  harnessBearer: (placement) => harnessToken(signingKey, placement),
291
+ // The Effectors (ADR-0068): each placement's Service, and the key its wire is signed with —
292
+ // derived from the signing key as the Harness bearer is, so nothing is stored. The Instance
293
+ // Effector's key is `effectorKey(signingKey, "instance")`; a Sandbox Effector's is its Sandbox's
294
+ // name's, as the provision wrote it, and its Service is the one the provision wrote beside it.
295
+ ...(opts.instanceEffector || opts.sandboxEffector
296
+ ? { effector: (placement: EffectorPlacement) => effectorTarget(placement, opts, signingKey) }
297
+ : {}),
130
298
  // The run-narrative echo (ADR-0023): tee a run's feed to its enclosing Workspace's Harness,
131
299
  // bearing that Workspace's Harness bearer (ADR-0058) — never the Instance token, which would
132
300
  // otherwise pass through a process the Agent executes code beside. The wire push is here and
133
301
  // the fire-and-forget is the host's, so a Harness that refuses (or is gone) costs a log line.
134
302
  echo: (endpoint, bearer) => createEchoPush({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
303
+ // The live-set statement (ADR-0031): the host tells the Instance Harness which conversations
304
+ // its live runs hold, and the Harness frees the rest. Stated only when the host knows one.
305
+ liveSet: (endpoint, bearer) => createLiveSetPush({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
135
306
  // A run left `live` to be retried is otherwise unexplained — the announce line names it, this
136
307
  // says why (ADR-0030). stderr, because it is a fault, not the boot's structured result.
137
308
  onRestoreError: (runId, err) =>
138
309
  console.error(`restore failed for run ${runId}: ${err instanceof Error ? err.message : err}`),
139
310
  });
140
311
 
141
- // Bump per reload so `import()` re-reads a changed file rather than serving the ESM module cache.
142
- let importGen = 0;
143
- const registerFile = async (name: string, file: string): Promise<void> => {
144
- const machine = await importMachine(name, file, importGen);
145
- host.register({
146
- name,
147
- machine,
148
- // Nothing to inject (ADR-0011): the module imports its own actors; live clients are built
149
- // per-invocation from input. `provide` stays a test seam on WorkflowDef, unused here.
150
- provide: () => ({}),
151
- });
312
+ // The named Charts (ADR-0070). The runtime ones are read from the volume now, and admitted when
313
+ // the first Assembly binds; with none current, every one waits unrunnable ("not in the current
314
+ // Assembly") and is never deleted.
315
+ const charts = new NamedCharts({ dir: opts.dir, host });
316
+ await charts.load();
317
+ // The current Assembly's own Workflow names (ADR-0069, ADR-0070): its Machines and its Chart
318
+ // files, as its record lists them. `GET /assembly` answers these; the runtime Charts the host
319
+ // also registers are not part of an Assembly.
320
+ let currentWorkflows: string[] = [];
321
+
322
+ /** Swap the registry to `loaded` and make `next` current, in one synchronous step: nothing in it
323
+ * awaits, so no request sees half of one Assembly and half of another (ADR-0069). The named
324
+ * Charts are re-admitted against the new Machines in the same step (ADR-0070), so a Chart never
325
+ * starts under a wrapper it was not admitted against. Answers the named Charts that cannot run. */
326
+ const swap = (
327
+ loaded: LoadedWorkflow[],
328
+ files: readonly ChartFile[],
329
+ next: CurrentAssembly,
330
+ ): ChartCounts["unrunnable"] => {
331
+ const names = new Set(loaded.map((w) => w.name));
332
+ for (const { name, machine } of loaded) {
333
+ host.register({
334
+ name,
335
+ machine,
336
+ // Nothing to inject (ADR-0011): the module imports its own actors; live clients are built
337
+ // per-invocation from input. `provide` stays a test seam on WorkflowDef, unused here.
338
+ provide: () => ({}),
339
+ // The Assembly the file came from (ADR-0069): every save of a run started from it names it.
340
+ assembly: next.hash,
341
+ });
342
+ }
343
+ // A Machine's name holds no slash (ADR-0070): the named Charts are the registry's to drop.
344
+ for (const name of host.workflows()) if (!splitChartName(name) && !names.has(name)) host.unregister(name);
345
+ const unrunnable = charts.bind({ workflows: loaded, files, assembly: next.hash });
346
+ current = next;
347
+ currentWorkflows = [...loaded.map((w) => w.name), ...files.map((f) => chartName(f.wrapper, f.name))];
348
+ return unrunnable;
152
349
  };
153
350
 
154
- // 2. Filename discovery: every `workflows/<name>.ts` exports its Machine + event manifest by name.
155
- for (const { name, file } of await discoverWorkflows(opts.dir)) await registerFile(name, file);
351
+ // 2. Filename discovery: every `workflows/<name>.ts` of the current Assembly exports its Machine
352
+ // by name. No current Assembly → no Workflows, and the process still serves (ADR-0048).
353
+ if (current) {
354
+ const booted = current;
355
+ swap(await importAssembly(booted.hash, booted.root), await readChartFiles(booted.root), booted);
356
+ } else {
357
+ charts.bind({ workflows: [], files: [] });
358
+ }
359
+
360
+ // What holds an Assembly (ADR-0069): the live ROWS, not this process's runs, so a run this boot
361
+ // failed to restore still holds its own; and the runs this process hosts, for a run started a
362
+ // moment ago that has no row yet. A terminal row holds nothing.
363
+ const holdsByHash = async (): Promise<Map<string, string[]>> => {
364
+ const holds = await store.liveAssemblies();
365
+ for (const [hash, runs] of host.assemblyHolds()) {
366
+ const known = holds.get(hash) ?? [];
367
+ holds.set(hash, [...known, ...runs.filter((r) => !known.includes(r))]);
368
+ }
369
+ return holds;
370
+ };
371
+
372
+ // Whether the boot deferred its restore to the first publish (step 3 below says why). The host
373
+ // keeps the flag, so `/agents/*` answers 503 for a waiting run, not 404 (ADR-0026).
374
+ if (current === null) host.deferRestore();
375
+
376
+ // The publish door (ADR-0069). Its walk imports the tree at `assemblies/<hash>`, where it stays:
377
+ // the config, every Workflow, then the refusals this process can see. The swap reuses what the
378
+ // walk imported, so nothing is imported after the pointer flips.
379
+ const assemblies = assemblyDoor({
380
+ dir: opts.dir,
381
+ current: () => current,
382
+ workflows: () => currentWorkflows,
383
+ holds: async () => heldAssemblies(opts.dir, current, await holdsByHash()),
384
+ roots: async () => new Set((await holdsByHash()).keys()),
385
+ onCollect: (collected) => {
386
+ for (const hash of collected) imported.delete(hash);
387
+ },
388
+ load: async (root) => {
389
+ const config = await loadConfig(root);
390
+ const workflows = await importWorkflows(root);
391
+ // The Assembly's Chart files (ADR-0070): the door admits each against its wrapper, and one
392
+ // that fails refuses the publish naming the file.
393
+ const files = await readChartFiles(root);
394
+ const refusals = await assemblyRefusals({
395
+ config,
396
+ workflows,
397
+ charts: files,
398
+ ...(opts.imageMap ? { images: opts.imageMap } : {}),
399
+ root,
400
+ });
401
+ // What THIS publish left unrunnable, for its own answer: a slot per `load`, so no other
402
+ // publish can change it (ADR-0070).
403
+ let unrunnable: ChartCounts["unrunnable"] = [];
404
+ return {
405
+ refusals,
406
+ workflows: [...workflows.map((w) => w.name), ...files.map((f) => chartName(f.wrapper, f.name))],
407
+ answer: () => (unrunnable.length ? { charts: { unrunnable } } : {}),
408
+ activate: async (next) => {
409
+ // Every named Chart is re-admitted against the new Assembly (ADR-0070): a runtime Chart
410
+ // that fails is marked unrunnable, kept on the volume, and named in the publish's answer.
411
+ unrunnable = swap(workflows, files, next);
412
+ // The Machines the walk imported are this hash's, so a run restored inside it later
413
+ // (the next boot is a new process; this is for the same one) needs no second import.
414
+ imported.set(next.hash, Promise.resolve(workflows));
415
+ opts.onPublish?.({ current: next, config, workflows });
416
+ // The restore a boot with no current Assembly deferred (ADR-0069). After the swap and
417
+ // `onPublish`, so the config that applies is bound: a re-attached run provisions under
418
+ // it. Each run restores inside the Assembly its blob names, as at boot; one whose
419
+ // Assembly was published for the old Kit version drifts now, and that is final. The
420
+ // door's collection follows in the same publish, so it removes the Assemblies only those
421
+ // drifted rows named.
422
+ if (!host.restorePending) return;
423
+ try {
424
+ const outcome = await host.restore();
425
+ opts.onRestore?.({ assembly: next.hash, ...outcome });
426
+ } catch (err) {
427
+ // Left pending: the next publish, or the next boot, tries again. The publish itself
428
+ // landed, so it is not refused for this.
429
+ console.error(`restore after publish failed: ${err instanceof Error ? err.message : err}`);
430
+ }
431
+ },
432
+ };
433
+ },
434
+ });
435
+
436
+ // The publish's answer names the named Charts the new Assembly left unrunnable (ADR-0070): each
437
+ // `load` above adds them to its own answer, under the door's lock.
438
+ const door: AssemblyDoor = assemblies;
156
439
 
157
- // 3. Resume in-flight runs persisted by a prior process (ADR-0007). The outcome is reported, not
158
- // swallowed (ADR-0030): a boot that declined to resume runs must say so, or "drifted" is only
159
- // discoverable by asking after a specific run id nobody knows to ask about.
160
- const restored = await host.restore();
440
+ // 3. Resume in-flight runs persisted by a prior process (ADR-0007), each inside the Assembly its
441
+ // snapshot names (ADR-0069). A run whose Assembly this Orchestrator cannot load (a volume that
442
+ // lost it, another Kit version) is `drifted` with its hash named, and its snapshot is kept. The
443
+ // outcome is reported, not swallowed (ADR-0030): a boot that declined to resume runs must say
444
+ // so, or "drifted" is only discoverable by asking after a specific run id nobody knows to ask
445
+ // about.
446
+ //
447
+ // Only when there is a current Assembly. With none (no pointer, a pointer this process cannot
448
+ // load, a pointer at another Kit version), no config is bound, so a re-attached run that reaches
449
+ // a provision would fault, which is terminal. And in the Kit bump window ("rolls the image first,
450
+ // publishes second") every held Assembly is at the old Kit version: a restore now would drift
451
+ // every live run before the publish, and an image rollback could not resume them. So the rows
452
+ // wait, untouched, and the first publish restores them (`activate` above).
453
+ const restored: RestoreOutcome = current
454
+ ? await host.restore()
455
+ : { reattached: [], lost: [], drifted: [], failed: [] };
456
+ const waiting = current ? [] : (await store.live()).map((row) => row.runId);
457
+
458
+ // Right after restore, not before: restore reads live rows only, and a row it marks `lost` or
459
+ // `drifted` here is written now, so its week starts now (ADR-0065). After the rows, the
460
+ // Assemblies: restore decided which rows stay live, and so which Assemblies stay held (ADR-0069).
461
+ // A sweep deletes finished rows only, so it never takes a waiting run; and with the restore
462
+ // deferred, every live row is a root, and so is the pointer's target, so the boot collection
463
+ // removes only the Assemblies nothing names.
464
+ // A sweep is housekeeping, never a reason to stay up — nor to fail a boot that has already
465
+ // resumed runs: a failed sweep or collection, at boot or on the hour, is logged, and the next
466
+ // hour tries again. The timer holds no process alive.
467
+ const sweep = async (): Promise<string[]> => {
468
+ await store
469
+ .sweep(new Date(Date.now() - RETAIN_MS))
470
+ .catch((err) => console.error(`sweep failed: ${err instanceof Error ? err.message : err}`));
471
+ return door.collect();
472
+ };
473
+ const collected = await sweep();
474
+ const onCollect =
475
+ opts.onCollect ??
476
+ ((hashes: string[]) => console.log(`collected Assemblies no live run holds: ${hashes.join(", ")}`));
477
+ const sweeper = setInterval(() => {
478
+ // `onCollect` is a callback the caller injects (the server's announce): a throw from it is
479
+ // logged, never an unhandled rejection that ends the process.
480
+ void sweep()
481
+ .then((hashes) => {
482
+ if (hashes.length) onCollect(hashes);
483
+ })
484
+ .catch((err) => console.error(`collection announce failed: ${err instanceof Error ? err.message : err}`));
485
+ }, SWEEP_EVERY_MS);
486
+ sweeper.unref();
161
487
 
162
- // 4. Serve, authenticated (ADR-0013) — the token and key resolved above.
163
- const auth = createAuthenticator({ instanceToken, signingKey });
488
+ // 4. Serve, authenticated (ADR-0013) — the tokens and key resolved above.
489
+ const auth = createAuthenticator({ instanceToken, signingKey, publishToken });
164
490
  // The Repos are read PER REQUEST, never snapshotted here: a cache the agent cloned minutes
165
491
  // after boot must show as present the next time anyone asks (ADR-0048/0051).
166
- const app = createApp(host, auth, { dataPlane: opts.dataPlane, repos: opts.repos, fetchRepo: opts.fetchRepo });
492
+ const app = createApp(host, auth, {
493
+ charts,
494
+ dataPlane: opts.dataPlane,
495
+ repos: opts.repos,
496
+ fetchRepo: opts.fetchRepo,
497
+ assembly: door,
498
+ ...(opts.mounts ? { mounts: opts.mounts } : {}),
499
+ });
167
500
  const server = serve({ fetch: app.fetch, port: opts.port ?? 0, hostname });
168
501
  const port = await new Promise<number>((resolve) => {
169
502
  server.once("listening", () => resolve((server.address() as AddressInfo).port));
@@ -173,23 +506,40 @@ export async function startInstance(opts: InstanceOptions): Promise<RunningInsta
173
506
  host,
174
507
  url: `http://${hostname}:${port}`,
175
508
  instanceToken,
509
+ publishToken,
510
+ get assembly() {
511
+ return current;
512
+ },
513
+ ...(resolved.error !== undefined ? { assemblyError: resolved.error } : {}),
176
514
  workflows: host.workflows(),
177
- restored,
178
- reload: async () => {
179
- importGen++;
180
- const before = new Set(host.workflows());
181
- const found = await discoverWorkflows(opts.dir);
182
- const foundNames = new Set(found.map((f) => f.name));
183
- for (const { name, file } of found) await registerFile(name, file);
184
- const removed: string[] = [];
185
- for (const name of before) if (!foundNames.has(name)) (host.unregister(name), removed.push(name));
186
- const added = [...foundNames].filter((n) => !before.has(n));
187
- const updated = [...foundNames].filter((n) => before.has(n));
188
- return { added, updated, removed, workflows: host.workflows() };
515
+ get charts() {
516
+ return charts.counts();
189
517
  },
518
+ heldMachines: async () => {
519
+ const machines: AnyStateMachine[] = [];
520
+ const now = current;
521
+ const hashes = [...(now ? [now.hash] : []), ...(await holdsByHash()).keys()];
522
+ for (const hash of new Set(hashes)) {
523
+ try {
524
+ const held = now && hash === now.hash ? { root: now.root } : await heldAssembly(opts.dir, hash);
525
+ if ("error" in held) continue;
526
+ machines.push(...(await importAssembly(hash, held.root)).map((w) => w.machine));
527
+ } catch {
528
+ // A fault on the volume or in the import: the restore reported it for the run.
529
+ }
530
+ }
531
+ return machines;
532
+ },
533
+ restored,
534
+ waiting,
535
+ collected,
536
+ admit: door.admit,
190
537
  close: async () => {
191
538
  // Before `server.close()`, not after: it waits for in-flight requests, and an observation
192
539
  // feed is in-flight until its watcher goes away. `host.close()` is what makes them go away.
540
+ // From that call on, `/agents/*` answers 503 until the listener closes: the Turns are parked
541
+ // for the next boot's restore, not over, so no Harness may read a 404 here (ADR-0026).
542
+ clearInterval(sweeper);
193
543
  await host.close();
194
544
  await new Promise<void>((resolve) => server.close(() => resolve()));
195
545
  await store.close();
@@ -204,10 +554,10 @@ async function discoverWorkflows(dir: string): Promise<Array<{ name: string; fil
204
554
  }
205
555
 
206
556
  /** Import one workflow module and take its Machine — the module contract (ADR-0011/0015):
207
- * `export const machine`. `gen` cache-busts a reload; 0 imports the module URL untouched. */
208
- async function importMachine(name: string, file: string, gen = 0): Promise<AnyStateMachine> {
209
- const href = pathToFileURL(file).href + (gen ? `?v=${gen}` : "");
210
- const mod: { machine?: unknown } = await import(href);
557
+ * `export const machine`. A published Assembly is a new directory, so its files are new URLs:
558
+ * the ESM module cache never serves an older Assembly's module, and needs no bust (ADR-0069). */
559
+ async function importMachine(name: string, file: string): Promise<AnyStateMachine> {
560
+ const mod: { machine?: unknown } = await import(pathToFileURL(file).href);
211
561
  const machine = mod.machine as AnyStateMachine | undefined;
212
562
  if (!machine) {
213
563
  throw new Error(
@@ -218,6 +568,31 @@ async function importMachine(name: string, file: string, gen = 0): Promise<AnySt
218
568
  return machine;
219
569
  }
220
570
 
571
+ /**
572
+ * Where an Effector placement is, and its key (ADR-0068): the Instance Effector's Service and
573
+ * `effectorKey(signingKey, "instance")`; a Sandbox Effector's Service by its Sandbox's name and
574
+ * `effectorKey(signingKey, <name>)`, the key the provision wrote into its pod. Undefined where the
575
+ * entrypoint named no address for that placement.
576
+ */
577
+ export function effectorTarget(
578
+ placement: EffectorPlacement,
579
+ opts: Pick<InstanceOptions, "instanceEffector" | "sandboxEffector">,
580
+ signingKey: Buffer,
581
+ ): EffectorTarget | undefined {
582
+ if (placement === "instance") {
583
+ return opts.instanceEffector
584
+ ? { placement, service: opts.instanceEffector, key: effectorKey(signingKey, placement) }
585
+ : undefined;
586
+ }
587
+ return opts.sandboxEffector
588
+ ? {
589
+ placement: "sandbox",
590
+ service: opts.sandboxEffector(placement.sandbox),
591
+ key: effectorKey(signingKey, placement.sandbox),
592
+ }
593
+ : undefined;
594
+ }
595
+
221
596
  /**
222
597
  * Discover + load every Workflow an instance folder registers — the same discovery and the same
223
598
  * module contract `startInstance` boots with, without booting.
@@ -235,6 +610,17 @@ export async function loadWorkflows(dir: string): Promise<Array<{ name: string;
235
610
  return loaded;
236
611
  }
237
612
 
613
+ /**
614
+ * Discover + read every Chart file an Assembly root holds (ADR-0070):
615
+ * `workflows/<wrapper>/<name>.chart.json`, one level below the flat `workflows/*.ts`. The sibling
616
+ * of {@link loadWorkflows}, for `jr2 up`'s host-side walk, which admits each file against its
617
+ * wrapper with `chartFileRefusals`, the check the door's `assemblyRefusals({ charts })` runs. Any other file in a
618
+ * wrapper's directory is ignored and named to `warn`.
619
+ */
620
+ export function loadCharts(root: string, warn?: (line: string) => void): Promise<ChartFile[]> {
621
+ return readChartFiles(root, warn);
622
+ }
623
+
238
624
  /**
239
625
  * The instance module-discovery convention: every `<moduleDir>/<name>.ts` except `_`-prefixed
240
626
  * helpers and `.d.ts`, sorted, name = filename stem. `workflows/` is the one directory that uses