@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/agent.ts ADDED
@@ -0,0 +1,124 @@
1
+ // The Agent definition (ADR-0018/0027): the part of an Agent a user genuinely owns — model +
2
+ // instructions + workspace access — as SERIALIZABLE DATA. Everything mechanical (the Adapter
3
+ // leash, the Working-tool assembly, the wire) lives in the stock Harness image (`@jr2/harness`),
4
+ // which runs the definition it is handed and re-reads it per Submission.
5
+ //
6
+ // A definition is NOT an Instance roster entry: a Machine CARRIES it as an actor slot —
7
+ // `jr2Setup({ actors: { coder: agent(def) } })`, invoked as `src: "coder"` (ADR-0049) — so the
8
+ // Agent's name is the slot key and its scope is that one Machine. Two Machines in one run may
9
+ // both carry a `coder`; neither can see the other's.
10
+ //
11
+ // This module is the plain-data contract plus the slot BRAND (`isAgent` — the readable
12
+ // `definition` property `agent()` stamps on the logic). The brand lives HERE, apart from the
13
+ // logic that carries it, so `jr2Setup`'s menu derivation can recognize an Agent slot without
14
+ // pulling the wire client onto its load path.
15
+ //
16
+ // Since ADR-0054 the contract has two halves, and the split is the whole point: `AgentDefinition`
17
+ // is the WIRE type — `model: string`, because a Symbol does not ride a Turn — while
18
+ // `AgentDeclaration` is what an AUTHOR writes, whose `model` may be Open (open.ts) for a composer
19
+ // to bind. `jr2 up` refuses an Open Agent before anything is built; the Agent actor refuses to
20
+ // admit a Turn under one, as the second fence. Everything downstream of admission sees a
21
+ // definition.
22
+
23
+ import { open } from "./open.ts";
24
+
25
+ /** jr2's reasoning-effort scale (ADR-0027) — a strict subset of the runtime's, so every value
26
+ * passes through unmapped; mirrored by `@jr2/harness`'s spec contract. */
27
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
28
+
29
+ /** What an Agent may DO to the Workspace (ADR-0028) — and, through `"none"`, where its Turn runs
30
+ * (ADR-0031): the value the Agent actor reads off its own slot's definition to place the Turn. */
31
+ export type WorkspaceAccess = "write" | "read" | "none";
32
+
33
+ /** The plain-data Agent definition (ADR-0018). Must stay JSON-serializable: the definition rides
34
+ * the Turn to the Harness (ADR-0049), so anything non-serializable would be silently lost — grow
35
+ * this contract deliberately. ADR-0028 added a restriction vocabulary (`workspace`), not an
36
+ * extension one: custom tool implementations stay out of the contract. */
37
+ export type AgentDefinition = {
38
+ /** Model specifier, `<provider>/<modelId>`, e.g. `anthropic/claude-sonnet-4-6`. REQUIRED —
39
+ * there is no instance-wide default (ADR-0018): `jr2.config.ts`'s `harness` section
40
+ * declares which providers are REACHABLE, and the definition makes the choice. This is also the
41
+ * only model `jr2 up` can preflight, since a workflow's is not statically recoverable. An
42
+ * invocation may override it for one Turn (`AgentTurnInput.model`). */
43
+ model: string;
44
+ /** The Agent's system prompt. */
45
+ instructions: string;
46
+ /** Optional static description — observability, never sent to the model. */
47
+ description?: string;
48
+ /** Working directory inside the Sandbox. Default `/work` — the pod volume the attach step put
49
+ * the worktrees on (ADR-0005); override only for non-Workspace layouts. */
50
+ cwd?: string;
51
+ /** Reasoning effort. Omitted → the runtime's default. An invocation may override it for one
52
+ * Turn (`AgentTurnInput.thinkingLevel`) — effort is a property of the task's difficulty, so the
53
+ * same persona legitimately runs at different settings in different Machines. */
54
+ thinkingLevel?: ThinkingLevel;
55
+ /** What this Agent may DO to the Workspace (ADR-0028) — the persona in one word, deliberately
56
+ * not `tools` (that names the control-plane Menu, what it may SAY). `"read"` withholds the
57
+ * write/edit Working tools; bash stays, so this states intent and stops the honest path — the
58
+ * detached review worktree is the containment. `"none"` withholds the ENTIRE Working toolset:
59
+ * the Menu-only Agent converses and picks from its Menu, nothing else (`cwd` is moot — only
60
+ * Working tools consume it) — and places the Turn on the Instance Harness (ADR-0031).
61
+ * Default `"write"`. */
62
+ workspace?: WorkspaceAccess;
63
+ };
64
+
65
+ /**
66
+ * The Agent as an author DECLARES it (ADR-0054): the definition, except that `model` may be Open —
67
+ * the one part a packaged Machine cannot honestly fill, since the package cannot pay for it. Every
68
+ * other field is the wire's, because every other field a package CAN state.
69
+ *
70
+ * This is what `agent()` takes and what the slot's brand carries, so the walk and `customize()`
71
+ * read the author's own answer, Open included. Nothing sends this: the wire takes an
72
+ * {@link AgentDefinition}, and {@link requireBoundAgent} is the one door between the two.
73
+ */
74
+ export type AgentDeclaration = Omit<AgentDefinition, "model"> & { model: string | typeof open };
75
+
76
+ /** The brand `agent(declaration)` stamps on its logic (ADR-0049): the declaration itself, readable
77
+ * off the logic object. Everything that must recognize an Agent slot reads THIS — never a name
78
+ * convention and never a roster. */
79
+ export type AgentSlot = { definition: AgentDeclaration };
80
+
81
+ /**
82
+ * Is this actor logic an Agent slot? What `jr2Setup` asks of every `actors` entry an invoke names,
83
+ * to decide whether the invoke gets a derived Menu, a minted instance id, and its slot key as the
84
+ * Agent name (ADR-0049) — replacing the retired `src === "agentRun"` test.
85
+ *
86
+ * Duck-typed on the brand, not an instanceof: a `.provide()`-substituted fake (the unit-test seam)
87
+ * is deliberately NOT an Agent slot — the wrapping already happened at createMachine time, against
88
+ * the declared slot.
89
+ *
90
+ * An Open model counts (ADR-0054): a Machine whose Agent nobody bound yet is still a Machine that
91
+ * carries an Agent, and the walk that refuses it has to SEE it first — an unrecognized slot would
92
+ * be reported as nothing at all.
93
+ */
94
+ export function isAgent(logic: unknown): logic is AgentSlot {
95
+ if (typeof logic !== "object" || logic === null) return false;
96
+ const definition = (logic as { definition?: unknown }).definition;
97
+ if (typeof definition !== "object" || definition === null) return false;
98
+ const { model, instructions } = definition as Partial<AgentDeclaration>;
99
+ return (typeof model === "string" || model === open) && typeof instructions === "string";
100
+ }
101
+
102
+ /** Is this Agent's model still Open — nobody's answer yet (ADR-0054)? What the walk reports and
103
+ * the actor refuses on. */
104
+ export function isOpenAgent(declaration: AgentDeclaration): boolean {
105
+ return declaration.model === open;
106
+ }
107
+
108
+ /**
109
+ * The declaration as the wire takes it, or a refusal naming the slot — the SECOND fence
110
+ * (ADR-0054). The first is `jr2 up`'s walk, which refuses an Open Agent on a registered Machine
111
+ * before anything is built; this one catches every path that walk never saw (a Machine invoked as
112
+ * itself in a test, an unregistered import composed mid-run) and it catches it before a Turn is
113
+ * admitted rather than as a Harness 400 with nothing but a slot key in it.
114
+ */
115
+ export function requireBoundAgent(slot: string, declaration: AgentDeclaration): AgentDefinition {
116
+ if (typeof declaration.model !== "string") {
117
+ throw new Error(
118
+ `agent "${slot}" has an Open model — nobody bound it; bind it where the Machine is registered ` +
119
+ `(\`customize(<machine>, { agents: { ${slot}: { model: "<provider>/<model>" } } })\`), ` +
120
+ "because a packaged Machine cannot pick a model on your behalf (ADR-0054)",
121
+ );
122
+ }
123
+ return { ...declaration, model: declaration.model };
124
+ }
package/src/ambient.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Ambient workspace coordinates (ADR-0016): how an Agent's turn finds its Harness without the
2
+ // workflow threading `endpoint`/`sandbox` through every input. `workspace()`'s `running` state
3
+ // co-invokes a REGISTRAR actor that records the wrapper's mechanism handles here, keyed by the
4
+ // wrapper's own actorRef; the Agent actor walks `self._parent` to the nearest registered ancestor.
5
+ //
6
+ // Why an invoked actor and not an entry action: invoked actors restart on snapshot restore,
7
+ // entry actions do not — so the registration is restore-safe by construction (the same property
8
+ // the reconcile probe leans on, ADR-0012). Why the parent CHAIN: many concurrent workspaces
9
+ // share one actor system, so a system-keyed map cannot carry per-workspace handles; the chain
10
+ // resolves the ENCLOSING wrapper structurally, never a sibling's — which is what keeps
11
+ // cross-feature event injection impossible and lets the registration record the right Sandbox
12
+ // (ADR-0013's token scope) with zero consumer plumbing.
13
+ //
14
+ // A pure leaf (no flue, no machines) for the same reason as vocabulary.ts: actor.ts must reach
15
+ // it without dragging workspace.ts (and its SandboxPort surface) onto the actor's load path.
16
+
17
+ import type { AnyActorRef } from "xstate";
18
+
19
+ /** The mechanism-facing handles a workspace registers: where the Harness is, WHICH Sandbox
20
+ * (ADR-0013 token scope), and the worktree geography the body-facing subset is cut from. */
21
+ export type AmbientHandles = {
22
+ endpoint: string;
23
+ sandbox: string;
24
+ /** Every Repo Slot's branch-worktree path, by slot (ADR-0051). */
25
+ repos: Record<string, string>;
26
+ branch: string;
27
+ /** Detached review-worktree paths, when the spec carried `reviewSha` (ADR-0028). */
28
+ review?: Record<string, string>;
29
+ };
30
+
31
+ const byRef = new WeakMap<AnyActorRef, AmbientHandles>();
32
+
33
+ /** Registrar-side: record a wrapper's handles under its actorRef; returns the disposer. */
34
+ export function registerAmbientHandles(ref: AnyActorRef, handles: AmbientHandles): () => void {
35
+ byRef.set(ref, handles);
36
+ return () => {
37
+ // Dispose only our own entry (a re-registration on restore must not be clobbered late).
38
+ if (byRef.get(ref) === handles) byRef.delete(ref);
39
+ };
40
+ }
41
+
42
+ /** Actor-side: the nearest enclosing workspace's handles, via the actor parent chain.
43
+ * (`_parent` is typed public API on `ActorRef` — underscore-prefixed, not hidden.) */
44
+ export function ambientHandlesFor(self: AnyActorRef): AmbientHandles | undefined {
45
+ for (let ref = self._parent; ref; ref = ref._parent) {
46
+ const handles = byRef.get(ref);
47
+ if (handles) return handles;
48
+ }
49
+ return undefined;
50
+ }
package/src/config.ts ADDED
@@ -0,0 +1,297 @@
1
+ // Instance configuration (ADR-0050): the deployment facts a Machine cannot carry — the instance's
2
+ // identity and reach, what its Harness may reach, and how the cluster authenticates to Repos. What
3
+ // a Sandbox is MADE of and which Repos it attaches are not here — they are `workspace()` options
4
+ // (ADR-0037/0049/0051), and every image ref is resolved by `jr2 up` (ADR-0038). There is no `images`
5
+ // block and nothing that names a Repo, deliberately: neither would type anything a Machine cannot
6
+ // carry itself, and an override seat for the Harness ref is the eject hatch ADR-0027 refuses.
7
+ //
8
+ // `defineConfig` is an identity passthrough — it exists solely so a `jr2.config.ts` is checked
9
+ // against `JR2Config` at authoring time and in `jr2 up`'s typecheck gate (ADR-0050), exactly like
10
+ // the config helpers in vite/tsup/etc. No runtime behavior beyond returning its argument. It is
11
+ // NOT generic: the parameter is `JR2Config` itself, so the literal an author writes gets
12
+ // excess-property checking at every depth. A generic `<T extends JR2Config>` would infer `T` as
13
+ // the literal's own type and check nothing an extra key could break — a `tokn` on a
14
+ // `git.credentials` entry would compile, and that entry would admit its prefix through the fence
15
+ // anonymously (ADR-0051). Nothing reads the literal's type back, so the generic buys nothing.
16
+
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync } from "node:fs";
19
+ import { readFile } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+ import { fileURLToPath, pathToFileURL } from "node:url";
22
+ import { z } from "zod";
23
+ import { repoIdentity } from "./repo-identity.ts";
24
+
25
+ // ------------------------------------------------------------------------------------------------
26
+ // `git.credentials` (ADR-0051): how the cluster authenticates to a Repo, and the fence.
27
+ //
28
+ // A Repo is identified by its url and nothing in this file names one (CONTEXT.md "Repo"). What
29
+ // the config declares is CREDENTIALS, matched by prefix on the identity — the Argo and Flux shape.
30
+ // The Orchestrator resolves the entry when it creates a Repo CR and writes a `secretRef`; the
31
+ // cache agent reads only that. The list is also the fence: a per-run url (run input, a ticket
32
+ // field) that matches no entry is refused at attach, so nothing can spend this cluster's
33
+ // credential against an arbitrary host. A bound url is code the Instance typechecked and deployed
34
+ // — admitted without a match, cloned anonymously.
35
+
36
+ /** One credentials entry. An entry may carry neither `token` nor `sshKey`: it then admits its
37
+ * prefix through the fence and the clone is anonymous. */
38
+ export type GitCredential = {
39
+ /** A prefix on the identity (`github.com/ourorg/`), or `*` for everything. */
40
+ match: string;
41
+ /** Env var name holding an HTTPS token; `jr2 up` materializes its value into the Instance Secret. */
42
+ token?: string;
43
+ /** A Secret (Flux key names: `identity`, `identity.pub`, `known_hosts`) holding a deploy key. */
44
+ sshKey?: string;
45
+ };
46
+
47
+ export type GitConfig = {
48
+ /** Matched by prefix on the Repo identity; the longest `match` wins, ties → first in the list. */
49
+ credentials?: readonly GitCredential[];
50
+ };
51
+
52
+ /** The entry for an identity: `*` matches everything at length 0, else a prefix match; the
53
+ * longest `match` wins, and a tie goes to the first in the list. `undefined` when none matches —
54
+ * which the fence reads as "refuse a per-run url". */
55
+ export function matchCredential(identity: string, list: readonly GitCredential[]): GitCredential | undefined {
56
+ let best: { entry: GitCredential; length: number } | undefined;
57
+ for (const entry of list) {
58
+ const length = entry.match === "*" ? 0 : identity.startsWith(entry.match) ? entry.match.length : -1;
59
+ if (length < 0) continue;
60
+ if (best === undefined || length > best.length) best = { entry, length };
61
+ }
62
+ return best?.entry;
63
+ }
64
+
65
+ /** The Secret a Repo CR's `secretRef` names for `url` under `entry`, picked by the url's scheme:
66
+ * https/http spend a token (a Secret the Orchestrator derives from the env var), ssh spends the
67
+ * named deploy-key Secret. `undefined` when no entry, no applicable field, or a scheme that carries
68
+ * no credential (`git://`, a local path) — the clone is anonymous. */
69
+ export function credentialSecretFor(
70
+ url: string,
71
+ entry: GitCredential | undefined,
72
+ ): { kind: "token"; env: string; secret: string } | { kind: "ssh"; secret: string } | undefined {
73
+ if (entry === undefined) return undefined;
74
+ const { scheme } = repoIdentity(url);
75
+ if ((scheme === "https" || scheme === "http") && entry.token !== undefined)
76
+ return { kind: "token", env: entry.token, secret: gitTokenSecretName(entry.match) };
77
+ if (scheme === "ssh" && entry.sshKey !== undefined) return { kind: "ssh", secret: entry.sshKey };
78
+ return undefined;
79
+ }
80
+
81
+ /** The token Secret's name for one entry — deterministic in `match`, so a redeploy finds its own
82
+ * Secret and two entries never share one. */
83
+ export function gitTokenSecretName(match: string): string {
84
+ return `jr2-git-${createHash("sha256").update(match).digest("hex").slice(0, 8)}`;
85
+ }
86
+
87
+ /** Whether `url` clones over ssh — scp-style `git@host:path`, `ssh://`, or `git+ssh://`. A url
88
+ * that does not parse is not an ssh url. */
89
+ export function isSshUrl(url: string): boolean {
90
+ try {
91
+ return repoIdentity(url).scheme === "ssh";
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /** An env var on the Harness container, in the CR's (corev1.EnvVar) shape — `value` or a
98
+ * `valueFrom` secret/configmap reference, passed through to the operator verbatim. */
99
+ export type HarnessEnvVar = {
100
+ name: string;
101
+ value?: string;
102
+ valueFrom?: Record<string, unknown>;
103
+ };
104
+
105
+ /** A whole-Secret/ConfigMap env injection (corev1.EnvFromSource) for the Harness container —
106
+ * e.g. `{ secretRef: { name: "anthropic" } }` to hand a real Harness its model API key. */
107
+ export type HarnessEnvFromSource = {
108
+ secretRef?: { name: string };
109
+ configMapRef?: { name: string };
110
+ };
111
+
112
+ /** This kit's version — npm version == published image tag, one release train (ADR-0019). */
113
+ export const KIT_VERSION = (
114
+ JSON.parse(await readFile(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")) as {
115
+ version: string;
116
+ }
117
+ ).version;
118
+
119
+ /** Token limits for one model — the Harness's provider-registration options, keyed per model because limits are
120
+ * properties of the MODEL, not the endpoint (agents pick models per definition, ADR-0018). */
121
+ export type HarnessProviderModel = {
122
+ /** The model's context window, in tokens (vLLM: `max_model_len`). */
123
+ contextWindow?: number;
124
+ /** The model's max output tokens per completion. */
125
+ maxTokens?: number;
126
+ };
127
+
128
+ /** A custom model provider (ADR-0018) — what the stock Harness registers with pi's
129
+ * `registerProvider(id, { api, baseUrl, … })` (ADR-0027). The vLLM/Ollama path: an OpenAI-compatible
130
+ * endpoint under an instance-chosen provider id. */
131
+ export type HarnessProvider = {
132
+ /** The provider id model specifiers use (`<id>/<model>`), e.g. `vllm`. */
133
+ id: string;
134
+ /** The wire protocol, e.g. `openai-completions` (most OpenAI-compatible endpoints). */
135
+ api: string;
136
+ /** The endpoint — reachable FROM PODS (`localhost` never is; a LAN address works on kind).
137
+ * Deployment-varying → resolve from env (`.env`), never hardcode (ADR-0019). */
138
+ baseUrl: string;
139
+ /** API key, when the endpoint wants one. May read `process.env` — `jr2 up` materializes config
140
+ * env values into the instance's Secret; the literal never lands in a manifest (ADR-0019).
141
+ * Genuinely optional: an unauthenticated endpoint needs none (the Harness sends the wire
142
+ * library's placeholder, which vLLM/Ollama ignore). */
143
+ apiKey?: string;
144
+ /** Endpoint-wide default token limits, for any model this provider serves. Flue resolves
145
+ * per-model → provider-level → catalog → 0, and a CUSTOM provider id has no catalog entry —
146
+ * unset limits resolve to 0, which leaves auto-compaction no context budget to reason about
147
+ * (the wire request itself is fine: a 0 `maxTokens` is omitted, never sent). */
148
+ contextWindow?: number;
149
+ maxTokens?: number;
150
+ /** Per-model limits, keyed by the model id AFTER the provider prefix — the map entry for
151
+ * `vllm/Qwen/Qwen3-32B` is `"Qwen/Qwen3-32B"`. Committable model properties, not
152
+ * deployment-varying: whichever model an Agent definition names resolves its own entry. */
153
+ models?: Record<string, HarnessProviderModel>;
154
+ };
155
+
156
+ /** The agent-runtime section (ADR-0018): what the stock Harness image consumes alongside the
157
+ * Agent definition each Turn hands it (ADR-0049). Moved out of `sandbox` deliberately — `sandbox`
158
+ * is pod transport (it still CARRIES this env to the Harness container), but model concerns are
159
+ * Harness semantics.
160
+ *
161
+ * It declares what this instance can REACH — endpoints, credentials, trust — and never WHICH
162
+ * model to use (ADR-0018). This config holds no instance-wide `model` default and no Agent
163
+ * roster: an Agent is an actor slot on ONE Machine, whose definition rides the Turn (ADR-0049).
164
+ * One definition value may still be carried by several Machines, so the variation that matters is
165
+ * per-definition and per-invocation, which one global default serves not at all. */
166
+ export type HarnessConfig = {
167
+ /** Custom model provider, preflighted from inside the cluster by `jr2 up` (ADR-0019). */
168
+ provider?: HarnessProvider;
169
+ /** Env vars for the Harness container (Agent creds, e.g. ANTHROPIC_API_KEY). Values read from
170
+ * `process.env`/`.env` are materialized into the instance-owned Secret by `jr2 up`. */
171
+ env?: readonly HarnessEnvVar[];
172
+ /** Whole-Secret/ConfigMap env for the Harness container — `envFrom` refs to Secrets YOU manage
173
+ * (Sealed Secrets etc.); `jr2 up` preflights that each referenced Secret exists (ADR-0019). */
174
+ envFrom?: readonly HarnessEnvFromSource[];
175
+ /** Path to a PEM CA bundle, RELATIVE to the instance folder — commit the file (CA certs are
176
+ * public; e.g. an internal CA in front of a LAN vLLM). Only `jr2 up` reads it (host-side): it
177
+ * materializes the `jr2-ca` ConfigMap and runs the provider preflight with the same trust. The
178
+ * bundle lands on the Harness container and NOWHERE else — never the Adapter, whose Orchestrator
179
+ * credential has no business behind the same trust store (the `harness.env` asymmetry,
180
+ * ADR-0013/0020). */
181
+ caBundle?: string;
182
+ };
183
+
184
+ /** One pod-spec toleration, verbatim (ADR-0052): `nodeSelector` and `tolerations` are the raw
185
+ * Kubernetes shapes, the stance `HarnessEnvVar` takes for `EnvVar`. */
186
+ export type Toleration = {
187
+ key?: string;
188
+ operator?: "Exists" | "Equal";
189
+ value?: string;
190
+ effect?: "NoSchedule" | "PreferNoSchedule" | "NoExecute";
191
+ tolerationSeconds?: number;
192
+ };
193
+
194
+ /**
195
+ * Where an Instance's Sandboxes may land (ADR-0052). Absent, a Sandbox node is wherever an
196
+ * ordinary pod lands — not cordoned, no taint — and no jr2 label is ever required. Both keys ride
197
+ * the Sandbox pod verbatim, the Repo cache agent's DaemonSet takes the same two so a cache is only
198
+ * ever where a Sandbox can reach it, and `jr2 up` reads the same predicate off the nodes to report
199
+ * the set. A Machine says nothing about placement: a node label is a deployment fact (ADR-0050).
200
+ * These two are the default class; a per-Machine `classes` map is the deferred extension.
201
+ */
202
+ export type SandboxPlacement = {
203
+ /** Pod `spec.nodeSelector`: a node must carry every label. */
204
+ nodeSelector?: Readonly<Record<string, string>>;
205
+ /** Pod `spec.tolerations`, verbatim — no `tolerationSeconds` is added, so a tolerated `NoExecute`
206
+ * taint keeps a Sandbox through it for as long as its Lease is renewed. */
207
+ tolerations?: readonly Toleration[];
208
+ };
209
+
210
+ export type JR2Config = {
211
+ /** The instance's identity (ADR-0019): its kube namespace defaults to this (`-n` overrides),
212
+ * and `jr2 up` labels every object it owns with it. Default: the instance folder's name. */
213
+ name?: string;
214
+ /** How the cluster authenticates to Repos, and the fence a per-run url must pass (ADR-0051).
215
+ * NOT a list of Repos: a Machine names those itself, through its `workspace()`'s Repo Slots, and
216
+ * whether the instance has a data plane at all is read off the registered Machines. */
217
+ git?: GitConfig;
218
+ /** Agent-runtime config for the stock Harness (see `HarnessConfig`). */
219
+ harness?: HarnessConfig;
220
+ /** Which nodes are this Instance's Sandbox nodes (see `SandboxPlacement`, ADR-0052). Absent →
221
+ * wherever an ordinary pod lands. */
222
+ sandbox?: SandboxPlacement;
223
+ /** Image registry prefix (deployment-varying — resolve from env). Absent → images are
224
+ * `kind load`-ed; present → pushed. A non-kind cluster without one fails loudly (ADR-0019). */
225
+ registry?: string;
226
+ /** Where this cluster pulls the PUBLISHED Kit images from (deployment-varying — resolve from
227
+ * env). Absent → the canonical home, `ghcr.io/snapwich/jr2-harness:<kitversion>` and friends;
228
+ * present → the same tags re-homed to a self-hosted mirror, `<kitRegistry>/jr2-harness:<ver>`,
229
+ * for a self-hosted, air-gapped, or mirror-only cluster (ADR-0044). Seeding that mirror is a
230
+ * deliberate, instance-less act (`jr2 kit push`), never a side effect of `jr2 up`.
231
+ *
232
+ * Separate from `registry` on purpose: `registry` addresses images THIS converge builds,
233
+ * `kitRegistry` addresses artifacts the kit already published. One key for both would make every
234
+ * private-registry user mirror three images they could have pulled from the home. */
235
+ kitRegistry?: string;
236
+ /** What `jr2 up` builds its images FOR — docker platform strings, e.g. `["linux/arm64"]`
237
+ * (deployment-varying — resolve from env). Absent → derived from the cluster's schedulable nodes
238
+ * and intersected with the platforms the kit releases for, which is the answer for every ordinary
239
+ * cluster (ADR-0045). Present → ABSOLUTE: derivation is skipped and this is the build set (still
240
+ * intersected, so an unpublished platform is a named error, never a silent build).
241
+ *
242
+ * The escape hatch for the two cases derivation cannot see: a pool that autoscales from zero (no
243
+ * nodes to read yet), and a polluted set (an amd64 GPU pool beside arm64 workers, where the
244
+ * derived pair would cost a needless qemu cross-build). Not additive or subtractive. */
245
+ platforms?: readonly string[];
246
+ /** Operator-layer overrides — kit development territory (ADR-0019). */
247
+ operator?: {
248
+ /** `false` = `jr2 up` skips the operator layer (run the controller loop yourself). */
249
+ manage?: boolean;
250
+ };
251
+ };
252
+
253
+ /** Identity passthrough that checks a config object against `JR2Config` where it is written —
254
+ * excess keys included, at every depth, which is why the parameter is the type and not a generic
255
+ * bound by it. */
256
+ export function defineConfig(c: JR2Config): JR2Config {
257
+ return c;
258
+ }
259
+
260
+ /**
261
+ * Load an instance's `jr2.config.ts` (default export). Absent file → undefined (an instance
262
+ * can boot configless); a file that fails to IMPORT throws — a broken config must be loud,
263
+ * never silently treated as "no config". The one shape check lives here, on the path the host
264
+ * CLI and the in-cluster Orchestrator share: `git.credentials` is checked at runtime, because an
265
+ * instance is zero-build and nothing typechecks this file before Node strips its types and
266
+ * imports it.
267
+ */
268
+ export async function loadConfig(dir: string): Promise<JR2Config | undefined> {
269
+ const file = join(dir, "jr2.config.ts");
270
+ if (!existsSync(file)) return undefined;
271
+ const mod = (await import(pathToFileURL(file).href)) as { default?: JR2Config };
272
+ if (!mod.default) throw new Error(`${file} has no default export (use \`export default defineConfig({…})\`)`);
273
+ if (mod.default.git !== undefined) checkGit(mod.default.git, file);
274
+ return mod.default;
275
+ }
276
+
277
+ const NonEmpty = z.string().min(1);
278
+ const Credential = z.object({ match: NonEmpty, token: NonEmpty.optional(), sshKey: NonEmpty.optional() }).strict();
279
+
280
+ /** `git.credentials`' SHAPE, checked at load — by index and field, so a bad entry names itself
281
+ * (`git.credentials[1].token`) rather than surfacing as a Secret that never matches. */
282
+ function checkGit(git: unknown, where: string): void {
283
+ if (typeof git !== "object" || git === null || Array.isArray(git))
284
+ throw new Error(`${where} at git: expected an object — ${GIT_HINT}`);
285
+ const { credentials } = git as { credentials?: unknown };
286
+ if (credentials === undefined) return;
287
+ if (!Array.isArray(credentials)) throw new Error(`${where} at git.credentials: expected an array — ${GIT_HINT}`);
288
+ for (const [i, entry] of credentials.entries()) {
289
+ const parsed = Credential.safeParse(entry);
290
+ if (parsed.success) continue;
291
+ const issue = parsed.error.issues[0];
292
+ const at = ["", ...(issue?.path ?? [])].map(String).join(".");
293
+ throw new Error(`${where} at git.credentials[${i}]${at}: ${issue?.message ?? "invalid"} — ${GIT_HINT}`);
294
+ }
295
+ }
296
+
297
+ const GIT_HINT = "an entry is { match, token?, sshKey? } (ADR-0051)";