@jr2/orchestrator 0.1.0

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bin/server.ts +23 -0
  4. package/console/canvas.ts +843 -0
  5. package/console/components/app.ts +79 -0
  6. package/console/components/drawer.ts +131 -0
  7. package/console/components/fleet.ts +117 -0
  8. package/console/components/machine-pane.ts +85 -0
  9. package/console/components/nav.ts +81 -0
  10. package/console/components/schema-form.ts +137 -0
  11. package/console/main.ts +383 -0
  12. package/console/page.html +28 -0
  13. package/console/store.ts +336 -0
  14. package/console/style.css +700 -0
  15. package/console/tsconfig.json +18 -0
  16. package/package.json +61 -0
  17. package/src/actor.ts +562 -0
  18. package/src/agent.ts +124 -0
  19. package/src/ambient.ts +50 -0
  20. package/src/config.ts +297 -0
  21. package/src/customize.ts +348 -0
  22. package/src/durability.ts +135 -0
  23. package/src/fingerprint.ts +92 -0
  24. package/src/gate.ts +76 -0
  25. package/src/harness-client.ts +503 -0
  26. package/src/http.ts +753 -0
  27. package/src/images.ts +303 -0
  28. package/src/index.ts +40 -0
  29. package/src/instance.ts +294 -0
  30. package/src/machine-doc.ts +334 -0
  31. package/src/names.ts +78 -0
  32. package/src/open.ts +17 -0
  33. package/src/parts.ts +500 -0
  34. package/src/pool.ts +284 -0
  35. package/src/registration.ts +340 -0
  36. package/src/repo-fetch.ts +259 -0
  37. package/src/repo-identity.ts +145 -0
  38. package/src/repos.ts +330 -0
  39. package/src/run-host.ts +1095 -0
  40. package/src/sandbox-kubectl.ts +1136 -0
  41. package/src/server.ts +220 -0
  42. package/src/setup.ts +360 -0
  43. package/src/snapshot-store.ts +150 -0
  44. package/src/stub-harness.ts +217 -0
  45. package/src/tokens.ts +126 -0
  46. package/src/vocabulary.ts +99 -0
  47. package/src/wire.ts +103 -0
  48. package/src/workspace.ts +874 -0
  49. package/tsconfig.instance.json +26 -0
package/src/images.ts ADDED
@@ -0,0 +1,303 @@
1
+ // The resolved key→ref image map, from the READ side (ADR-0037/0038/0049). `jr2 up` builds every
2
+ // image it deploys and writes this map into the `jr2-images` ConfigMap; the Sandbox port consults it
3
+ // when it creates a pod. This module is the shape both sides agree on — deliberately its own file,
4
+ // not folded into sandbox-kubectl.ts, because the CLI needs the type without dragging in the
5
+ // kubectl port.
6
+ //
7
+ // The map is NESTED, never flat. The kit's own `harness`/`adapter` refs sit beside a `sandbox`
8
+ // sub-map, so a user image can never shadow them — and `default` is the one reserved key in that
9
+ // sub-map, because ADR-0037's fallback chain is built on it.
10
+ //
11
+ // A user image's key is its build context's CONTENT DIGEST (ADR-0049). That is what lets the two
12
+ // sides agree without a path table: a Machine names its context as a `file:` URL, whose absolute
13
+ // path differs between the host at `jr2 up` and the baked Orchestrator, while the FOLDER is the same
14
+ // folder — the instance's `node_modules` holds the very tree the converge hashed. Digest in, digest
15
+ // out, no dirname registry in between.
16
+ //
17
+ // There is NO cache and NO memo for the map itself. The whole reason it arrives as a mounted
18
+ // ConfigMap rather than Deployment env (ADR-0038) is that a Dockerfile edit must not roll the
19
+ // Orchestrator and put every live run through snapshot restore; a read-once cache would give back
20
+ // exactly the stale-ref behavior the mount exists to avoid. The cost is a plain file read per
21
+ // provision.
22
+
23
+ import { createHash } from "node:crypto";
24
+ import { readdir, readFile } from "node:fs/promises";
25
+ import { join, relative } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ /**
29
+ * Every image ref one converge resolved. `harness`/`adapter` are the kit's own (built from a kit
30
+ * checkout, or the published `<kitversion>` tags); `sandbox` is the instance's own docker-context
31
+ * builds — one `docker build` straight to a content tag, carrying no jr2 layers at all, because the
32
+ * Harness arrives at POD time on the `/opt/jr2` volume (ADR-0037).
33
+ *
34
+ * A registry REF is deliberately absent from this map and always will be: it is never built,
35
+ * never labeled, never swept, and never inspected at converge (ADR-0037/0039 — what jr2 did not
36
+ * stamp, jr2 does not touch). It needs no entry because it already IS its own ref.
37
+ */
38
+ export type ImageRefs = {
39
+ /** The stock Harness image — also the last leg of the Sandbox Image fallback chain. */
40
+ harness: string;
41
+ /** The Adapter image (ADR-0013). An Agent with no Adapter cannot drive its Machine at all, so
42
+ * this is required: a map without it fails the provision rather than shipping a mute pod. */
43
+ adapter: string;
44
+ /** Built Sandbox Images by their build context's CONTENT DIGEST (ADR-0049), plus the reserved
45
+ * key `default` — the Instance's `images/default`, ADR-0037's middle leg. Empty when the
46
+ * instance ships no context and scaffolded no default. */
47
+ sandbox: Record<string, string>;
48
+ /**
49
+ * What each BUILT Sandbox Image's `USER` is, keyed by the same key — the answer to a
50
+ * question a provision cannot ask. ADR-0037 gives an image that declares no user a fallback
51
+ * (uid 1000, `HOME=/home/jr2` on an emptyDir), and only the host that built the image can see
52
+ * which case it is: `docker inspect` at converge is free, the cluster has no such reach.
53
+ *
54
+ * `""` is DATA, not a missing value: it is docker's own answer for "declares none", and it is
55
+ * exactly what the fallback turns on. An ABSENT key means jr2 did not build the image — a
56
+ * registry ref by construction, never built, never inspected — so it runs as whatever its own
57
+ * `USER` says, and one that would run as root fails the Harness container's `runAsNonRoot`.
58
+ * Optional so an older converge's map still reads: absent reads as "nothing known", which
59
+ * applies no fallback, which is the safe direction (the image keeps its own identity).
60
+ */
61
+ sandboxUser?: Record<string, string>;
62
+ };
63
+
64
+ /**
65
+ * Read the map the CLI wrote, per call. Absent or unparseable is a POINTED error naming the path
66
+ * and `jr2 up` — never a fallback onto a published tag: in a kit checkout the Harness is built to a
67
+ * content-addressed tag, so a literal `jr2-harness:<kitversion>` fallback would name a tag that was
68
+ * never built, and more importantly it would be the eject hatch ADR-0027/0038 refuse (nobody gets
69
+ * to run a hand-picked Harness against a real cluster).
70
+ */
71
+ export async function readImageRefs(path: string): Promise<ImageRefs> {
72
+ let raw: string;
73
+ try {
74
+ raw = await readFile(path, "utf8");
75
+ } catch (err) {
76
+ throw new Error(
77
+ `no image map at ${path} (${(err as NodeJS.ErrnoException).code ?? "read failed"}) — every Sandbox ` +
78
+ "image is resolved from the `jr2-images` ConfigMap, which `jr2 up` writes when it builds the " +
79
+ "instance's images (ADR-0038). Converge this instance with `jr2 up`.",
80
+ );
81
+ }
82
+ let parsed: unknown;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ } catch (err) {
86
+ throw new Error(
87
+ `the image map at ${path} is not JSON (${err instanceof Error ? err.message : err}) — it is written by ` +
88
+ "`jr2 up`; re-run it to rewrite the `jr2-images` ConfigMap (ADR-0038).",
89
+ );
90
+ }
91
+ const bad = (why: string): never => {
92
+ throw new Error(`the image map at ${path} is malformed: ${why} — re-run \`jr2 up\` (ADR-0038).`);
93
+ };
94
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) bad("expected a JSON object");
95
+ const map = parsed as Record<string, unknown>;
96
+ const ref = (key: string): string => {
97
+ const value = map[key];
98
+ if (typeof value !== "string" || !value) bad(`no \`${key}\` ref (got ${JSON.stringify(value)})`);
99
+ return value as string;
100
+ };
101
+ const harness = ref("harness");
102
+ // Required, not optional: with the ref living in the map there is no "no adapter configured"
103
+ // branch left to gate on, and an Agent whose pod has no Adapter parks forever on a tool call it
104
+ // cannot make (ADR-0013). Failing the provision is the only honest outcome.
105
+ const adapter = ref("adapter");
106
+ const sandboxRaw = map["sandbox"];
107
+ if (sandboxRaw !== undefined && (typeof sandboxRaw !== "object" || sandboxRaw === null || Array.isArray(sandboxRaw)))
108
+ bad("`sandbox` must be an object of name → ref");
109
+ const sandbox: Record<string, string> = {};
110
+ for (const [name, value] of Object.entries((sandboxRaw ?? {}) as Record<string, unknown>)) {
111
+ if (typeof value !== "string" || !value) bad(`\`sandbox.${name}\` is not a ref (got ${JSON.stringify(value)})`);
112
+ sandbox[name] = value as string;
113
+ }
114
+ const userRaw = map["sandboxUser"];
115
+ if (userRaw !== undefined && (typeof userRaw !== "object" || userRaw === null || Array.isArray(userRaw)))
116
+ bad("`sandboxUser` must be an object of name → USER");
117
+ const sandboxUser: Record<string, string> = {};
118
+ for (const [name, value] of Object.entries((userRaw ?? {}) as Record<string, unknown>)) {
119
+ // `""` is admitted deliberately — it is docker's answer for "declares no USER", the fact the
120
+ // fallback turns on. Only a non-string is malformed.
121
+ if (typeof value !== "string") bad(`\`sandboxUser.${name}\` is not a USER (got ${JSON.stringify(value)})`);
122
+ sandboxUser[name] = value as string;
123
+ }
124
+ return { harness, adapter, sandbox, sandboxUser };
125
+ }
126
+
127
+ /**
128
+ * Which of ADR-0037's two origins an image string names, told apart by SHAPE alone: a `file:` URL
129
+ * is a docker CONTEXT the Machine's module ships (`import.meta.resolve("./image")` — the only way
130
+ * an ES module can name a folder it owns), and anything else is a registry REF.
131
+ *
132
+ * Shape is the whole test on purpose: it needs no lookup, so it gives the same answer on the CLI's
133
+ * side and here. There is no third shape any more — the `images/<name>` dirname retired with
134
+ * dirname discovery (ADR-0049/0050), so a bare `ubuntu` is now what it looks like, a ref.
135
+ */
136
+ export function isImageContext(image: string): boolean {
137
+ return image.startsWith("file:");
138
+ }
139
+
140
+ /**
141
+ * The content address of a build context: everything in that directory, with NO exclusions — and
142
+ * nothing else (ADR-0037/0038).
143
+ *
144
+ * It lives HERE, not in the CLI's build module, because BOTH sides compute it: `jr2 up` keys the
145
+ * map it publishes by this digest, and the baked Orchestrator computes it again at provision to
146
+ * look the ref up. One implementation is what makes "the host and the pod agree without a path
147
+ * table" true rather than hopeful (ADR-0049).
148
+ *
149
+ * No exclusions is deliberate: that directory IS the build context and it carries no
150
+ * `.dockerignore`, so a `dist/` or `node_modules/` beside the Dockerfile is image content and must
151
+ * be image address. The resolved harness ref is likewise NOT an input — the Harness arrives on a
152
+ * pod volume, so a kit edit moves the harness image's own tag and re-images future pods while
153
+ * every Sandbox Image tag stands still.
154
+ */
155
+ export async function imageContextDigest(dir: string): Promise<string> {
156
+ const h = createHash("sha256");
157
+ // A domain separator, and the whole of it: a Sandbox Image's build is `docker build` of the
158
+ // user's own directory with no generated text anywhere in it, so — unlike the instance image,
159
+ // whose generated Dockerfile is image content the context never holds — there is nothing to salt
160
+ // WITH. The constant only keeps this hash's domain apart from the bundle's.
161
+ h.update("salt:sandbox\n");
162
+ const walk = async (root: string, d: string): Promise<void> => {
163
+ const entries = (await readdir(d, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
164
+ for (const e of entries) {
165
+ const p = join(d, e.name);
166
+ if (e.isDirectory()) await walk(root, p);
167
+ // Symlinks are skipped, as they are for every other content address jr2 takes: a link's
168
+ // content is its target's path, which is host geography, not image content.
169
+ else if (e.isFile()) {
170
+ h.update(`0:${relative(root, p)}\n`);
171
+ h.update(await readFile(p));
172
+ }
173
+ }
174
+ };
175
+ await walk(dir, dir);
176
+ return h.digest("hex").slice(0, 12);
177
+ }
178
+
179
+ /**
180
+ * The map key one image string resolves to: a CONTEXT is keyed by its content digest, a REF is its
181
+ * own key and is never looked up. Absent is the reserved `default` key — ADR-0037's middle leg.
182
+ *
183
+ * A context whose folder is unreadable fails HERE, before a Secret or a CR exists, naming the path:
184
+ * in a deployed Orchestrator that means the module shipped a context the instance bundle does not
185
+ * hold, which is a packaging bug and not a cluster one.
186
+ */
187
+ async function imageKey(image: string): Promise<string> {
188
+ if (!isImageContext(image)) return image;
189
+ const dir = fileURLToPath(image);
190
+ try {
191
+ return await imageContextDigest(dir);
192
+ } catch (err) {
193
+ throw new Error(
194
+ `the Sandbox Image context ${image} cannot be read (${(err as NodeJS.ErrnoException).code ?? "read failed"}) — ` +
195
+ "a `file:` image names a docker context the Machine's own module ships (ADR-0037), so it must travel " +
196
+ `with the module: check that ${dir} exists in the instance's node_modules.`,
197
+ );
198
+ }
199
+ }
200
+
201
+ /**
202
+ * One resolved Sandbox Image (ADR-0037): the ref the pod runs, plus the two seat facts only the
203
+ * host that BUILT it could see. One call rather than three, because all three answers must come
204
+ * off the same leg of the resolution chain — split, they could disagree.
205
+ */
206
+ export type ResolvedImage = {
207
+ /** The ref the primary container runs. */
208
+ ref: string;
209
+ /**
210
+ * The image declares no `USER`, so ADR-0037's fallback seat applies: uid 1000 with
211
+ * `HOME=/home/jr2` on an emptyDir.
212
+ *
213
+ * Only a recorded `""` — docker's own answer for "declares none" — sets this. Everything else is
214
+ * false, and each for its own reason: a registry ref was never inspected (its own `USER` stands,
215
+ * and a root one fails `runAsNonRoot` at provision, named from the pod's own status rather than
216
+ * silently patched); the stock Harness declares `USER 1000` itself; and an unrecorded key is a
217
+ * map an older converge wrote, where "nothing known" must leave the image's identity alone.
218
+ */
219
+ fallbackSeat: boolean;
220
+ /**
221
+ * The recorded `USER` the kubelet will REFUSE, when it will.
222
+ *
223
+ * Every jr2-owned seat carries `runAsNonRoot: true` and names no `runAsUser` (the image's own
224
+ * `USER` decides — ADR-0005), and the kubelet resolves that pairing before it ever starts the
225
+ * container. Two recorded values lose there: a non-numeric name (`USER dev`), which the kubelet
226
+ * cannot prove is non-root because it does not read the image's `/etc/passwd`, and uid 0
227
+ * (`USER root`, `USER 0`), which plainly is root.
228
+ *
229
+ * Both surface as CreateContainerConfigError on the FIRST init step that runs the image — the
230
+ * preflight — and that container never starts, so `kubectl logs -c preflight` prints nothing. The
231
+ * record is the one place that can see it coming: `docker inspect` at converge already put the
232
+ * string in the map, so the provision can fail BEFORE it applies anything. Only a BUILT image is
233
+ * knowable here; a registry ref was never inspected, so it answers undefined and that pod is
234
+ * caught later, from the cluster (`rootImageFault` in sandbox-kubectl.ts) — same fault, same fix,
235
+ * one round trip more expensive.
236
+ */
237
+ refusedUser?: string;
238
+ };
239
+
240
+ /** The recorded `USER`, judged. `uid:gid` is split because docker records the whole `USER` line;
241
+ * only the uid half decides. */
242
+ function judgeUser(recorded: string | undefined): Pick<ResolvedImage, "fallbackSeat" | "refusedUser"> {
243
+ // `""` is the fallback's trigger, not a failure (ADR-0037 supplies uid 1000); absent is unknown.
244
+ if (recorded === undefined) return { fallbackSeat: false };
245
+ if (recorded === "") return { fallbackSeat: true };
246
+ const uid = recorded.split(":")[0]!;
247
+ if (!/^\d+$/.test(uid) || Number(uid) === 0) return { fallbackSeat: false, refusedUser: recorded };
248
+ return { fallbackSeat: false };
249
+ }
250
+
251
+ /**
252
+ * Look one built key up in the map this converge wrote — for the Sandbox Image and the User
253
+ * Container alike, because ADR-0005 gives the User Container "the same resolution" deliberately.
254
+ *
255
+ * An unknown key throws: a converge-time check is impossible for a ref and unnecessary for a
256
+ * context (the walk built every one it found), so this fires when the Orchestrator's own
257
+ * `node_modules` holds a context the LAST converge did not build — a stale deployment. The error
258
+ * says exactly that, because "re-run `jr2 up`" is the whole fix.
259
+ */
260
+ function builtRef(refs: ImageRefs, key: string, image: string, what: string): string {
261
+ const ref = refs.sandbox[key];
262
+ if (ref === undefined) {
263
+ throw new Error(
264
+ `no ${what} for ${image} (context digest ${key}) — this instance's last converge built ` +
265
+ `${Object.keys(refs.sandbox).length} image(s), none of them this one. Re-run \`jr2 up\`: it walks the ` +
266
+ "registered Machines and builds every `file:` context they carry (ADR-0037/0049).",
267
+ );
268
+ }
269
+ return ref;
270
+ }
271
+
272
+ /**
273
+ * ADR-0037's resolution chain: the wrapper's `image` (a context or a ref) → the Instance's
274
+ * `images/default` → the stock Harness. The last leg comes out of the MAP rather than a
275
+ * `jr2-harness:${KIT_VERSION}` literal, because in a kit checkout the Harness is built to a
276
+ * content-addressed tag (ADR-0038) and a literal would name a tag nothing ever built.
277
+ *
278
+ * A REF passes through VERBATIM. It is deployed-never-built: jr2 never built it, so jr2 has no ref to
279
+ * look up and nothing to say about its tag discipline — the cluster pulls it, and nothing was
280
+ * inspected, so no seat fact is known.
281
+ */
282
+ export async function resolveSandboxImage(refs: ImageRefs, image?: string): Promise<ResolvedImage> {
283
+ if (image === undefined) {
284
+ const ref = refs.sandbox["default"];
285
+ if (ref === undefined) return { ref: refs.harness, fallbackSeat: false };
286
+ return { ref, ...judgeUser(refs.sandboxUser?.["default"]) };
287
+ }
288
+ if (!isImageContext(image)) return { ref: image, fallbackSeat: false };
289
+ const key = await imageKey(image);
290
+ return { ref: builtRef(refs, key, image, "Sandbox Image"), ...judgeUser(refs.sandboxUser?.[key]) };
291
+ }
292
+
293
+ /**
294
+ * The User Container's image (ADR-0005). Same two origins, same resolution — and NO fallback
295
+ * chain: absence means the pod has no third container, so there is nothing for a default to be.
296
+ * The seat's identity is "what jr2 does not own", and a jr2-chosen default would be an opinion in
297
+ * the one place ADR-0005 promises none. Its `USER` is not judged either: the seat carries no
298
+ * `securityContext` at all, so root is allowed there.
299
+ */
300
+ export async function resolveUserImage(refs: ImageRefs, image: string): Promise<string> {
301
+ if (!isImageContext(image)) return image;
302
+ return builtRef(refs, await imageKey(image), image, "User Container image");
303
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ // @jr2/orchestrator — the deployed app that runs Machines: workflow config, the agent + gate
2
+ // delivery surfaces, the duplex Actor over the Harness wire, and durable snapshot persistence
3
+ // (ADR-0002/0006/0007). Builds on the `@jr2/agent-protocol` wire contract.
4
+ //
5
+ // It does NOT speak MCP (ADR-0013): the Agent's MCP surface is hosted by the Adapter, in the
6
+ // Sandbox. What lives here is the registration table and two thin HTTP adapters over it.
7
+
8
+ // The wire contract, re-exported: a workflow authors against ONE package (`defineEvent`,
9
+ // `jr2Setup`, `workspace`, … all import from "@jr2/orchestrator" — ADR-0015).
10
+ export * from "@jr2/agent-protocol";
11
+ export * from "./agent.ts";
12
+ export * from "./parts.ts";
13
+ export * from "./customize.ts";
14
+ export * from "./ambient.ts";
15
+ export * from "./config.ts";
16
+ export * from "./repo-identity.ts";
17
+ export * from "./pool.ts";
18
+ export * from "./setup.ts";
19
+ export * from "./vocabulary.ts";
20
+ export * from "./tokens.ts";
21
+ export * from "./durability.ts";
22
+ export * from "./snapshot-store.ts";
23
+ export * from "./actor.ts";
24
+ export * from "./gate.ts";
25
+ export * from "./workspace.ts";
26
+ export * from "./images.ts";
27
+ export * from "./sandbox-kubectl.ts";
28
+ export * from "./repos.ts";
29
+ export * from "./repo-fetch.ts";
30
+ // Type-only: what a workflow event delivery looks like (the registration TABLE stays internal —
31
+ // ADR-0011: workflows speak only defineEvent/agent/gate).
32
+ export type { DeliveredEvent } from "./registration.ts";
33
+ export * from "./machine-doc.ts";
34
+ export * from "./run-host.ts";
35
+ export * from "./http.ts";
36
+ export * from "./harness-client.ts";
37
+ export * from "./stub-harness.ts";
38
+ export * from "./instance.ts";
39
+ export * from "./server.ts";
40
+ export * from "./names.ts";
@@ -0,0 +1,294 @@
1
+ // The instance bootstrap (ADR-0008/0009): turn an instance folder into a *running* orchestrator.
2
+ // This is the core of the deployed app's entrypoint (`serverMain`) — the seam that assembles the
3
+ // slice-1/2/3 pieces into one process:
4
+ //
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
7
+ // `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
+ // 4. serve the hono HTTP surface (`createApp`) so the CLI / humans can push + control + observe.
11
+ //
12
+ // The one design point worth stating (ADR-0011 static-import doctrine): a workflow module is
13
+ // self-contained — it declares its Agents and imports `gate` itself, in its own `setup` actors;
14
+ // everything live is constructed per-invocation from serializable input (the flue client from
15
+ // `input.endpoint`). The host injects NOTHING into workflow machines; `WorkflowDef.provide`
16
+ // remains a seam for tests, not a wiring obligation.
17
+
18
+ import { mkdir, readdir, stat } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+ import type { AddressInfo } from "node:net";
22
+ import { serve } from "@hono/node-server";
23
+ import type { AnyStateMachine } from "xstate";
24
+ import { createEchoPush } from "./harness-client.ts";
25
+ import { createApp } from "./http.ts";
26
+ import type { FetchAnswer } from "./repo-fetch.ts";
27
+ import type { RepoStatus } from "./repos.ts";
28
+ import { RunHost } from "./run-host.ts";
29
+ import type { RunRecord } from "./run-host.ts";
30
+ import { SqliteSnapshotStore } from "./snapshot-store.ts";
31
+ import type { SnapshotStore } from "./snapshot-store.ts";
32
+ import { createAuthenticator, loadSigningKey, mintInstanceToken } from "./tokens.ts";
33
+ import type { SandboxPort } from "./workspace.ts";
34
+
35
+ export type InstanceOptions = {
36
+ /** The instance folder (holds `workflows/`, and `.jr2/state.db` unless `store` is supplied). */
37
+ dir: string;
38
+ /** Listen port. Default 0 → an ephemeral port (read back from the running instance's `url`). */
39
+ port?: number;
40
+ /** Listen hostname. Default `127.0.0.1`. */
41
+ hostname?: string;
42
+ /** Override the durable store. Default: sqlite at `<dir>/.jr2/state.db`. */
43
+ store?: SnapshotStore;
44
+ /** Probe the live world before re-attaching on restore (ADR-0007). Default: always present. */
45
+ reconcile?: (run: RunRecord) => boolean | Promise<boolean>;
46
+ /** 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. */
49
+ sandbox?: SandboxPort;
50
+ /** Whether the instance has a data plane (ADR-0051) — what `GET /repos` reports beside the
51
+ * Repos, so `jr2 status` can say "no Workspace runs here" instead of listing nothing. */
52
+ dataPlane?: boolean;
53
+ /** The Repo resources as the cluster reports them (ADR-0051), read per request off the port the
54
+ * caller built. Absent = no data plane, which reports no Repos. */
55
+ repos?: () => Promise<RepoStatus[]>;
56
+ /** The ask a pod makes when something inside it fetches (ADR-0053), off the port the caller
57
+ * built. Absent = no data plane, so no Sandbox to ask for. */
58
+ fetchRepo?: (sandbox: string, identity: string) => Promise<FetchAnswer>;
59
+ /** The Instance Harness base URL (ADR-0031) — where a `workspace: "none"` Turn is admitted.
60
+ * The entrypoint derives it from the pod's namespace (deterministic Service DNS); absent,
61
+ * such a Turn without an explicit `endpoint` faults pointedly. */
62
+ instanceHarness?: string;
63
+ /** The key Sandbox tokens are signed with (ADR-0013). Default: `<dir>/.jr2/secret`, minted on
64
+ * 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). */
66
+ signingKey?: Buffer;
67
+ /** The Instance token to authenticate with (ADR-0013/0019). Deployed, `jr2 up` materializes it in
68
+ * the instance's Secret and the entrypoint passes it here, so a pod restart keeps the credential
69
+ * the CLI reads from that Secret. Default: minted per boot. */
70
+ instanceToken?: string;
71
+ };
72
+
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[] };
75
+
76
+ export type RunningInstance = {
77
+ host: RunHost;
78
+ /** The base URL the HTTP surface is reachable at (with the resolved port). */
79
+ url: string;
80
+ /**
81
+ * The Instance token this boot serves under (ADR-0013): the credential for gates and run
82
+ * control. Deployed it is supplied from the instance's Secret (ADR-0019) so it survives pod
83
+ * restarts; when minted per boot instead, only this handle knows it. The SIGNING KEY behind the
84
+ * Sandbox tokens must never rotate per boot either way (see `loadSigningKey`).
85
+ */
86
+ instanceToken: string;
87
+ /** Names of the workflows discovered + registered from `<dir>/workflows`. */
88
+ 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[] };
93
+ /**
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).
98
+ */
99
+ reload: () => Promise<ReloadResult>;
100
+ /** Stop the HTTP server and close the store. */
101
+ close: () => Promise<void>;
102
+ };
103
+
104
+ /** Boot an instance folder into a running orchestrator (discover → restore → serve). */
105
+ export async function startInstance(opts: InstanceOptions): Promise<RunningInstance> {
106
+ const hostname = opts.hostname ?? "127.0.0.1";
107
+
108
+ // 1. Durable store. Default sqlite needs its parent dir to exist before `DatabaseSync` opens it.
109
+ let store = opts.store;
110
+ if (!store) {
111
+ await mkdir(join(opts.dir, ".jr2"), { recursive: true });
112
+ store = new SqliteSnapshotStore(join(opts.dir, ".jr2", "state.db"));
113
+ }
114
+ await store.init();
115
+
116
+ // Resolved BEFORE the host: the Instance token is also the echo bearer (ADR-0023), so the
117
+ // host's echo pusher closes over it. Served under in step 4 below, unchanged.
118
+ const instanceToken = opts.instanceToken ?? mintInstanceToken();
119
+
120
+ const host = new RunHost({
121
+ store,
122
+ reconcile: opts.reconcile,
123
+ sandbox: opts.sandbox,
124
+ instanceHarness: opts.instanceHarness,
125
+ // The run-narrative echo (ADR-0023): tee a run's feed to its enclosing Workspace's Harness,
126
+ // authenticated as the instance. The wire push is here and the fire-and-forget is the
127
+ // host's, so a Harness that refuses (or is gone) costs a log line at most.
128
+ echo: (endpoint) => createEchoPush({ baseUrl: endpoint, token: instanceToken }),
129
+ // A run left `live` to be retried is otherwise unexplained — the announce line names it, this
130
+ // says why (ADR-0030). stderr, because it is a fault, not the boot's structured result.
131
+ onRestoreError: (runId, err) =>
132
+ console.error(`restore failed for run ${runId}: ${err instanceof Error ? err.message : err}`),
133
+ });
134
+
135
+ // Bump per reload so `import()` re-reads a changed file rather than serving the ESM module cache.
136
+ let importGen = 0;
137
+ const registerFile = async (name: string, file: string): Promise<void> => {
138
+ const machine = await importMachine(name, file, importGen);
139
+ host.register({
140
+ name,
141
+ machine,
142
+ // Nothing to inject (ADR-0011): the module imports its own actors; live clients are built
143
+ // per-invocation from input. `provide` stays a test seam on WorkflowDef, unused here.
144
+ provide: () => ({}),
145
+ });
146
+ };
147
+
148
+ // 2. Filename discovery: every `workflows/<name>.ts` exports its Machine + event manifest by name.
149
+ for (const { name, file } of await discoverWorkflows(opts.dir)) await registerFile(name, file);
150
+
151
+ // 3. Resume in-flight runs persisted by a prior process (ADR-0007). The outcome is reported, not
152
+ // swallowed (ADR-0030): a boot that declined to resume runs must say so, or "drifted" is only
153
+ // discoverable by asking after a specific run id nobody knows to ask about.
154
+ const restored = await host.restore();
155
+
156
+ // 4. Serve, authenticated (ADR-0013). The signing key is loaded from (or minted into) the
157
+ // instance folder, NOT generated per process: live Sandboxes outlive a restart, and their
158
+ // Adapters still bear tokens this key signed. The Instance token is per-boot; the key is not.
159
+ const signingKey = opts.signingKey ?? (await loadSigningKey(opts.dir));
160
+ const auth = createAuthenticator({ instanceToken, signingKey });
161
+ // The Repos are read PER REQUEST, never snapshotted here: a cache the agent cloned minutes
162
+ // after boot must show as present the next time anyone asks (ADR-0048/0051).
163
+ const app = createApp(host, auth, { dataPlane: opts.dataPlane, repos: opts.repos, fetchRepo: opts.fetchRepo });
164
+ const server = serve({ fetch: app.fetch, port: opts.port ?? 0, hostname });
165
+ const port = await new Promise<number>((resolve) => {
166
+ server.once("listening", () => resolve((server.address() as AddressInfo).port));
167
+ });
168
+
169
+ return {
170
+ host,
171
+ url: `http://${hostname}:${port}`,
172
+ instanceToken,
173
+ workflows: host.workflows(),
174
+ restored,
175
+ reload: async () => {
176
+ importGen++;
177
+ const before = new Set(host.workflows());
178
+ const found = await discoverWorkflows(opts.dir);
179
+ const foundNames = new Set(found.map((f) => f.name));
180
+ for (const { name, file } of found) await registerFile(name, file);
181
+ const removed: string[] = [];
182
+ for (const name of before) if (!foundNames.has(name)) (host.unregister(name), removed.push(name));
183
+ const added = [...foundNames].filter((n) => !before.has(n));
184
+ const updated = [...foundNames].filter((n) => before.has(n));
185
+ return { added, updated, removed, workflows: host.workflows() };
186
+ },
187
+ close: async () => {
188
+ // Before `server.close()`, not after: it waits for in-flight requests, and an observation
189
+ // feed is in-flight until its watcher goes away. `host.close()` is what makes them go away.
190
+ await host.close();
191
+ await new Promise<void>((resolve) => server.close(() => resolve()));
192
+ await store.close();
193
+ },
194
+ };
195
+ }
196
+
197
+ /** List `<dir>/workflows/*.ts` (ignoring `_`-prefixed helpers + `.d.ts`); name = filename stem. */
198
+ async function discoverWorkflows(dir: string): Promise<Array<{ name: string; file: string }>> {
199
+ // An absent workflows/ dir → an instance with no workflows yet (still boots + serves).
200
+ return discoverModules(join(dir, "workflows"));
201
+ }
202
+
203
+ /** Import one workflow module and take its Machine — the module contract (ADR-0011/0015):
204
+ * `export const machine`. `gen` cache-busts a reload; 0 imports the module URL untouched. */
205
+ async function importMachine(name: string, file: string, gen = 0): Promise<AnyStateMachine> {
206
+ const href = pathToFileURL(file).href + (gen ? `?v=${gen}` : "");
207
+ const mod: { machine?: unknown } = await import(href);
208
+ const machine = mod.machine as AnyStateMachine | undefined;
209
+ if (!machine) {
210
+ throw new Error(
211
+ `workflow "${name}" (${file}) has no \`machine\` named export ` +
212
+ `(module contract, ADR-0011/0015: \`export const machine\`)`,
213
+ );
214
+ }
215
+ return machine;
216
+ }
217
+
218
+ /**
219
+ * Discover + load every Workflow an instance folder registers — the same discovery and the same
220
+ * module contract `startInstance` boots with, without booting.
221
+ *
222
+ * `jr2 up` is the caller (ADR-0049/0050): a Machine carries its Agents and its Sandbox Image, so
223
+ * the only way to know what a deployment must preflight and converge is to load the Machines and
224
+ * WALK them (`partsOf`, parts.ts). It lives here, beside the discovery it shares, so the
225
+ * convention has one implementation rather than a second copy in the CLI that can drift.
226
+ */
227
+ export async function loadWorkflows(dir: string): Promise<Array<{ name: string; machine: AnyStateMachine }>> {
228
+ const loaded: Array<{ name: string; machine: AnyStateMachine }> = [];
229
+ for (const { name, file } of await discoverWorkflows(dir)) {
230
+ loaded.push({ name, machine: await importMachine(name, file) });
231
+ }
232
+ return loaded;
233
+ }
234
+
235
+ /**
236
+ * The instance module-discovery convention: every `<moduleDir>/<name>.ts` except `_`-prefixed
237
+ * helpers and `.d.ts`, sorted, name = filename stem. `workflows/` is the one directory that uses
238
+ * it, and the ONLY thing an Instance still discovers by filename (ADR-0050: what only the CLI and
239
+ * the HTTP API name is discovered; everything code names rides the Machine). The `agents/` folder
240
+ * and the `images/<name>` dirname scan it was also written for both retired with ADR-0049. An
241
+ * ABSENT dir is empty; any other readdir failure (EACCES, ENOTDIR, …) throws — a directory that
242
+ * exists but cannot be read must be loud, never "no modules".
243
+ */
244
+ export async function discoverModules(moduleDir: string): Promise<Array<{ name: string; file: string }>> {
245
+ let entries: string[];
246
+ try {
247
+ entries = await readdir(moduleDir);
248
+ } catch (err) {
249
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
250
+ throw err;
251
+ }
252
+ return entries
253
+ .filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && !f.startsWith("_"))
254
+ .sort()
255
+ .map((f) => ({ name: f.slice(0, -3), file: join(moduleDir, f) }));
256
+ }
257
+
258
+ /**
259
+ * The Instance's `images/default` build context, or undefined when it scaffolded none — ADR-0037's
260
+ * middle resolution leg, so that a local Machine never has to spell
261
+ * `import.meta.resolve("../images/default")` for the toolchain its own instance ships.
262
+ *
263
+ * A PATH CONVENTION, not discovery (ADR-0049/0050). There is exactly one path, it is checked, and
264
+ * nothing is enumerated: `images/<name>` dirnames stopped being names the moment a Machine started
265
+ * carrying its own image, and a folder scan would re-introduce the very thing that made a packaged
266
+ * Machine depend on someone else's directory layout. A second image is a `file:` context beside the
267
+ * Machine that names it.
268
+ *
269
+ * A folder with no `Dockerfile` THROWS, naming the missing path: unlike a stray `README.md`,
270
+ * `images/default/` has no other reason to exist, so silence there would be a Sandbox Image the
271
+ * author believes in and no converge ever builds.
272
+ *
273
+ * The Orchestrator process never calls this — refs reach it resolved, through the `jr2-images`
274
+ * ConfigMap (images.ts). It lives here because it is an Instance-layout fact, beside the one
275
+ * discovery convention that survives.
276
+ */
277
+ export async function defaultImageContext(dir: string): Promise<string | undefined> {
278
+ const imageDir = join(dir, "images", "default");
279
+ try {
280
+ await stat(join(imageDir, "Dockerfile"));
281
+ return imageDir;
282
+ } catch (err) {
283
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
284
+ }
285
+ try {
286
+ await stat(imageDir);
287
+ } catch {
288
+ return undefined;
289
+ }
290
+ throw new Error(
291
+ `the Instance's \`images/default\` has no Dockerfile (${join(imageDir, "Dockerfile")}) — that directory IS ` +
292
+ "a build context (ADR-0037), and it is the Sandbox Image every `workspace()` that names none falls back to.",
293
+ );
294
+ }