@jr2/orchestrator 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -4
  13. package/src/actor.ts +502 -49
  14. package/src/ambient.ts +2 -0
  15. package/src/assembly-checks.ts +165 -0
  16. package/src/assembly-tar.ts +494 -0
  17. package/src/assembly.ts +721 -0
  18. package/src/chart.ts +2050 -0
  19. package/src/charts.ts +720 -0
  20. package/src/config.ts +152 -46
  21. package/src/content-hash.ts +105 -0
  22. package/src/custodian.lua +6 -5
  23. package/src/custodian.ts +71 -19
  24. package/src/customize.ts +216 -25
  25. package/src/effect-checks.ts +557 -0
  26. package/src/effect.ts +905 -0
  27. package/src/effector-wire.ts +525 -0
  28. package/src/fingerprint.ts +4 -3
  29. package/src/gate.ts +8 -0
  30. package/src/git-clone.ts +65 -0
  31. package/src/git-push.ts +336 -0
  32. package/src/harness-client.ts +245 -23
  33. package/src/held-secrets.ts +82 -26
  34. package/src/holds.ts +156 -0
  35. package/src/http-effect.ts +981 -0
  36. package/src/http.ts +496 -104
  37. package/src/images.ts +47 -21
  38. package/src/index.ts +43 -2
  39. package/src/instance.ts +444 -58
  40. package/src/kube-client.ts +333 -0
  41. package/src/machine-doc.ts +88 -6
  42. package/src/mount-digest.ts +53 -0
  43. package/src/names.ts +66 -8
  44. package/src/notices.ts +88 -0
  45. package/src/open-body.ts +1444 -0
  46. package/src/open-effect.ts +144 -0
  47. package/src/open.ts +4 -4
  48. package/src/parts.ts +530 -33
  49. package/src/party.ts +468 -0
  50. package/src/pick.ts +309 -0
  51. package/src/pool.ts +1 -1
  52. package/src/registration.ts +301 -17
  53. package/src/repo-fetch.ts +60 -85
  54. package/src/repos.ts +63 -74
  55. package/src/rest.ts +133 -0
  56. package/src/run-host.ts +1832 -98
  57. package/src/sandbox-effect.ts +891 -0
  58. package/src/sandbox-effector.ts +111 -0
  59. package/src/sandbox-kube.ts +1719 -0
  60. package/src/sandbox-watch.ts +260 -0
  61. package/src/secret-placement.ts +494 -0
  62. package/src/server.ts +281 -84
  63. package/src/setup.ts +589 -70
  64. package/src/size.ts +342 -0
  65. package/src/snapshot-store.ts +64 -13
  66. package/src/stub-harness.ts +5 -0
  67. package/src/sync-client.ts +54 -0
  68. package/src/timers.ts +178 -0
  69. package/src/tokens.ts +35 -4
  70. package/src/vocabulary.ts +66 -1
  71. package/src/wire.ts +156 -2
  72. package/src/workspace.ts +529 -194
  73. package/src/sandbox-kubectl.ts +0 -1108
package/src/server.ts CHANGED
@@ -1,65 +1,93 @@
1
- // The deployed orchestrator's entrypoint (ADR-0019): the process the instance image runs — and, by
2
- // the same token, the e2e tier's per-scenario fixture (ADR-0010 boots exactly this process on the
3
- // host). Configuration is env, kube-style; the instance folder is the process cwd (the image bakes
4
- // engine + `workflows/` there, ADR-0008). No dev.json, no hot-reload, no stub Harness: those were
5
- // host-dev affordances, and there is no host dev mode.
1
+ // The deployed orchestrator's entrypoint (ADR-0019): the process the Orchestrator Kit image runs
2
+ // — and, by the same token, the e2e tier's per-scenario fixture (ADR-0010 boots exactly this
3
+ // process on the host). Configuration is env, kube-style; the instance folder is the process cwd.
4
+ // The Kit image holds the engine there and nothing of any Instance (ADR-0069): the Workflows and
5
+ // `jr2.config.ts` are the CURRENT ASSEMBLY's, which `jr2 up` publishes onto the state volume
6
+ // (assembly.ts). The folder the process starts in is never loaded: a host-booted fixture publishes
7
+ // through the same door. No dev.json, no stub Harness: those were host-dev affordances, and there
8
+ // is no host dev mode.
6
9
  //
7
10
  // PORT listen port (default 4000 — what the Service targets)
8
11
  // HOST listen hostname (default 0.0.0.0: pods must be reachable off-loopback)
9
12
  // JR2_INSTANCE_TOKEN the Instance credential (ADR-0013), from the instance's Secret; minted
10
13
  // per boot when absent (then only the announce line knows it — fixtures set it)
14
+ // JR2_PUBLISH_TOKEN the publish credential (ADR-0069): `PUT /assembly` alone accepts it. From
15
+ // the same Secret; minted per boot when absent, so no host-booted process has
16
+ // a publish door anyone outside it can open.
11
17
  // JR2_SIGNING_KEY base64 key Sandbox tokens are signed with; from the Secret so live Sandboxes
12
18
  // survive a pod restart. Absent → minted into `<dir>/.jr2/secret` (dev-grade).
13
19
  // JR2_NAMESPACE the pod's own namespace (Deployment fieldRef) — presence = "deployed":
14
20
  // Sandboxes are driven in it, and the Custodians' route home is Service DNS.
15
- // <git.credentials[].token>
16
- // each token env var the config names rides the instance Secret (ADR-0051):
17
- // the Orchestrator materializes it into the Repo's credential Secret.
18
21
  //
19
- // The first stdout line is one JSON object `{ url, workflows }` — the discovery seam a fixture (or
20
- // a human tailing pod logs) parses instead of racing the socket. Deployed with a data plane, the
21
- // boot then creates one `Repo` resource per identity its Machines bind (ADR-0051) and announces
22
- // each as `{ repo, url, bound: true }` — or `{ repo, error }` — one line apiece, after serving:
23
- // a Repo the cluster refuses is a degraded Repo (ADR-0048), never a boot that did not happen.
24
- // Deployed WITHOUT one, the same pass still runs and binds nothing, which unlabels every Repo a
25
- // previous deploy bound — an Instance that drops its last `workspace()` leaves `jr2 gc` able to
26
- // collect what it stopped using.
22
+ // No git token reaches this process (ADR-0068): `jr2 up` writes each entry's clone Secret, and
23
+ // the Repo port names it from `git-clone.json` in the `jr2-held` mount.
24
+ //
25
+ // The first stdout line is one JSON object `{ url, workflows, assembly, kitVersion }` — the
26
+ // discovery seam a fixture (or a human tailing pod logs) parses instead of racing the socket.
27
+ // `assembly` is the current Assembly's hash, or `null` with `assemblyError` saying why when the
28
+ // volume named one this process cannot load. The line also names the runs the boot did not resume
29
+ // (`lost`, `drifted`, `failed`; ADR-0030), each restored inside the Assembly its snapshot names,
30
+ // and the Assemblies the boot `collected` because no live run holds them (ADR-0069), and, when any
31
+ // exists, the named Charts as `charts: { files, runtime, unrunnable }` (ADR-0070). A boot with
32
+ // no current Assembly restores nothing: it names the live runs it left untouched as `waiting`, and
33
+ // the first publish that lands restores them and announces one more line,
34
+ // `{ assembly, lost?, drifted?, failed? }`, when any run was not resumed. The hourly sweep
35
+ // announces what it collects as one more line, `{ collected }`; a publish names it in its answer.
36
+ // Deployed, the boot then creates one `Repo` resource per identity the Machines of its held
37
+ // Assemblies bind (ADR-0051, ADR-0069)
38
+ // and announces each as `{ repo, url, bound: true }` — or `{ repo, error }` — one line apiece,
39
+ // after serving: a Repo the cluster refuses is a degraded Repo (ADR-0048), never a boot that did
40
+ // not happen. An Assembly that binds none still runs the same pass and binds nothing, which
41
+ // unlabels every Repo a previous deploy bound — an Instance that drops its last `workspace()`
42
+ // leaves `jr2 gc` able to collect what it stopped using. Each publish runs the pass again. With no
43
+ // current Assembly there is no pass: nothing says what is bound, and the next publish decides.
27
44
 
28
45
  import { join } from "node:path";
29
- import { loadConfig } from "./config.ts";
46
+ import { resolveAssembly, type ResolvedAssembly } from "./assembly.ts";
47
+ import { KIT_VERSION, loadConfig, type JR2Config } from "./config.ts";
48
+ import { readImageRefs } from "./images.ts";
49
+ import { readMountDigests } from "./mount-digest.ts";
50
+ import { GIT_CLONE_MANIFEST_KEY, readGitCloneManifest } from "./git-clone.ts";
30
51
  import { loadWorkflows, startInstance, type RunningInstance } from "./instance.ts";
31
52
  import {
32
53
  HARNESS_CONFIGMAP,
33
54
  HARNESS_CONFIG_KEY,
34
55
  HARNESS_ENV_SECRET,
56
+ HELD_MOUNT,
35
57
  IMAGES_KEY,
36
58
  IMAGES_MOUNT,
37
59
  INSTANCE_HARNESS_PORT,
38
60
  INSTANCE_HARNESS_SERVICE,
61
+ INSTANCE_EFFECTOR,
62
+ EFFECTOR_PORT,
39
63
  } from "./names.ts";
64
+ import type { AnyStateMachine } from "xstate";
40
65
  import { partsOf, type CarriedRepo } from "./parts.ts";
41
- import { kubectlRepoFetches, type RepoFetches } from "./repo-fetch.ts";
42
- import { kubectlRepos, type RepoResources } from "./repos.ts";
43
- import { kubectlSandbox, type KubectlExec } from "./sandbox-kubectl.ts";
66
+ import { kubeClient, type KubeClient } from "./kube-client.ts";
67
+ import { kubeRepoFetches, type RepoFetches } from "./repo-fetch.ts";
68
+ import { kubeRepos, type RepoResources } from "./repos.ts";
69
+ import { SANDBOX_EFFECTOR_PORT, kubeSandbox } from "./sandbox-kube.ts";
70
+ import { sandboxEffectorService } from "./sandbox-effector.ts";
71
+ import { watchSandboxes, type SandboxWatch } from "./sandbox-watch.ts";
44
72
  import { loadSigningKey, mintInstanceToken } from "./tokens.ts";
45
73
  import type { SandboxPort } from "./workspace.ts";
46
74
 
47
75
  export type ServerMainOptions = {
48
- /** The instance folder (deployed: the image's WORKDIR; fixtures: a temp instance). */
76
+ /** The instance folder (deployed: the image's WORKDIR, with the state volume at `.jr2`;
77
+ * fixtures: a temp instance, whose `.jr2` receives what the fixture publishes). */
49
78
  dir: string;
50
79
  /** The environment to read the contract above from (deployed: `process.env`). */
51
80
  env: Record<string, string | undefined>;
52
81
  /** Where the one-line JSON announcement goes (deployed: stdout). */
53
82
  announce: (line: string) => void;
54
- /** The kubectl process seam behind the data plane's two ports, injectable for tests.
55
- * Deployed: the `kubectl` on PATH. */
56
- exec?: KubectlExec;
83
+ /** The Kubernetes client behind the data plane's ports (ADR-0063), injectable for tests.
84
+ * Deployed: the in-cluster client over the Pod's ServiceAccount. */
85
+ kube?: KubeClient;
57
86
  };
58
87
 
59
88
  /** Boot the instance the env describes; resolves once serving (the caller owns signals/exit). */
60
89
  export async function serverMain(opts: ServerMainOptions): Promise<RunningInstance> {
61
90
  const { env } = opts;
62
- const config = await loadConfig(opts.dir);
63
91
  const port = env.PORT !== undefined ? Number(env.PORT) : 4000;
64
92
  const signingKey =
65
93
  env.JR2_SIGNING_KEY !== undefined ? Buffer.from(env.JR2_SIGNING_KEY, "base64") : await loadSigningKey(opts.dir);
@@ -71,78 +99,190 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
71
99
 
72
100
  // From the instance Secret when deployed; per-boot for a host-booted fixture.
73
101
  const instanceToken = env.JR2_INSTANCE_TOKEN ?? mintInstanceToken();
102
+ const publishToken = env.JR2_PUBLISH_TOKEN ?? mintInstanceToken();
103
+
104
+ // The current Assembly (ADR-0069), and its config and Workflows read from its ROOT, never from
105
+ // the folder directly. A published Assembly that does not load (a Kit bump that broke an import,
106
+ // say) is no current Assembly: the process serves and says why (ADR-0048).
107
+ let resolved: ResolvedAssembly = await resolveAssembly(opts.dir);
108
+ // The config that applies is ALWAYS the current Assembly's (ADR-0069): the ports read it through
109
+ // this binding, and a publish replaces it. `bound` is whether a current Assembly binds one at all:
110
+ // an Assembly with no `jr2.config.ts` binds `undefined`, which is the defaults; no current
111
+ // Assembly binds nothing. In that window no run runs: the boot defers its restore to the first
112
+ // publish, which binds a config before it restores (instance.ts), and no Workflow is registered
113
+ // to start. The read routes (`GET /repos`, `GET /assembly`, `/healthz`, the feeds) read no
114
+ // config, so they answer in the window.
115
+ let config: JR2Config | undefined;
116
+ let bound = false;
117
+ // The current Assembly's hash, beside its config: a held run's provision names it (ADR-0069).
118
+ let currentHash: string | undefined;
119
+ let carried = partsOf([]);
120
+ if (resolved.current) {
121
+ const current = resolved.current;
122
+ try {
123
+ config = await loadConfig(current.root);
124
+ carried = partsOf((await loadWorkflows(current.root)).map((w) => w.machine));
125
+ bound = true;
126
+ currentHash = current.hash;
127
+ } catch (err) {
128
+ config = undefined;
129
+ resolved = {
130
+ current: null,
131
+ error: `the current Assembly ${current.hash} does not load: ${err instanceof Error ? err.message : err}`,
132
+ };
133
+ }
134
+ }
74
135
 
75
- // The data-plane switch (ADR-0012/0031/0051): a registered Machine COMPOSES a Sandbox — read off
76
- // the same walk `jr2 up` makes — and this process is deployed in a cluster → wire the kubectl
77
- // Sandbox backend. Otherwise an instance without a data plane (workspace() invocations fault
78
- // pointedly). The walk loads the same modules `startInstance` registers below; Node's module
79
- // cache makes them one import.
80
- const carried = partsOf((await loadWorkflows(opts.dir)).map((w) => w.machine));
81
- const dataPlane = carried.composesSandbox && namespace !== undefined;
136
+ // The data plane is ALWAYS wired when deployed (ADR-0069): a publish can bring the first
137
+ // `workspace()`, and wiring a client is free. `jr2 up` still decides from its host-side walk
138
+ // whether to DEPLOY the operator; a provision on a cluster with none fails naming `jr2 up`
139
+ // (sandbox-kube.ts). A host-booted process has no cluster: its `workspace()` runs fault pointedly.
140
+ const dataPlane = namespace !== undefined;
82
141
  let sandbox: SandboxPort | undefined;
83
142
  let repos: RepoResources | undefined;
84
143
  let fetches: RepoFetches | undefined;
85
- // The Repo port hangs off DEPLOYED, not off the data plane: this boot's reconcile is the only
86
- // writer that ever REMOVES `jr2.dev/bound` (repos.ts), and an Instance that drops its last
87
- // `workspace()` still owns the Repos its earlier deploys bound. So the port is built whenever
88
- // there is a cluster to drive, and the walk — now naming nothing — unlabels every one of them,
89
- // which is what puts them on `jr2 gc`'s clock. Gate it on the data plane instead and they stay
90
- // bound forever: uncollectable resources, with their node caches behind them.
144
+ let watch: LazyWatch | undefined;
91
145
  if (namespace !== undefined) {
92
- const credentials = config?.git?.credentials ?? [];
146
+ /** The config that applies now, or a pointed refusal while no current Assembly binds one.
147
+ * Only a provision (`settings`) and a Repo pass (`credentials`, through `bind`/`ensure`) read
148
+ * it; `repos.list()`, which `GET /repos` reads, does not. No run reaches it in the window: the
149
+ * restore is deferred and nothing is registered to start. The guard exists so that a future
150
+ * caller cannot provision with an empty fence and no placement, a config nobody wrote. */
151
+ const applied = (): JR2Config | undefined => {
152
+ if (!bound) throw new Error(NO_CONFIG);
153
+ return config;
154
+ };
155
+ const credentials = () => applied()?.git?.credentials ?? [];
156
+ // ONE client for the process (ADR-0063): one write cap over every port that writes, and the
157
+ // token read fresh on every request. Nothing is dialed until a port first asks.
158
+ const client = opts.kube ?? kubeClient({ env });
93
159
  // The Repo resources (ADR-0051): created by this process, cloned by the operator's cache agent
94
160
  // on every node that needs them. The port resolves `git.credentials` into each resource's
95
- // `secretRef`, reading a token entry's env var off this process — the Instance Secret is
96
- // `envFrom` on the Deployment, so `jr2 up` is what put it there.
97
- repos = kubectlRepos({ namespace, credentials, env, ...(opts.exec ? { exec: opts.exec } : {}) });
98
- if (dataPlane) {
99
- // The ask a pod makes when something inside it fetches (ADR-0053): it marks the Sandbox CR
100
- // and waits on the same status the provision waits on. Gated on the DATA PLANE, unlike the
101
- // Repo port above — there is nothing to ask for where no Sandbox is ever composed, and the
102
- // only caller is a pod that would have to exist to call it.
103
- fetches = kubectlRepoFetches({ namespace, ...(opts.exec ? { exec: opts.exec } : {}) });
104
- sandbox = kubectlSandbox({
105
- // The fence (ADR-0051): a per-run url must match one of these, or the provision refuses it.
106
- credentials,
107
- // Where a provision records the Repos it names, before the CR names them.
108
- repos,
109
- // Named here the same way HARNESS_CONFIGMAP is: a jr2-owned mount path, deliberately NOT an
110
- // env knob — there is no image escape hatch left to configure (ADR-0038). Note what this
111
- // buys: the map is read per provision, so an instance whose `jr2-images` ConfigMap is not yet
112
- // mounted still BOOTS and serves — only a provision fails, pointing at `jr2 up`. That is the
113
- // correct blast pattern, and the stale-read window is one kubelet propagation.
114
- imagesPath: join(IMAGES_MOUNT, IMAGES_KEY),
115
- // The Harness containers' env (ADR-0018): what this instance can REACH (the custom provider
116
- // — no Agents, they ride each Turn since ADR-0049), then the instance's own valueFrom
117
- // entries (literal values already live in the jr2-harness-env Secret below).
118
- env: [
119
- {
120
- name: "JR2_HARNESS_JSON",
121
- valueFrom: { configMapKeyRef: { name: HARNESS_CONFIGMAP, key: HARNESS_CONFIG_KEY } },
122
- },
123
- ...(config?.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
124
- ],
125
- envFrom: [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(config?.harness?.envFrom ?? [])],
126
- // Presence only — the PEM itself was materialized into the jr2-ca ConfigMap by `jr2 up`
127
- // (ADR-0020); the in-cluster config eval never reads the file.
128
- caBundle: config?.harness?.caBundle !== undefined,
129
- // Which nodes are Sandbox nodes (ADR-0052) — the CR carries it, the operator reads no config.
130
- ...(config?.sandbox ? { placement: config.sandbox } : {}),
131
- signingKey,
132
- namespace,
133
- ...(opts.exec ? { exec: opts.exec } : {}),
134
- });
135
- }
161
+ // `secretRef` and only names the Secret: `jr2 up` wrote each token's clone Secret and listed
162
+ // them in `git-clone.json`, read here per call so a converge reaches the next attach (ADR-0068).
163
+ // This port is also what unlabels the Repos an earlier deploy bound, so it hangs off DEPLOYED.
164
+ repos = kubeRepos({
165
+ namespace,
166
+ credentials,
167
+ cloneSecrets: async () => new Set((await readGitCloneManifest(join(HELD_MOUNT, GIT_CLONE_MANIFEST_KEY))).secrets),
168
+ client,
169
+ });
170
+ // The one watch (ADR-0063): every Sandbox of this Instance, shared by the provision, the lease
171
+ // and the fetch ask. Lazy, because a cluster where `jr2 up` deployed no operator serves no
172
+ // Sandbox kind, and a watch on it would only log. It starts at boot when the current Assembly
173
+ // composes a Sandbox: its first list IS the restore reconcile (ADR-0012).
174
+ watch = lazyWatch(() => watchSandboxes(client, { namespace }));
175
+ if (carried.composesSandbox) watch.start();
176
+ // The ask a pod makes when something inside it fetches (ADR-0053): it marks the Sandbox CR and
177
+ // waits on the same watch the provision waits on.
178
+ fetches = kubeRepoFetches({ namespace, client, watch });
179
+ sandbox = kubeSandbox({
180
+ // Named here the same way HARNESS_CONFIGMAP is: a jr2-owned mount path, deliberately NOT an
181
+ // env knob — there is no image escape hatch left to configure (ADR-0038). Note what this
182
+ // buys: the map is read per provision, so an instance whose `jr2-images` ConfigMap is not yet
183
+ // mounted still BOOTS and serves — only a provision fails, pointing at `jr2 up`. That is the
184
+ // correct blast pattern, and the stale-read window is one kubelet propagation.
185
+ imagesPath: join(IMAGES_MOUNT, IMAGES_KEY),
186
+ // What comes from the config, read per provision off the current Assembly's (ADR-0069).
187
+ settings: () => {
188
+ const config = applied();
189
+ return {
190
+ // The fence (ADR-0051): a per-run url must match one of these, or the provision refuses it.
191
+ credentials: credentials(),
192
+ // The Harness containers' env (ADR-0018): what this instance can REACH (the custom provider
193
+ // — no Agents, they ride each Turn since ADR-0049), then the instance's own valueFrom
194
+ // entries (literal values already live in the jr2-harness-env Secret below).
195
+ env: [
196
+ {
197
+ name: "JR2_HARNESS_JSON",
198
+ valueFrom: { configMapKeyRef: { name: HARNESS_CONFIGMAP, key: HARNESS_CONFIG_KEY } },
199
+ },
200
+ ...(config?.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
201
+ ],
202
+ envFrom: [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(config?.harness?.envFrom ?? [])],
203
+ // Presence only — the PEM itself was materialized into the jr2-ca ConfigMap by `jr2 up`
204
+ // (ADR-0020); the in-cluster config eval never reads the file.
205
+ caBundle: config?.harness?.caBundle !== undefined,
206
+ // Which nodes are Sandbox nodes (ADR-0052) — the CR carries it, the operator reads no config.
207
+ ...(config?.sandbox ? { placement: config.sandbox } : {}),
208
+ // The Size of a Workspace that states none (ADR-0060), and the class its pod runs under.
209
+ ...(config?.sandbox?.resources ? { defaultSize: config.sandbox.resources } : {}),
210
+ ...(config?.priorityClasses?.sandbox ? { priorityClassName: config.priorityClasses.sandbox } : {}),
211
+ };
212
+ },
213
+ // A held run's provision names the current Assembly when its image is not in the map.
214
+ currentAssembly: () => currentHash,
215
+ // Where a provision records the Repos it names, before the CR names them.
216
+ repos,
217
+ signingKey,
218
+ namespace,
219
+ client,
220
+ watch,
221
+ });
136
222
  }
137
223
 
224
+ // The Repo passes run one after another on one chain, so the newest pass always finishes last:
225
+ // a slow boot pass can never reconcile after a publish has bound other Repos.
226
+ // A pass binds the Repos of every HELD Assembly's Machines, not the current one's alone
227
+ // (ADR-0051, ADR-0069): a run under a held Assembly mounts the Repos its own Machine binds, so
228
+ // they stay bound, and the boot restates their spec, while a live run holds that Assembly.
229
+ // Read when the pass runs, so it sees the holds of that moment.
230
+ let held: (() => Promise<AnyStateMachine[]>) | undefined;
231
+ let passes: Promise<void> = Promise.resolve();
232
+ const repoPass = (port: RepoResources): void => {
233
+ passes = passes.then(async () => {
234
+ let repos = carried.repos;
235
+ try {
236
+ if (held) repos = partsOf(await held()).repos;
237
+ } catch (err) {
238
+ opts.announce(JSON.stringify({ repos: [], error: (err as Error).message }));
239
+ return;
240
+ }
241
+ await ensureBound(port, repos, opts.announce);
242
+ });
243
+ };
244
+
138
245
  const inst = await startInstance({
139
246
  dir: opts.dir,
247
+ assembly: resolved,
140
248
  port,
141
249
  hostname: env.HOST ?? "0.0.0.0",
142
250
  instanceToken,
251
+ publishToken,
143
252
  signingKey,
144
253
  sandbox,
145
254
  dataPlane,
255
+ // The door reads the same image map a provision reads (ADR-0069): a `file:` context whose
256
+ // digest it lacks is refused at the publish rather than at the first provision.
257
+ ...(dataPlane
258
+ ? { imageMap: () => readImageRefs(join(IMAGES_MOUNT, IMAGES_KEY)), mounts: () => readMountDigests() }
259
+ : {}),
260
+ // A publish landed (ADR-0069): its config is the one that applies from now on, and its
261
+ // Machines may bind other Repos, so the Repo pass runs again. Synchronous: the walk is over
262
+ // the Machines the door already imported.
263
+ onPublish: ({ current: published, config: next, workflows }) => {
264
+ config = next;
265
+ bound = true;
266
+ currentHash = published.hash;
267
+ carried = partsOf(workflows.map((w) => w.machine));
268
+ if (carried.composesSandbox) watch?.start();
269
+ if (repos) repoPass(repos);
270
+ },
271
+ // The Assemblies the hourly sweep let go (ADR-0069): a run that finished released its hold.
272
+ onCollect: (collected) => opts.announce(JSON.stringify({ collected })),
273
+ // The restore a boot with no current Assembly deferred, run by the first publish that lands
274
+ // (ADR-0069): the runs it did not resume ride one line, as the boot's do (ADR-0030).
275
+ onRestore: ({ assembly, lost, drifted, failed }) => {
276
+ if (!lost.length && !drifted.length && !failed.length) return;
277
+ opts.announce(
278
+ JSON.stringify({
279
+ assembly,
280
+ ...(lost.length ? { lost } : {}),
281
+ ...(drifted.length ? { drifted } : {}),
282
+ ...(failed.length ? { failed } : {}),
283
+ }),
284
+ );
285
+ },
146
286
  // Read per request, never snapshotted: the Repos as the cluster reports them right now. An
147
287
  // instance without a data plane reports none — `GET /repos` answers `{ dataPlane: false,
148
288
  // repos: [] }` (http.ts), and what its dropped Machines left behind is `jr2 gc`'s to name.
@@ -150,37 +290,94 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
150
290
  // The pod's route out (ADR-0053), same shape: read per request, off the port.
151
291
  ...(fetches ? { fetchRepo: (name: string, identity: string) => fetches.fetch(name, identity) } : {}),
152
292
  // Where a Menu-only Turn runs (ADR-0031): the Instance Harness's deterministic Service DNS.
153
- // `jr2 up` converges the Deployment behind it whenever any definition declares
293
+ // `jr2 up` converges the StatefulSet behind it whenever any definition declares
154
294
  // `workspace: "none"`, so deployed, the address exists exactly when it is needed.
155
295
  instanceHarness: namespace
156
296
  ? `http://${INSTANCE_HARNESS_SERVICE}.${namespace}.svc:${INSTANCE_HARNESS_PORT}`
157
297
  : undefined,
298
+ // Where an http Effect runs (ADR-0068): the Instance Effector's headless Service. `jr2 up`
299
+ // converges it whenever a registered Machine carries an http Effect.
300
+ ...(namespace ? { instanceEffector: `${INSTANCE_EFFECTOR}.${namespace}.svc:${EFFECTOR_PORT}` } : {}),
301
+ // Where a push, a file-body http or a credentialed command runs (ADR-0068): a Sandbox's Effector, by
302
+ // the headless Service the provision writes beside it when `jr2 up` placed an Effect there.
303
+ ...(namespace
304
+ ? {
305
+ sandboxEffector: (sandbox: string) =>
306
+ `${sandboxEffectorService(sandbox)}.${namespace}.svc:${SANDBOX_EFFECTOR_PORT}`,
307
+ }
308
+ : {}),
158
309
  });
310
+ held = inst.heldMachines;
311
+ // The watch ends with the instance: its stream is the one thing here that never settles alone.
312
+ if (watch) {
313
+ const close = inst.close;
314
+ inst.close = async () => {
315
+ watch!.stop();
316
+ await close();
317
+ };
318
+ }
159
319
  // Resumed runs are routine and stay quiet; runs this boot did NOT resume are not, so they ride
160
320
  // the announce line (ADR-0030) — the one thing every boot prints, whatever is reading it. Without
161
321
  // this, `drifted` is only reachable by asking after a run id nobody knows to ask about.
162
322
  const { lost, drifted, failed } = inst.restored;
323
+ const chartCounts = inst.charts;
163
324
  opts.announce(
164
325
  JSON.stringify({
165
326
  url: inst.url,
166
327
  workflows: inst.workflows,
328
+ // Which Assembly this process serves (ADR-0069), and the Kit version it accepts at the door.
329
+ assembly: inst.assembly?.hash ?? null,
330
+ kitVersion: KIT_VERSION,
331
+ ...(inst.assemblyError !== undefined ? { assemblyError: inst.assemblyError } : {}),
167
332
  ...(lost.length ? { lost } : {}),
168
333
  ...(drifted.length ? { drifted } : {}),
169
334
  ...(failed.length ? { failed } : {}),
335
+ // The live runs a boot with no current Assembly left untouched; the first publish restores them.
336
+ ...(inst.waiting.length ? { waiting: inst.waiting } : {}),
337
+ ...(inst.collected.length ? { collected: inst.collected } : {}),
338
+ // The named Charts (ADR-0070), when any exists: how many Chart files and runtime Charts, and
339
+ // the ones that cannot run now, with why.
340
+ ...(chartCounts.files || chartCounts.runtime ? { charts: chartCounts } : {}),
170
341
  }),
171
342
  );
172
343
  // After serving, never awaited: the bound Repos' resources (ADR-0051). Serving does not wait on
173
344
  // the cluster — a run whose Repo the boot could not record still finds its provision ensuring
174
345
  // it again — and a refusal is one announced line per Repo, in ADR-0048's shape. A deployed
175
346
  // instance that binds nothing still runs this: the walk names no Repo, so the reconcile is the
176
- // whole of it, and every resource an earlier deploy bound becomes evictable.
177
- if (repos) void ensureBound(repos, carried.repos, opts.announce);
347
+ // whole of it, and every resource an earlier deploy bound becomes evictable. A boot with no
348
+ // current Assembly does not: an empty walk there means "unknown", not "binds nothing", and
349
+ // unlabeling every Repo would let `jr2 gc` evict them until the publish.
350
+ if (repos && inst.assembly) repoPass(repos);
178
351
  return inst;
179
352
  }
180
353
 
354
+ /** What a port that reads the config says while no current Assembly binds one (ADR-0069). */
355
+ export const NO_CONFIG =
356
+ "no current Assembly: no config applies, so this Orchestrator provisions nothing — `jr2 up` publishes one";
357
+
358
+ /** The one Sandbox watch, started on first use or by {@link LazyWatch.start}. */
359
+ type LazyWatch = SandboxWatch & { start(): void };
360
+
361
+ /**
362
+ * A watch that starts on its first use (ADR-0063, ADR-0069). The data plane is wired on every
363
+ * deployed boot, but a cluster with no operator serves no Sandbox kind: a watch started there
364
+ * would only log its retries. Every method but `stop` starts it.
365
+ */
366
+ function lazyWatch(begin: () => SandboxWatch): LazyWatch {
367
+ let inner: SandboxWatch | undefined;
368
+ const live = () => (inner ??= begin());
369
+ return {
370
+ start: () => void live(),
371
+ synced: () => live().synced(),
372
+ get: (name) => live().get(name),
373
+ subscribe: (name, listener) => live().subscribe(name, listener),
374
+ stop: () => inner?.stop(),
375
+ };
376
+ }
377
+
181
378
  /**
182
379
  * The boot's half of ADR-0051's "the Orchestrator creates Repo resources": one per identity the
183
- * registered Machines bind, so statically known repositories are KNOWN before a run asks (the
380
+ * Machines of the held Assemblies bind (ADR-0069), so statically known repositories are KNOWN before a run asks (the
184
381
  * cache agent probes each; a node clones on first demand) — then
185
382
  * the bound label reconciled, so a slot unbound since the last deploy is a Repo `jr2 gc` may
186
383
  * evict. The boot is the ONE writer of a bound resource's spec: a redeploy that moved a url or a