@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/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)";
|