@jr2/cli 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/bin/jr2.js +3 -3
- package/manifests/operator.yaml +265 -23
- package/package.json +5 -5
- package/src/agents-md.ts +149 -0
- package/src/assembly.ts +432 -0
- package/src/build.ts +145 -290
- package/src/cli.ts +20 -41
- package/src/client.ts +144 -33
- package/src/command-table.ts +641 -0
- package/src/commands/check.ts +37 -0
- package/src/commands/down.ts +7 -16
- package/src/commands/gc.ts +2 -11
- package/src/commands/init.ts +82 -19
- package/src/commands/kit.ts +6 -11
- package/src/commands/logs.ts +13 -9
- package/src/commands/run.ts +70 -17
- package/src/commands/runs.ts +83 -6
- package/src/commands/send.ts +22 -23
- package/src/commands/status.ts +134 -22
- package/src/commands/up.ts +1704 -386
- package/src/commands/version.ts +26 -25
- package/src/deploy.ts +880 -101
- package/src/effector-image.ts +133 -0
- package/src/index.ts +26 -0
- package/src/instance-check.ts +309 -0
- package/src/instance.ts +3 -9
- package/src/kit-version.ts +27 -17
- package/src/kube.ts +439 -70
- package/src/nodes.ts +33 -2
- package/src/output.ts +1 -1
- package/src/repo-sweep.ts +1 -1
- package/src/sweep.ts +30 -15
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// What a push owes the author's Effector image (ADR-0068). The Sandbox Effector runs in the
|
|
2
|
+
// author's image — the Sandbox Image by default — and runs jr2's own programs from one fixed PATH
|
|
3
|
+
// (`/usr/local/bin:/usr/bin:/bin`, never the image's own). So, per push form the Workspace holds,
|
|
4
|
+
// `jr2 up` reads the image it built — or, for the stock Harness image, which the kit builds, knows
|
|
5
|
+
// what it holds — and refuses, with the fix, an image that lacks what that form needs:
|
|
6
|
+
//
|
|
7
|
+
// - ssh transport (`ssh://`, `git@`): `ssh`, and a passwd entry for the uid the Effector runs as —
|
|
8
|
+
// OpenSSH's client calls `getpwuid(getuid())` before any option, and exits "No user exists for
|
|
9
|
+
// uid N" without one;
|
|
10
|
+
// - ssh signing (`sign: { sshKey }`): `ssh-keygen` (`-Y sign` does no passwd lookup);
|
|
11
|
+
// - GPG signing (`sign: { gpgKey }`): `gpg` and `gpg-agent`;
|
|
12
|
+
// - a token over https: nothing beyond `git`, the Sandbox Image floor (the helper is jr2's binary).
|
|
13
|
+
//
|
|
14
|
+
// The uid is the image's: its `USER` line by name or number, else 1000 (ADR-0037's fallback).
|
|
15
|
+
// `http` (a Worktree file body included) and the credentialed command owe nothing: the first is
|
|
16
|
+
// Go, the last is the author's argv.
|
|
17
|
+
|
|
18
|
+
/** Where the Effector looks for jr2's own programs: effector/internal/gitclean's Path. */
|
|
19
|
+
export const EFFECTOR_TOOL_DIRS = ["/usr/local/bin", "/usr/bin", "/bin"] as const;
|
|
20
|
+
|
|
21
|
+
/** One form a push of a Workspace takes, by what it owes its image. */
|
|
22
|
+
export type PushForm = "ssh-transport" | "ssh-sign" | "gpg-sign";
|
|
23
|
+
|
|
24
|
+
/** The programs each form needs, by name. */
|
|
25
|
+
const TOOLS: Record<PushForm, readonly string[]> = {
|
|
26
|
+
"ssh-transport": ["ssh"],
|
|
27
|
+
"ssh-sign": ["ssh-keygen"],
|
|
28
|
+
"gpg-sign": ["gpg", "gpg-agent"],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** What to install, by program: the package name on Debian and on Alpine, where they differ. */
|
|
32
|
+
const PACKAGES: Record<string, string> = {
|
|
33
|
+
ssh: "openssh-client",
|
|
34
|
+
"ssh-keygen": "openssh-client (Alpine: openssh-keygen)",
|
|
35
|
+
gpg: "gnupg",
|
|
36
|
+
"gpg-agent": "gnupg",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the stock Harness image holds of the forms' programs, and its /etc/passwd: the fallback
|
|
41
|
+
* Sandbox Image, which the Sandbox Effector runs in when the Workspace names no image and the
|
|
42
|
+
* instance has no `images/default`. The kit builds it (deploy/harness/Dockerfile, its last stage:
|
|
43
|
+
* `node:24-slim` with `git` and `ca-certificates`, `USER 1000`, node's uid), so `jr2 up` knows it in both worlds
|
|
44
|
+
* without reading it — a published one is never pulled to the host for this. A test holds the
|
|
45
|
+
* Dockerfile to it.
|
|
46
|
+
*/
|
|
47
|
+
export const STOCK_HARNESS_IMAGE = {
|
|
48
|
+
present: ["/usr/bin/git"],
|
|
49
|
+
passwd: "root:x:0:0:root:/root:/bin/bash\nnode:x:1000:1000::/home/node:/bin/bash\n",
|
|
50
|
+
user: "1000",
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/** Every path the forms' programs may be at: each program in each of the Effector's tool dirs. */
|
|
54
|
+
export function effectorImagePaths(forms: readonly PushForm[]): string[] {
|
|
55
|
+
const out: string[] = [];
|
|
56
|
+
for (const form of new Set(forms)) {
|
|
57
|
+
for (const tool of TOOLS[form]) for (const dir of EFFECTOR_TOOL_DIRS) out.push(`${dir}/${tool}`);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The uid the Effector runs as, from the image's recorded `USER`: a name, a number, or — none
|
|
63
|
+
* declared — ADR-0037's 1000. A `user:group` line decides by its user half. */
|
|
64
|
+
export function effectorSeat(user: string): { name: string } | { uid: number } {
|
|
65
|
+
const head = user.split(":")[0]!.trim();
|
|
66
|
+
if (head === "") return { uid: 1000 };
|
|
67
|
+
if (/^\d+$/.test(head)) return { uid: Number(head) };
|
|
68
|
+
return { name: head };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether /etc/passwd's text holds an entry for the seat. */
|
|
72
|
+
export function passwdHolds(passwd: string, seat: { name: string } | { uid: number }): boolean {
|
|
73
|
+
for (const line of passwd.split("\n")) {
|
|
74
|
+
const fields = line.split(":");
|
|
75
|
+
if (fields.length < 3) continue;
|
|
76
|
+
if ("name" in seat ? fields[0] === seat.name : fields[2] === String(seat.uid)) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Why the image cannot run the forms, with the fix, or undefined. `present` is which of
|
|
83
|
+
* {@link effectorImagePaths} the image holds (links followed); `passwd` its `/etc/passwd`, "" when
|
|
84
|
+
* it has none; `user` its recorded `USER`.
|
|
85
|
+
*/
|
|
86
|
+
export function effectorImageRefusal(input: {
|
|
87
|
+
/** For the message: `Workspace of workflow "w"`. */
|
|
88
|
+
label: string;
|
|
89
|
+
ref: string;
|
|
90
|
+
user: string;
|
|
91
|
+
/** Each form, and the push it is for: `the push of Repo Slot "app"`. */
|
|
92
|
+
forms: ReadonlyArray<{ form: PushForm; push: string }>;
|
|
93
|
+
present: ReadonlySet<string>;
|
|
94
|
+
passwd: string;
|
|
95
|
+
/** The image is the stock Harness image: the fix is an image of the author's, not an install. */
|
|
96
|
+
stock?: boolean;
|
|
97
|
+
}): string | undefined {
|
|
98
|
+
const { label, ref } = input;
|
|
99
|
+
for (const { form, push } of input.forms) {
|
|
100
|
+
for (const tool of TOOLS[form]) {
|
|
101
|
+
if (EFFECTOR_TOOL_DIRS.some((dir) => input.present.has(`${dir}/${tool}`))) continue;
|
|
102
|
+
const why =
|
|
103
|
+
form === "ssh-transport" ? "over ssh" : form === "ssh-sign" ? "signed with an ssh key" : "signed with GPG";
|
|
104
|
+
if (input.stock) {
|
|
105
|
+
return (
|
|
106
|
+
`${label}: ${push} ${why} needs ${tool}, and its Sandbox Effector runs in the stock Harness image ${ref}, ` +
|
|
107
|
+
`which holds git alone — the Workspace names no image and the instance has no images/default; give it an ` +
|
|
108
|
+
`image that holds ${tool} (${PACKAGES[tool]}): an images/default Sandbox Image, the workspace()'s image, ` +
|
|
109
|
+
`or its effector: { image } (ADR-0068)`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
`${label}: ${push} ${why} needs ${tool}, and its Sandbox Effector's image ${ref} holds none in ` +
|
|
114
|
+
`${EFFECTOR_TOOL_DIRS.join(", ")} — the Effector runs jr2's programs from there alone; install it in the ` +
|
|
115
|
+
`image (${PACKAGES[tool]}) (ADR-0068)`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (form !== "ssh-transport") continue;
|
|
119
|
+
const seat = effectorSeat(input.user);
|
|
120
|
+
if (passwdHolds(input.passwd, seat)) continue;
|
|
121
|
+
const who = "name" in seat ? `user ${seat.name}` : `uid ${seat.uid}`;
|
|
122
|
+
const fix =
|
|
123
|
+
"name" in seat
|
|
124
|
+
? `a USER line naming a user the image's /etc/passwd holds`
|
|
125
|
+
: `RUN adduser -D -u ${seat.uid} <name> in the image` +
|
|
126
|
+
(input.user === "" ? " (the image declares no USER, so the Effector runs as uid 1000)" : "");
|
|
127
|
+
return (
|
|
128
|
+
`${label}: ${push} over ssh needs a passwd entry for the ${who} the Sandbox Effector runs as, and its image ` +
|
|
129
|
+
`${ref} has none — ssh exits "No user exists for uid" before it connects; add ${fix} (ADR-0068)`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -14,3 +14,29 @@ export type { KubePort } from "./kube.ts";
|
|
|
14
14
|
// test can issue its own fixtures the same way (the @kind tier's HTTPS model provider).
|
|
15
15
|
export { issueCa, issueLeaf } from "./held-pki.ts";
|
|
16
16
|
export type { Pem } from "./held-pki.ts";
|
|
17
|
+
// The command table (ADR-0072): every verb as data, and the renderers `--help` prints with — what the
|
|
18
|
+
// docs' CLI Reference is generated from, so the help and the site cannot disagree.
|
|
19
|
+
export {
|
|
20
|
+
COMMANDS,
|
|
21
|
+
GLOBAL,
|
|
22
|
+
GLOBAL_OPTIONS,
|
|
23
|
+
HELP_FLAG,
|
|
24
|
+
SECTIONS,
|
|
25
|
+
EXIT_CODES,
|
|
26
|
+
nameOf,
|
|
27
|
+
synopsis,
|
|
28
|
+
renderHelp,
|
|
29
|
+
renderCommand,
|
|
30
|
+
} from "./command-table.ts";
|
|
31
|
+
export type {
|
|
32
|
+
Command,
|
|
33
|
+
CommandName,
|
|
34
|
+
Flag,
|
|
35
|
+
Arg,
|
|
36
|
+
EnvVar,
|
|
37
|
+
Form,
|
|
38
|
+
Output,
|
|
39
|
+
OutputKind,
|
|
40
|
+
GlobalOption,
|
|
41
|
+
Section,
|
|
42
|
+
} from "./command-table.ts";
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// The Instance check (ADR-0050): the typecheck and the Machine walk, which `jr2 up` and `jr2 check`
|
|
2
|
+
// both run through this one function, so the two commands cannot give different answers. It reads
|
|
3
|
+
// the Instance folder alone: no kube context, no cluster, no docker, no install. Each refusal is an
|
|
4
|
+
// `activity` line that starts with `refusing:`, and the function then returns `undefined`. A module
|
|
5
|
+
// that throws when it loads (a refusal of `createMachine`, `defineEvent` or `workspace()`) throws
|
|
6
|
+
// out of `loadWorkflows`, and the CLI's dispatch prints it as one `error:` line, for each command.
|
|
7
|
+
//
|
|
8
|
+
// The order is the order of `jr2 up`: the compiler first, because a wrong name is a type error since
|
|
9
|
+
// ADR-0049; then the walk, which holds the refusals that the compiler cannot make (an Open part, a
|
|
10
|
+
// Chart file, a dispatch, an Effect secret, a Size). The Assembly is not staged here: staging installs
|
|
11
|
+
// the Instance's production dependencies from its lockfile, and its refusals are about that install,
|
|
12
|
+
// so they stay with `jr2 up`.
|
|
13
|
+
|
|
14
|
+
import { readdir } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
chartFileRefusals,
|
|
18
|
+
CUSTOM_PROVIDER_APIS,
|
|
19
|
+
dispatchRefusals,
|
|
20
|
+
doorRefusals,
|
|
21
|
+
loadCharts,
|
|
22
|
+
loadWorkflows,
|
|
23
|
+
machineBodies,
|
|
24
|
+
openPartRefusals,
|
|
25
|
+
partsOf,
|
|
26
|
+
placementWalkOf,
|
|
27
|
+
refuseEffectSecrets,
|
|
28
|
+
resolveSize,
|
|
29
|
+
seatLine,
|
|
30
|
+
serializeMachine,
|
|
31
|
+
splitSize,
|
|
32
|
+
targetText,
|
|
33
|
+
type JR2Config,
|
|
34
|
+
type PlacementWalk,
|
|
35
|
+
type ResolvedSize,
|
|
36
|
+
} from "@jr2/orchestrator/internal";
|
|
37
|
+
import { activity, type Io } from "./output.ts";
|
|
38
|
+
import { tscTypecheck } from "./typecheck.ts";
|
|
39
|
+
|
|
40
|
+
/** What the walk found, for the layers of `jr2 up` that come after it. */
|
|
41
|
+
export type CheckedInstance = {
|
|
42
|
+
/** The registered Workflows, loaded as the Orchestrator loads them. */
|
|
43
|
+
workflows: Awaited<ReturnType<typeof loadWorkflows>>;
|
|
44
|
+
/** The parts that the registered Machines carry. */
|
|
45
|
+
carried: ReturnType<typeof partsOf>;
|
|
46
|
+
/** The placement walk: what names each secret, and where each Harness pod goes. */
|
|
47
|
+
placementWalk: PlacementWalk;
|
|
48
|
+
/** The Size of each `workspace()`, resolved and split as the provision does it. */
|
|
49
|
+
sizes: Array<{ workflow: string; size: ResolvedSize }>;
|
|
50
|
+
/** The Chart files under `workflows/`, as `<wrapper>/<name>`. */
|
|
51
|
+
chartFiles: string[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run the typecheck and the Machine walk of the Instance at `root`. Returns what the walk found, or
|
|
56
|
+
* `undefined` after a `refusing:` line. A module that fails to load throws.
|
|
57
|
+
*/
|
|
58
|
+
export async function checkInstance(io: Io, root: string, config: JR2Config): Promise<CheckedInstance | undefined> {
|
|
59
|
+
// --- typecheck (ADR-0050): the compiler agrees the folder is coherent, before anything is spent -
|
|
60
|
+
// A REFUSAL, not a warning. `jr2 up` runs it before the namespace apply and before the builds:
|
|
61
|
+
// the names a Machine carries (an Agent slot, a composed Machine, a Repo Slot, a `customize()` of
|
|
62
|
+
// any of them) are typed since ADR-0049/0051, so the answer here is the same answer the author's
|
|
63
|
+
// editor gives — and a converge that shipped a Machine the compiler rejects would surface it as
|
|
64
|
+
// an invoke-time failure mid-run, minutes and three image builds later.
|
|
65
|
+
activity(io, "typecheck: tsc --noEmit (the instance's own compiler)");
|
|
66
|
+
const checked = await (io.typecheck ?? tscTypecheck)(root);
|
|
67
|
+
if (!checked.ok) {
|
|
68
|
+
activity(io, "refusing: this instance does not typecheck — nothing was built and nothing was applied");
|
|
69
|
+
for (const line of checked.output.split("\n")) activity(io, ` ${line}`);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
// A custom provider's wire protocol, from the Harness's own list (`CUSTOM_PROVIDER_APIS`): the
|
|
73
|
+
// Harness refuses any other at boot, and a check pod would only crash on it (ADR-0018).
|
|
74
|
+
const api = config.harness?.provider?.api;
|
|
75
|
+
if (api !== undefined && !CUSTOM_PROVIDER_APIS.includes(api)) {
|
|
76
|
+
activity(
|
|
77
|
+
io,
|
|
78
|
+
`refusing: harness.provider.api is "${api}" — the Harness speaks: ${CUSTOM_PROVIDER_APIS.join(", ")} (ADR-0018)`,
|
|
79
|
+
);
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- the Machine walk (ADR-0049, ADR-0051) -----------------------------------------------------
|
|
84
|
+
// A Machine carries its parts, so the check LOADS the instance's registered Workflows — the
|
|
85
|
+
// same discovery and module contract the Orchestrator boots with — and walks them. The walk
|
|
86
|
+
// returns what `jr2 up` uses after the check: the Agent definitions whose models are preflighted
|
|
87
|
+
// (ADR-0018), any `workspace: "none"`, for which `jr2 up` converges the Instance Harness
|
|
88
|
+
// (ADR-0031), every `file:` docker context a `workspace()` names, which `jr2 up` builds
|
|
89
|
+
// (ADR-0037), every bound Repo, whose ssh key `jr2 up` asks for and whose resource the
|
|
90
|
+
// Orchestrator creates at boot (ADR-0051), and whether any Machine composes a Sandbox at all —
|
|
91
|
+
// the data-plane switch. The walk comes right after the gate, and `jr2 up` runs the check before
|
|
92
|
+
// ownership, because the walk holds REFUSALS the compiler cannot make: a part left Open on a
|
|
93
|
+
// registered Machine — a Repo Slot with no url, an Agent with no model (ADR-0054) — and a zod
|
|
94
|
+
// `input` on a Machine that `jr2Setup` did not make, which no door reads (ADR-0033). Nothing
|
|
95
|
+
// about Agents is published, because the definition rides each Turn.
|
|
96
|
+
const workflows = await loadWorkflows(root);
|
|
97
|
+
const carried = partsOf(workflows.map((w) => w.machine));
|
|
98
|
+
const { agents } = carried;
|
|
99
|
+
// The Machine is named by where it sits under the registered root — the slot chain a
|
|
100
|
+
// `customize()` walks — and the fix line nests the same way, so it pastes into
|
|
101
|
+
// `workflows/<name>.ts` with the imported Machine and the url filled in.
|
|
102
|
+
// Every Open part is refused in ONE listing, in walk order within each kind: a composer fixing a
|
|
103
|
+
// packaged Machine fixes all of them in the same edit, and a converge that refused the Repo Slot,
|
|
104
|
+
// then the Agent one run later, would cost them two rounds to learn one thing. The listing is the
|
|
105
|
+
// door's own (ADR-0069): the Orchestrator refuses a publish with the same lines, so the host walk
|
|
106
|
+
// and the door's walk cannot drift.
|
|
107
|
+
// A zod `input` that no door reads (ADR-0033) is in the same listing, first: the door refuses it
|
|
108
|
+
// with the same line.
|
|
109
|
+
const open = [...doorRefusals(workflows), ...openPartRefusals(workflows)];
|
|
110
|
+
if (open.length) {
|
|
111
|
+
for (const line of open) activity(io, `refusing: ${line}`);
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
// Each Chart file is admitted HERE by the door's own check (ADR-0070), so a Chart file the
|
|
115
|
+
// Orchestrator would refuse is refused at the same command as `tsc`'s errors, before any
|
|
116
|
+
// cluster work, with the lines the publish would answer.
|
|
117
|
+
const charts = await loadCharts(root, workflows, (line) => activity(io, `warning: ${line}`));
|
|
118
|
+
const chartRefusals = chartFileRefusals(charts, workflows);
|
|
119
|
+
if (chartRefusals.length) {
|
|
120
|
+
for (const line of chartRefusals) activity(io, `refusing: ${line}`);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
// What each same-Instance dispatch names resolves in this Assembly, and its slot schema matches
|
|
124
|
+
// the callee's door (ADR-0071): the door's own check, so the publish would refuse the same lines.
|
|
125
|
+
// The host cannot see the Orchestrator's volume, so a named Chart that is no Chart file here is a
|
|
126
|
+
// warning, which the door decides.
|
|
127
|
+
const dispatched = dispatchRefusals(workflows, charts);
|
|
128
|
+
for (const line of dispatched.warnings) activity(io, `warning: ${line}`);
|
|
129
|
+
if (dispatched.errors.length) {
|
|
130
|
+
for (const line of dispatched.errors) activity(io, `refusing: ${line}`);
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
// What each registered Machine runs outside itself (ADR-0067, ADR-0068): every Effect slot, with
|
|
134
|
+
// the plain data it carries — a command's argv, its timeout, the Repo Slot it runs in; an http
|
|
135
|
+
// Effect's method, its fixed target and the secrets it names — read, never run.
|
|
136
|
+
for (const w of workflows) {
|
|
137
|
+
const effects = partsOf([w.machine]).effects;
|
|
138
|
+
if (!effects.length) continue;
|
|
139
|
+
activity(io, `effects: workflow "${w.name}" — what it runs outside itself (ADR-0067)`);
|
|
140
|
+
for (const e of effects) {
|
|
141
|
+
const where = e.path?.length ? ` (on ${e.path.map((k) => `"${k}"`).join(" → ")})` : "";
|
|
142
|
+
const slot = "slot" in e && e.slot !== undefined ? `, in Repo Slot "${e.slot}"` : "";
|
|
143
|
+
if (e.kind === "http") {
|
|
144
|
+
const names = e.secrets.length ? `, secrets ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
|
|
145
|
+
// Its body decides where it runs (ADR-0067): a Worktree file needs its Workspace's Sandbox Effector.
|
|
146
|
+
const where2 = e.file
|
|
147
|
+
? `in the Sandbox Effector, a Worktree file as its body${slot}`
|
|
148
|
+
: "in the Instance Effector";
|
|
149
|
+
activity(
|
|
150
|
+
io,
|
|
151
|
+
` ${e.name}${where}: http ${e.method} ${e.target ? targetText({ target: e.target }) : "<open>"} — ${e.timeoutMs / 1000}s, ` +
|
|
152
|
+
`${where2}${names}${e.idempotent ? ", idempotent" : ""}`,
|
|
153
|
+
);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (e.kind === "dispatch") {
|
|
157
|
+
// Where it runs is read from the target (ADR-0071): none is this Orchestrator, in-process.
|
|
158
|
+
const to = e.target ? targetText({ target: e.target }) : e.open ? "<open>" : "this Orchestrator";
|
|
159
|
+
const names = e.secrets.length ? `, token ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
|
|
160
|
+
const where2 = e.placement === "instance" ? "in the Instance Effector" : "in the Orchestrator's own process";
|
|
161
|
+
activity(
|
|
162
|
+
io,
|
|
163
|
+
` ${e.name}${where}: dispatch ${e.workflow}${e.wait ? " wait" : ""} → ${to} — ${e.timeoutMs / 1000}s, ` +
|
|
164
|
+
`${where2}${names}`,
|
|
165
|
+
);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (e.kind === "push") {
|
|
169
|
+
const opts = (["pin", "sign", "squash", "force"] as const).filter((k) => e[k]);
|
|
170
|
+
activity(
|
|
171
|
+
io,
|
|
172
|
+
` ${e.name}${where}: push${opts.length ? ` (${opts.join(", ")})` : ""} — ${e.timeoutMs / 1000}s, ` +
|
|
173
|
+
`in the Sandbox Effector${slot}`,
|
|
174
|
+
);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!("argv" in e)) continue;
|
|
178
|
+
const names = e.secrets?.length ? `, secrets ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
|
|
179
|
+
const seat = e.secrets?.length ? `the Sandbox Effector${e.pin ? ", pinned" : ""}` : "the Harness";
|
|
180
|
+
activity(
|
|
181
|
+
io,
|
|
182
|
+
` ${e.name}${where}: ${e.kind} ${JSON.stringify(e.argv)} — ${e.timeoutMs / 1000}s, in ${seat}${slot}${names}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Each state that can fail the run (ADR-0073): every state that invokes a `fail()` slot, in the
|
|
187
|
+
// registered Machine and in each Machine it composes, read, never run — so an author sees which
|
|
188
|
+
// ends of a Workflow are `failed` and not `done` before a run.
|
|
189
|
+
for (const w of workflows) {
|
|
190
|
+
const failures = partsOf([w.machine]).failures;
|
|
191
|
+
if (!failures.length) continue;
|
|
192
|
+
activity(io, `failures: workflow "${w.name}" — the states that fail the run (ADR-0073)`);
|
|
193
|
+
for (const f of failures) {
|
|
194
|
+
const where = f.path?.length ? ` (on ${f.path.map((k) => `"${k}"`).join(" → ")})` : "";
|
|
195
|
+
activity(io, ` ${f.state}${where}: invokes ${f.slot}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// An Effect that names a secret the `secrets` list does not hold, and a literal Effect secret too
|
|
199
|
+
// short to scrub (ADR-0068), are refused HERE, off the walk and the config alone — before anything is
|
|
200
|
+
// built or applied (ADR-0050, ADR-0068). `jr2 up` places a secret read from the user's Secret
|
|
201
|
+
// after the check, once it reaches the cluster.
|
|
202
|
+
// What names each secret, read off the walk (ADR-0059, ADR-0068): every Effect, every Workspace's
|
|
203
|
+
// pod — its `workspace()`'s `secrets` and the models of the Agents whose Turns run there — and the
|
|
204
|
+
// models of the `"none"` Agents the Instance Harness hosts.
|
|
205
|
+
// An Open body that allows "none" needs the Instance Harness, holding a key for each model it
|
|
206
|
+
// allows (ADR-0070), as a "none" Agent does. The door's walk says both, so `jr2 up` and the
|
|
207
|
+
// publish door place the same set.
|
|
208
|
+
const placementWalk = placementWalkOf(workflows);
|
|
209
|
+
refuseEffectSecrets(config, placementWalk);
|
|
210
|
+
// Who may send what, per state (ADR-0015): the audience rule's answer for each registered
|
|
211
|
+
// Machine and every Machine it composes, as its doc carries it — so an author reads who holds a
|
|
212
|
+
// pick before a run finds out. Then what the build found legal and suspect: a warning, never a
|
|
213
|
+
// refusal (the refusals threw when the Machine loaded).
|
|
214
|
+
for (const w of workflows) {
|
|
215
|
+
const bodies = machineBodies(serializeMachine(w.name, w.machine));
|
|
216
|
+
const seats = bodies.flatMap((body) => body.seats.map(seatLine));
|
|
217
|
+
if (seats.length) {
|
|
218
|
+
activity(io, `seats: workflow "${w.name}" — who may send what, per state (ADR-0015)`);
|
|
219
|
+
for (const line of seats) activity(io, ` ${line}`);
|
|
220
|
+
}
|
|
221
|
+
for (const body of bodies) {
|
|
222
|
+
for (const warning of body.warnings) activity(io, `warning: workflow "${w.name}": ${warning} (ADR-0015)`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// The Size (ADR-0060): every `workspace()` a registered Machine composes, resolved down its chain
|
|
226
|
+
// (the Machine's, then `sandbox.resources`, then the kit's) and split inside the pod, exactly as
|
|
227
|
+
// the provision will. A split that leaves the Harness below its floor is refused HERE, naming the
|
|
228
|
+
// Machine and the number to change, rather than at the first provision of a run. The check
|
|
229
|
+
// returns the resolved Sizes, and `jr2 up` uses them for its Sandbox-node warning.
|
|
230
|
+
const sizes: Array<{ workflow: string; size: ResolvedSize }> = [];
|
|
231
|
+
for (const w of workflows) {
|
|
232
|
+
for (const { path, resources, user } of partsOf([w.machine]).sizes) {
|
|
233
|
+
try {
|
|
234
|
+
const size = resolveSize(resources, config.sandbox?.resources);
|
|
235
|
+
splitSize(size, user);
|
|
236
|
+
sizes.push({ workflow: w.name, size });
|
|
237
|
+
} catch (e) {
|
|
238
|
+
const where =
|
|
239
|
+
path !== undefined && path.length
|
|
240
|
+
? ` (on the Machine composed as ${path.map((k) => `"${k}"`).join(" → ")})`
|
|
241
|
+
: "";
|
|
242
|
+
const fix =
|
|
243
|
+
path === undefined
|
|
244
|
+
? ""
|
|
245
|
+
: `; a composer retunes it where the Machine is registered: export const machine = ${sizeLine(path)}`;
|
|
246
|
+
activity(io, `refusing: workflow "${w.name}"${where}: ${(e as Error).message}${fix}`);
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const agentNames = [...new Set(agents.map((a) => a.name))].join(", ") || "(none)";
|
|
252
|
+
activity(io, `agents: ${agentNames} (carried by ${workflows.length} workflow machine(s))`);
|
|
253
|
+
// Each Chart wrapper (ADR-0070): what its Open body lends a Chart, how many models a Chart's own
|
|
254
|
+
// Agents may name (`jr2 up` places a key for each), and the Chart files the Assembly carries for
|
|
255
|
+
// it. The walk reads the Open body's declaration, so the keys and the Instance Harness a Chart
|
|
256
|
+
// could need are placed before any run.
|
|
257
|
+
const chartFiles = await chartFilesIn(root);
|
|
258
|
+
for (const w of workflows) {
|
|
259
|
+
const bodies = partsOf([w.machine]).openBodies.filter((b) => b.path !== undefined && b.path.length === 0);
|
|
260
|
+
if (!bodies.length) continue;
|
|
261
|
+
const lends = [
|
|
262
|
+
...new Set(
|
|
263
|
+
bodies.flatMap(({ declaration: { lends } }) => [
|
|
264
|
+
...(lends.agents ?? []),
|
|
265
|
+
...(lends.effects ?? []),
|
|
266
|
+
...(lends.machines ?? []),
|
|
267
|
+
...(lends.guards ?? []),
|
|
268
|
+
...(lends.actions ?? []),
|
|
269
|
+
...(lends.delays ?? []),
|
|
270
|
+
]),
|
|
271
|
+
),
|
|
272
|
+
];
|
|
273
|
+
const models = new Set(bodies.flatMap((b) => b.declaration.allows.models));
|
|
274
|
+
const files = chartFiles.filter((n) => n.startsWith(`${w.name}/`)).length;
|
|
275
|
+
activity(
|
|
276
|
+
io,
|
|
277
|
+
`chart wrapper "${w.name}": lends ${lends.join(", ") || "nothing"}; allows ${models.size} model(s); ${files} Chart file(s)`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return { workflows, carried, placementWalk, sizes, chartFiles };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The `customize` line that retunes a composed Machine's Size (ADR-0060), nested through `actors`
|
|
285
|
+
* the way `customize()` accepts — the Size twin of `customizeLine`, so the refusal pastes.
|
|
286
|
+
*/
|
|
287
|
+
function sizeLine(path: readonly string[]): string {
|
|
288
|
+
const binds = '{ resources: { limits: { memory: "<memory>" } } }';
|
|
289
|
+
return `customize(<import>, ${path.reduceRight((parts, key) => `{ actors: { ${key}: ${parts} } }`, binds)})`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The Chart files under an Assembly root (ADR-0070), as Workflow names `<wrapper>/<name>`: each
|
|
294
|
+
* `workflows/<wrapper>/<name>.chart.json`, one level below the flat `workflows/*.ts`, in a directory
|
|
295
|
+
* named after its wrapper. The layout the Orchestrator's discovery reads (charts.ts); a directory
|
|
296
|
+
* whose name starts with `_` or `.` is not a wrapper's.
|
|
297
|
+
*/
|
|
298
|
+
export async function chartFilesIn(root: string): Promise<string[]> {
|
|
299
|
+
const out: string[] = [];
|
|
300
|
+
const dir = join(root, "workflows");
|
|
301
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
302
|
+
for (const entry of entries) {
|
|
303
|
+
if (!entry.isDirectory() || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
|
|
304
|
+
const files = await readdir(join(dir, entry.name)).catch(() => [] as string[]);
|
|
305
|
+
for (const f of files)
|
|
306
|
+
if (f.endsWith(".chart.json")) out.push(`${entry.name}/${f.slice(0, -".chart.json".length)}`);
|
|
307
|
+
}
|
|
308
|
+
return out.sort();
|
|
309
|
+
}
|
package/src/instance.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// - Every resolution prints its target on stderr, so ambient-context drift stays visible.
|
|
15
15
|
|
|
16
16
|
import { basename } from "node:path";
|
|
17
|
-
import { loadConfig } from "@jr2/orchestrator";
|
|
17
|
+
import { loadConfig } from "@jr2/orchestrator/internal";
|
|
18
18
|
import { INSTANCE_SECRET, kubectlKube, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "./kube.ts";
|
|
19
19
|
import { assertKitVersion } from "./kit-version.ts";
|
|
20
20
|
import { activity, type Io } from "./output.ts";
|
|
@@ -43,14 +43,8 @@ export type TargetOptions = {
|
|
|
43
43
|
context?: string;
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
url: { type: "string" },
|
|
49
|
-
namespace: { type: "string", short: "n" },
|
|
50
|
-
context: { type: "string" },
|
|
51
|
-
} as const;
|
|
52
|
-
|
|
53
|
-
/** Pull the target-addressing flags out of a strict:false parseArgs `values` bag. */
|
|
46
|
+
/** Pull the target-addressing flags (the command table's shared `-n`, `--context`, `--url`) out of
|
|
47
|
+
* a strict:false parseArgs `values` bag. */
|
|
54
48
|
export function targetOptions(values: Record<string, unknown>): TargetOptions {
|
|
55
49
|
return {
|
|
56
50
|
url: values.url as string | undefined,
|
package/src/kit-version.ts
CHANGED
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
// one, the bundle baked the other, and the CLI's own code handled config objects built by a
|
|
5
5
|
// different copy of the same classes. Now the orchestrator is the CLI's PEER (one copy can exist),
|
|
6
6
|
// and every Instance verb asks this module whether the copy the CLI resolves IS the copy the
|
|
7
|
-
// Instance resolves: same REAL path
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// Instance resolves: same REAL path. Same file means same version by construction under npm
|
|
8
|
+
// hoisting, pnpm dedup, and checkout symlinks alike — and identity is exactly the property the
|
|
9
|
+
// class-identity bug needs. One copy is not yet one Kit version: an Instance that pins only
|
|
10
|
+
// `@jr2/orchestrator` at a new number gets a warning from npm (`ERESOLVE overriding peer
|
|
11
|
+
// dependency`), not an error, because the root's own pin wins over a peer, and the one hoisted
|
|
12
|
+
// copy is then the CLI's peer too. So the check also asks that copy's version to be the CLI's own:
|
|
13
|
+
// one release train (ADR-0055) carries one number.
|
|
10
14
|
//
|
|
11
15
|
// Builtins only: the launcher (`bin/jr2.js`) resolves through here before any kit import.
|
|
12
16
|
|
|
@@ -59,11 +63,17 @@ export const CLI_VERSION = (JSON.parse(readFileSync(join(CLI_ROOT, "package.json
|
|
|
59
63
|
const ORCHESTRATOR = "@jr2/orchestrator";
|
|
60
64
|
const CLI = "@jr2/cli";
|
|
61
65
|
|
|
66
|
+
/** The CLI a check is for: this one, unless a test names another copy. */
|
|
67
|
+
export type CliCopy = { root: string; version: string };
|
|
68
|
+
|
|
69
|
+
const THIS_CLI: CliCopy = { root: CLI_ROOT, version: CLI_VERSION };
|
|
70
|
+
|
|
62
71
|
/**
|
|
63
72
|
* The ADR-0056 question, answered rather than asserted: what the Instance at `root` resolves
|
|
64
|
-
* `@jr2/orchestrator` to, what this CLI resolves it to, and whether they are one copy
|
|
65
|
-
* real-path identity
|
|
66
|
-
* goes through {@link assertKitVersion}, which turns a not-ok answer into the
|
|
73
|
+
* `@jr2/orchestrator` to, what this CLI resolves it to, and whether they are one copy at the CLI's
|
|
74
|
+
* own version. `ok` is the real-path identity AND that version. `jr2 version` prints this; every
|
|
75
|
+
* other Instance verb goes through {@link assertKitVersion}, which turns a not-ok answer into the
|
|
76
|
+
* refusal.
|
|
67
77
|
*/
|
|
68
78
|
export type KitCheck = {
|
|
69
79
|
/** The Instance's own resolution — its Kit version; absent when nothing resolves from `root`. */
|
|
@@ -76,14 +86,14 @@ export type KitCheck = {
|
|
|
76
86
|
ok: boolean;
|
|
77
87
|
};
|
|
78
88
|
|
|
79
|
-
export function checkKitVersion(root: string): KitCheck {
|
|
89
|
+
export function checkKitVersion(root: string, cli: CliCopy = THIS_CLI): KitCheck {
|
|
80
90
|
const instance = resolvePackage(ORCHESTRATOR, root);
|
|
81
|
-
const own = resolvePackage(ORCHESTRATOR,
|
|
91
|
+
const own = resolvePackage(ORCHESTRATOR, cli.root);
|
|
82
92
|
return {
|
|
83
93
|
instance,
|
|
84
94
|
own,
|
|
85
95
|
pinned: pinnedVersion(root, ORCHESTRATOR),
|
|
86
|
-
ok: instance !== undefined && own !== undefined && instance.root === own.root,
|
|
96
|
+
ok: instance !== undefined && own !== undefined && instance.root === own.root && own.version === cli.version,
|
|
87
97
|
};
|
|
88
98
|
}
|
|
89
99
|
|
|
@@ -106,13 +116,13 @@ export function pinnedVersion(root: string, name: string): string | undefined {
|
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
/**
|
|
109
|
-
* Refuse unless the Instance at `root` and this CLI resolve the SAME `@jr2/orchestrator
|
|
110
|
-
* message names
|
|
111
|
-
* told to add one (the launcher then hands off to it); one that has it is
|
|
112
|
-
* at one number.
|
|
119
|
+
* Refuse unless the Instance at `root` and this CLI resolve the SAME `@jr2/orchestrator`, at the
|
|
120
|
+
* CLI's own version. The message names the versions and the lines to edit: an Instance with no
|
|
121
|
+
* `@jr2/cli` of its own is told to add one (the launcher then hands off to it); one that has it is
|
|
122
|
+
* told to pin both lines at one number.
|
|
113
123
|
*/
|
|
114
|
-
export function assertKitVersion(root: string): void {
|
|
115
|
-
const { instance, own, ok } = checkKitVersion(root);
|
|
124
|
+
export function assertKitVersion(root: string, cli: CliCopy = THIS_CLI): void {
|
|
125
|
+
const { instance, own, ok } = checkKitVersion(root, cli);
|
|
116
126
|
if (ok) return;
|
|
117
127
|
|
|
118
128
|
const manifest = join(root, "package.json");
|
|
@@ -127,7 +137,7 @@ export function assertKitVersion(root: string): void {
|
|
|
127
137
|
? `${ORCHESTRATOR} resolves to ${instance.version} (${instance.root})`
|
|
128
138
|
: `${ORCHESTRATOR} does not resolve from ${root}`;
|
|
129
139
|
const self = own
|
|
130
|
-
? `this jr2 (${
|
|
131
|
-
: `this jr2 (${
|
|
140
|
+
? `this jr2 (${cli.version}) runs against ${own.version} (${own.root})`
|
|
141
|
+
: `this jr2 (${cli.version}, at ${cli.root}) has no ${ORCHESTRATOR} beside it`;
|
|
132
142
|
throw new Error(`Kit version mismatch — an Instance has one (ADR-0056):\n ${have}\n ${self}\n ${fix}`);
|
|
133
143
|
}
|