@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.
- package/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +10 -1
- package/console/components/fleet.ts +39 -6
- package/console/components/machine-pane.ts +89 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +110 -39
- package/console/store.ts +221 -17
- package/console/style.css +90 -0
- package/package.json +4 -5
- package/src/actor.ts +507 -54
- package/src/agent.ts +2 -2
- package/src/ambient.ts +2 -0
- package/src/assembly-checks.ts +165 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/chart.ts +2050 -0
- package/src/charts.ts +720 -0
- package/src/config.ts +233 -22
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +313 -0
- package/src/custodian.ts +951 -0
- package/src/customize.ts +216 -25
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +905 -0
- package/src/effector-wire.ts +525 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +247 -25
- package/src/held-secrets.ts +643 -0
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +514 -119
- package/src/images.ts +58 -31
- package/src/index.ts +49 -4
- package/src/instance.ts +445 -59
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/model-keys.ts +92 -0
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +89 -7
- package/src/notices.ts +88 -0
- package/src/open-body.ts +1444 -0
- package/src/open-effect.ts +144 -0
- package/src/open.ts +4 -4
- package/src/parts.ts +530 -33
- package/src/party.ts +468 -0
- package/src/pick.ts +309 -0
- package/src/pool.ts +1 -1
- package/src/registration.ts +301 -17
- package/src/repo-fetch.ts +61 -86
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1841 -105
- package/src/sandbox-effect.ts +891 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1719 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +494 -0
- package/src/server.ts +285 -89
- package/src/setup.ts +589 -70
- package/src/size.ts +342 -0
- package/src/snapshot-store.ts +64 -13
- package/src/stub-harness.ts +5 -0
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +178 -0
- package/src/tokens.ts +44 -12
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- 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`
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
48
|
-
*
|
|
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 `
|
|
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
|
|
74
|
-
export type
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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`
|
|
155
|
-
|
|
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)
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
178
|
-
|
|
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`.
|
|
208
|
-
|
|
209
|
-
|
|
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
|