@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.
- package/LICENSE +21 -0
- package/README.md +23 -0
- package/bin/server.ts +23 -0
- package/console/canvas.ts +843 -0
- package/console/components/app.ts +79 -0
- package/console/components/drawer.ts +131 -0
- package/console/components/fleet.ts +117 -0
- package/console/components/machine-pane.ts +85 -0
- package/console/components/nav.ts +81 -0
- package/console/components/schema-form.ts +137 -0
- package/console/main.ts +383 -0
- package/console/page.html +28 -0
- package/console/store.ts +336 -0
- package/console/style.css +700 -0
- package/console/tsconfig.json +18 -0
- package/package.json +61 -0
- package/src/actor.ts +562 -0
- package/src/agent.ts +124 -0
- package/src/ambient.ts +50 -0
- package/src/config.ts +297 -0
- package/src/customize.ts +348 -0
- package/src/durability.ts +135 -0
- package/src/fingerprint.ts +92 -0
- package/src/gate.ts +76 -0
- package/src/harness-client.ts +503 -0
- package/src/http.ts +753 -0
- package/src/images.ts +303 -0
- package/src/index.ts +40 -0
- package/src/instance.ts +294 -0
- package/src/machine-doc.ts +334 -0
- package/src/names.ts +78 -0
- package/src/open.ts +17 -0
- package/src/parts.ts +500 -0
- package/src/pool.ts +284 -0
- package/src/registration.ts +340 -0
- package/src/repo-fetch.ts +259 -0
- package/src/repo-identity.ts +145 -0
- package/src/repos.ts +330 -0
- package/src/run-host.ts +1095 -0
- package/src/sandbox-kubectl.ts +1136 -0
- package/src/server.ts +220 -0
- package/src/setup.ts +360 -0
- package/src/snapshot-store.ts +150 -0
- package/src/stub-harness.ts +217 -0
- package/src/tokens.ts +126 -0
- package/src/vocabulary.ts +99 -0
- package/src/wire.ts +103 -0
- package/src/workspace.ts +874 -0
- 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";
|
package/src/instance.ts
ADDED
|
@@ -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
|
+
}
|