@jr2/cli 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 +18 -0
- package/bin/jr2.js +38 -0
- package/manifests/operator.yaml +6669 -0
- package/package.json +56 -0
- package/src/build.ts +1551 -0
- package/src/cli.ts +110 -0
- package/src/client.ts +219 -0
- package/src/commands/down.ts +104 -0
- package/src/commands/gc.ts +73 -0
- package/src/commands/init.ts +238 -0
- package/src/commands/kit.ts +141 -0
- package/src/commands/logs.ts +50 -0
- package/src/commands/run.ts +64 -0
- package/src/commands/runs.ts +19 -0
- package/src/commands/send.ts +83 -0
- package/src/commands/status.ts +90 -0
- package/src/commands/up.ts +1402 -0
- package/src/deploy.ts +592 -0
- package/src/env.ts +68 -0
- package/src/index.ts +11 -0
- package/src/instance.ts +105 -0
- package/src/kube.ts +809 -0
- package/src/nodes.ts +74 -0
- package/src/output.ts +211 -0
- package/src/repo-sweep.ts +134 -0
- package/src/run-id.ts +85 -0
- package/src/sse.ts +41 -0
- package/src/sweep.ts +232 -0
- package/src/typecheck.ts +75 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// `jr2 init [dir] [--name <n>]` (ADR-0009): scaffold a new instance folder — the minimum `jr2 up`
|
|
2
|
+
// can converge and `jr2 run` can drive. v1 scope: the root marker (`jr2.config.ts`), a package.json, a
|
|
3
|
+
// `tsconfig.json` the instance typechecks against (an editor's language service reads it, and so
|
|
4
|
+
// does `jr2 up`, which refuses to converge a folder the compiler rejects — ADR-0050), a
|
|
5
|
+
// `.gitignore` for the runtime `.jr2/`, ONE starter workflow (`ping`) that runs end-to-end with no
|
|
6
|
+
// Workspace/Agent — a small "respond directly" Machine to trim down and build on — and
|
|
7
|
+
// `images/default/Dockerfile`, the Sandbox Image every Workspace falls back to (ADR-0037).
|
|
8
|
+
//
|
|
9
|
+
// What it does NOT scaffold is as deliberate: there is no `agents/` folder and no `images/` scan,
|
|
10
|
+
// because a Machine carries its own Agents and Sandbox Images (ADR-0049) and only what the CLI
|
|
11
|
+
// names by string is discovered from files (ADR-0050). `workflows/` is the one discovered folder.
|
|
12
|
+
//
|
|
13
|
+
// Templates mirror `templates/default/` verbatim (that folder is the model instance, ADR-0054) —
|
|
14
|
+
// byte-for-byte except package.json's `name`/`description`, which are per-instance.
|
|
15
|
+
// `test/init.test.ts` enforces that; without it the two drift silently, and since the manifest
|
|
16
|
+
// carries KIT_VERSION that same test is the version-bump tripwire (bump the kit, re-render the
|
|
17
|
+
// template). Existing files are left untouched (init is additive); created paths are reported on
|
|
18
|
+
// stderr.
|
|
19
|
+
//
|
|
20
|
+
// ONE template serves both checkout and installed mode (ADR-0043) — a branch there would mean the
|
|
21
|
+
// tested output and the shipped output diverge. So the scaffold names no package manager, and pins
|
|
22
|
+
// @jr2/* at the exact running KIT_VERSION: 0.x minors break, and the checkout resolves that literal
|
|
23
|
+
// to its own packages via `linkWorkspacePackages: true`.
|
|
24
|
+
|
|
25
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
26
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
27
|
+
import { parseArgs } from "node:util";
|
|
28
|
+
import { KIT_VERSION } from "@jr2/orchestrator";
|
|
29
|
+
import { activity, type Io } from "../output.ts";
|
|
30
|
+
|
|
31
|
+
export async function init(args: string[], io: Io): Promise<number> {
|
|
32
|
+
const { values, positionals } = parseArgs({
|
|
33
|
+
args,
|
|
34
|
+
allowPositionals: true,
|
|
35
|
+
strict: false,
|
|
36
|
+
options: { name: { type: "string" } },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const dir = positionals[0] ? resolve(io.cwd, String(positionals[0])) : io.cwd;
|
|
40
|
+
const name = (values.name as string | undefined) ?? basename(dir);
|
|
41
|
+
|
|
42
|
+
const files: Array<{ path: string; content: string }> = [
|
|
43
|
+
{ path: "package.json", content: packageJson(name) },
|
|
44
|
+
{ path: "tsconfig.json", content: TSCONFIG_JSON },
|
|
45
|
+
{ path: "jr2.config.ts", content: CONFIG_TS },
|
|
46
|
+
{ path: ".gitignore", content: GITIGNORE },
|
|
47
|
+
{ path: join("workflows", "ping.ts"), content: PING_TS },
|
|
48
|
+
{ path: join("images", "default", "Dockerfile"), content: IMAGE_DOCKERFILE },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
activity(io, `jr2 init — scaffolding ${name} in ${dir}`);
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const full = join(dir, file.path);
|
|
54
|
+
if (await exists(full)) {
|
|
55
|
+
activity(io, ` skip ${file.path} (exists)`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
await mkdir(dirname(full), { recursive: true });
|
|
59
|
+
await writeFile(full, file.content);
|
|
60
|
+
activity(io, ` create ${file.path}`);
|
|
61
|
+
}
|
|
62
|
+
activity(io, "done — install dependencies (npm, pnpm, or bun), then `jr2 up` and `jr2 run ping`");
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function exists(path: string): Promise<boolean> {
|
|
67
|
+
try {
|
|
68
|
+
await readFile(path);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The scaffold declares the tool every script it writes runs. `typescript` is the one that had to
|
|
76
|
+
// be learned the hard way, and `jr2 up` now RUNS this compiler as a converge gate (ADR-0050), so it
|
|
77
|
+
// is load-bearing twice over: the scaffold ships a `typecheck` script but named no compiler, so `tsc`
|
|
78
|
+
// resolved to whatever happened to be hoisted — in an installed instance that is `@jr2/cli`'s own
|
|
79
|
+
// transitive `ts-blank-space` → `typescript`, which floats across MAJORS. A scaffolded folder
|
|
80
|
+
// checked its kit's sources with a compiler the kit never ran, and reported ~120 errors in
|
|
81
|
+
// @jr2/orchestrator that the kit's own gate does not see. The range is the kit's own (ADR-0043's
|
|
82
|
+
// rule for `@jr2/*`, applied to the checker): the instance's PROGRAM includes the kit's `.ts`
|
|
83
|
+
// sources — zero-build, `exports` point at source — so the compiler is part of the contract, not
|
|
84
|
+
// the user's choice, and it moves when the kit moves.
|
|
85
|
+
function packageJson(name: string): string {
|
|
86
|
+
return `${JSON.stringify(
|
|
87
|
+
{
|
|
88
|
+
name,
|
|
89
|
+
version: "0.0.0",
|
|
90
|
+
private: true,
|
|
91
|
+
type: "module",
|
|
92
|
+
scripts: { typecheck: "tsc --noEmit" },
|
|
93
|
+
dependencies: { "@jr2/orchestrator": KIT_VERSION, xstate: "^5.18.0" },
|
|
94
|
+
devDependencies: { "@jr2/cli": KIT_VERSION, "@types/node": "^26.0.1", typescript: "^5.6.0" },
|
|
95
|
+
},
|
|
96
|
+
null,
|
|
97
|
+
2,
|
|
98
|
+
)}\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The instance's compiler options live in @jr2/orchestrator, not here: a scaffolded folder resolves
|
|
102
|
+
// them from node_modules, so they arrive with the dependency and stay in step with the engine.
|
|
103
|
+
const TSCONFIG_JSON = `{
|
|
104
|
+
"extends": "@jr2/orchestrator/tsconfig.instance.json",
|
|
105
|
+
"include": ["**/*.ts"]
|
|
106
|
+
}
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
const CONFIG_TS = `import { defineConfig } from "@jr2/orchestrator";
|
|
110
|
+
|
|
111
|
+
export default defineConfig({
|
|
112
|
+
git: {
|
|
113
|
+
// The fence (ADR-0051): a per-run repo url — run input, a ticket field — must match an entry here or the
|
|
114
|
+
// attach refuses it, so nothing can spend this cluster's credential against an arbitrary host. \`*\` is
|
|
115
|
+
// today's two implicit defaults made visible (JR2_GIT_TOKEN from .env for https, the jr2-git-ssh Secret
|
|
116
|
+
// for ssh). Narrow it to your hosts (\`match: "github.com/yourorg/"\`) before anything untrusted can start
|
|
117
|
+
// a run. The longest match wins; the url's scheme picks token vs sshKey.
|
|
118
|
+
credentials: [{ match: "*", token: "JR2_GIT_TOKEN", sshKey: "jr2-git-ssh" }],
|
|
119
|
+
},
|
|
120
|
+
// Where Sandboxes land (ADR-0052): by default wherever an ordinary pod lands — not cordoned, no taint — and
|
|
121
|
+
// the Repo cache agent follows the same set. No node label is needed. To admit a tainted pool or narrow to
|
|
122
|
+
// a labeled one, name it in raw pod-spec shapes; \`jr2 up\` reports the Sandbox nodes it sees.
|
|
123
|
+
// sandbox: { nodeSelector: { pool: "agents" }, tolerations: [{ key: "gpu", operator: "Exists" }] },
|
|
124
|
+
});
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
// The scaffolded Sandbox Image (ADR-0037). Scaffolding it is the point: `images/default` is the
|
|
128
|
+
// middle leg of the resolution chain (a `workspace()`'s `image` option → `images/default` → the
|
|
129
|
+
// stock Harness), so writing it out makes the fallback a visible convention rather than magic — and
|
|
130
|
+
// it is the ONE path jr2 still checks by convention, so a local Machine never has to spell
|
|
131
|
+
// `import.meta.resolve("../images/default")` for its own instance's toolchain. It has
|
|
132
|
+
// ZERO jr2 knowledge by contract — no ARG, no `FROM jr2-harness`, nothing about /opt/jr2 — because jr2
|
|
133
|
+
// injects its runtime at POD time and never builds a stage on top of this file. It also satisfies
|
|
134
|
+
// the floor by construction — a glibc base with git — so the `preflight` init step that proves it
|
|
135
|
+
// at the pod's first provision passes without the author ever meeting it.
|
|
136
|
+
const IMAGE_DOCKERFILE = `# A Sandbox Image (ADR-0037): the tools your agents can reach, and the shell you get when you
|
|
137
|
+
# \`kubectl exec\` into a running Workspace. This file is yours — jr2 never rewrites it and reads
|
|
138
|
+
# nothing out of it.
|
|
139
|
+
#
|
|
140
|
+
# The build context is THIS directory (\`images/default/\`), so \`COPY\` paths are relative to it and
|
|
141
|
+
# nothing outside it can invalidate the image's content hash.
|
|
142
|
+
#
|
|
143
|
+
# jr2 never builds on top of this file. Your image is run byte-for-byte: jr2 mounts its own runtime
|
|
144
|
+
# at /opt/jr2 when the pod starts and overrides the container's COMMAND to launch the Harness from
|
|
145
|
+
# there, appending (never prepending) /opt/jr2/bin to PATH — so a toolchain YOU pin wins. Your
|
|
146
|
+
# \`USER\` and \`HOME\` are respected, dotfiles included; declare neither and the pod runs uid 1000
|
|
147
|
+
# with HOME=/home/jr2. Your \`ENTRYPOINT\`/\`CMD\` simply do not run — a container has one command and
|
|
148
|
+
# the Harness must own it. A process you need running anyway gets its own seat: the User Container
|
|
149
|
+
# (\`user:\` on \`workspace()\`, ADR-0005).
|
|
150
|
+
#
|
|
151
|
+
# Two things jr2 cannot vendor, both proven by \`jr2 up\` before it converges:
|
|
152
|
+
# - \`git\` — the agent clones, worktrees, and commits with the git you chose;
|
|
153
|
+
# - a glibc base no older than jr2's node. alpine/musl cannot run it at all.
|
|
154
|
+
#
|
|
155
|
+
# This one folder is a path convention; there is no \`images/\` scan (ADR-0049/0050). A SECOND Sandbox
|
|
156
|
+
# Image is a docker context that travels with the Machine that names it — put the folder beside the
|
|
157
|
+
# workflow module and pass \`image: import.meta.resolve("./my-image")\` to \`workspace()\`, and \`jr2 up\`
|
|
158
|
+
# finds it by walking the registered Machines. \`image\` also takes a registry REF (anything that is
|
|
159
|
+
# not a \`file:\` URL) for an image you baked and host yourself, which jr2 never builds. This instance
|
|
160
|
+
# builds it once a registered Machine composes a Sandbox (a \`workspace()\` with \`repos\`).
|
|
161
|
+
|
|
162
|
+
FROM node:24-slim
|
|
163
|
+
|
|
164
|
+
RUN apt-get update \\
|
|
165
|
+
&& apt-get install -y --no-install-recommends git ca-certificates \\
|
|
166
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
167
|
+
|
|
168
|
+
# Your agents' toolchain goes here — compilers, CLIs, language servers, dotfiles.
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
const GITIGNORE = `# Runtime state the orchestrator writes under the instance root (ADR-0009): the sqlite snapshot store
|
|
172
|
+
# and the dev server's live address. Never checked in.
|
|
173
|
+
.jr2/
|
|
174
|
+
node_modules/
|
|
175
|
+
|
|
176
|
+
# Deployment-varying values + creds this instance's jr2.config.ts reads from the environment; the
|
|
177
|
+
# \`jr2\` CLI loads this file automatically (ADR-0019). Never checked in.
|
|
178
|
+
.env
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
const PING_TS = `// The simplest jr2 workflow: no Agent, no Workspace, no data plane at all. A Machine is free to "just
|
|
182
|
+
// respond to the request" with a plain actor (CONTEXT.md: a workflow need not spawn a Workspace) —
|
|
183
|
+
// this is that case, and the one workflow that runs end-to-end on a fresh instance before any Sandbox /
|
|
184
|
+
// Harness infrastructure exists. Filename \`ping.ts\` → workflow "ping".
|
|
185
|
+
//
|
|
186
|
+
// Shape: take the run input, invoke a plain \`fromPromise\` actor, fold its result into context, finish.
|
|
187
|
+
// \`jr2 run ping --input '{"message":"hi"}'\` → the run reaches \`done\` and \`jr2 status\` shows the reply.
|
|
188
|
+
//
|
|
189
|
+
// Module contract (ADR-0011/0015): one named export — \`machine\`. A workflow that accepts
|
|
190
|
+
// external events authors with \`jr2Setup({ events: [...] })\`; ping accepts none, so plain
|
|
191
|
+
// xstate \`setup()\` is all it needs.
|
|
192
|
+
//
|
|
193
|
+
// The next step is a Machine the kit already ships (ADR-0054). \`@jr2/machines\` exports \`task\` — one
|
|
194
|
+
// prompt, one Workspace, one human says done — and a Workflow is only the name an Instance
|
|
195
|
+
// registers a Machine under, so the whole of \`workflows/task.ts\` is:
|
|
196
|
+
//
|
|
197
|
+
// import { customize } from "@jr2/orchestrator";
|
|
198
|
+
// import { task } from "@jr2/machines";
|
|
199
|
+
//
|
|
200
|
+
// export const machine = customize(task, {
|
|
201
|
+
// repos: { target: { url: "https://github.com/you/repo.git" } },
|
|
202
|
+
// agents: { coder: { model: "anthropic/claude-sonnet-4-6" } },
|
|
203
|
+
// });
|
|
204
|
+
//
|
|
205
|
+
// A packaged Machine leaves the parts it cannot honestly fill OPEN: it does not know your
|
|
206
|
+
// repository and cannot pay for your model. \`jr2 up\` refuses an Open part nobody bound and prints
|
|
207
|
+
// the \`customize\` line that binds it, so forgetting one stops the converge instead of spending
|
|
208
|
+
// money on a model you never chose. Add \`@jr2/machines\` to this folder's dependencies when you
|
|
209
|
+
// write that file. \`ping\` stays Agent-free on purpose: it is the workflow that runs before any
|
|
210
|
+
// model provider, Harness, or Sandbox exists.
|
|
211
|
+
|
|
212
|
+
import { setup, assign, fromPromise } from "xstate";
|
|
213
|
+
|
|
214
|
+
type Input = { message?: string };
|
|
215
|
+
type Ctx = { message: string; reply?: string };
|
|
216
|
+
|
|
217
|
+
export const machine = setup({
|
|
218
|
+
types: {} as { context: Ctx; input: Input },
|
|
219
|
+
actors: {
|
|
220
|
+
// A plain actor — no Harness, no Sandbox. Stands in for any non-Agent compute a workflow runs.
|
|
221
|
+
respond: fromPromise<string, { message: string }>(async ({ input }) => \`pong: \${input.message}\`),
|
|
222
|
+
},
|
|
223
|
+
}).createMachine({
|
|
224
|
+
id: "ping",
|
|
225
|
+
context: ({ input }) => ({ message: input.message ?? "ping" }),
|
|
226
|
+
initial: "responding",
|
|
227
|
+
states: {
|
|
228
|
+
responding: {
|
|
229
|
+
invoke: {
|
|
230
|
+
src: "respond",
|
|
231
|
+
input: ({ context }) => ({ message: context.message }),
|
|
232
|
+
onDone: { target: "done", actions: assign({ reply: ({ event }) => event.output }) },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
done: { type: "final" },
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
`;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// `jr2 kit push <registry>` (ADR-0044): the installed self-hoster's mirror of the Kit images.
|
|
2
|
+
//
|
|
3
|
+
// Kit images live at a canonical public home (`ghcr.io/snapwich`, baked into `publishedKitRefs()`).
|
|
4
|
+
// A cluster that cannot reach it — air-gapped, mirror-only, or simply policy-bound to one registry —
|
|
5
|
+
// needs the three refs at its own address, which is what `kitRegistry` names in the config. This
|
|
6
|
+
// command is how the bytes get there.
|
|
7
|
+
//
|
|
8
|
+
// It is deliberately INSTANCE-LESS: no `jr2.config.ts`, no kube context, no namespace. Kit images are
|
|
9
|
+
// shared by every instance on a cluster (and possibly by every instance in an org), so moving them
|
|
10
|
+
// is its own deliberate act rather than a side effect of converging one instance — which is exactly
|
|
11
|
+
// why ADR-0044 keeps this out of `jr2 up`.
|
|
12
|
+
//
|
|
13
|
+
// It is a MIRROR and nothing else. There is no build arm here: the npm packages carry no Harness or
|
|
14
|
+
// Adapter source, and an installed CLI that could build Kit images would be the patched-Harness
|
|
15
|
+
// eject hatch ADR-0027/ADR-0038 welded shut. The kit developer's arm is `just kit-push`, in the
|
|
16
|
+
// checkout, where the sources actually are.
|
|
17
|
+
//
|
|
18
|
+
// The copy is `docker buildx imagetools create`, a registry-to-registry manifest copy: it moves the
|
|
19
|
+
// full manifest list (every platform, exact digests) without landing bytes on this host. A daemon
|
|
20
|
+
// -side `docker pull` + `docker push` would flatten a multi-arch image to the host's own platform
|
|
21
|
+
// and ship an amd64-only image to an arm64 cluster, so it is not an implementation detail we may
|
|
22
|
+
// swap — it is the decision.
|
|
23
|
+
|
|
24
|
+
import { execFile } from "node:child_process";
|
|
25
|
+
import { parseArgs, promisify } from "node:util";
|
|
26
|
+
import { KIT_VERSION } from "@jr2/orchestrator";
|
|
27
|
+
import { KIT_IMAGES, publishedKitRefs, type KitImageName } from "../build.ts";
|
|
28
|
+
import { activity, result, type Io } from "../output.ts";
|
|
29
|
+
|
|
30
|
+
const exec = promisify(execFile);
|
|
31
|
+
|
|
32
|
+
const USAGE = `usage: jr2 kit push <registry>
|
|
33
|
+
|
|
34
|
+
mirror the v${KIT_VERSION} kit images (harness, adapter, operator) from their canonical
|
|
35
|
+
home into <registry>, for a cluster whose \`kitRegistry\` names it (ADR-0044)`;
|
|
36
|
+
|
|
37
|
+
/** Run one `docker` invocation, rejecting on a non-zero exit. The seam this command is tested
|
|
38
|
+
* through: the claims are which argv each ref produces and what a failure means, and both are
|
|
39
|
+
* decidable with no registry anywhere near the test. Narrower than `build.ts`'s `RunCommand` on
|
|
40
|
+
* purpose — a mirror runs docker and only docker, and from nowhere in particular. */
|
|
41
|
+
export type RunDocker = (args: string[]) => Promise<void>;
|
|
42
|
+
|
|
43
|
+
const dockerCli: RunDocker = async (args) => {
|
|
44
|
+
await exec("docker", args, { maxBuffer: 64 * 1024 * 1024 });
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** What one image's mirror did — the per-image line, and the machine-readable result's rows. */
|
|
48
|
+
type Mirrored = { image: KitImageName; source: string; target: string; copied: boolean };
|
|
49
|
+
|
|
50
|
+
export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli): Promise<number> {
|
|
51
|
+
const { positionals } = parseArgs({ args, allowPositionals: true, strict: false, options: {} });
|
|
52
|
+
const [sub, registry] = positionals;
|
|
53
|
+
|
|
54
|
+
if (sub !== "push") {
|
|
55
|
+
activity(io, sub === undefined ? "jr2 kit: no subcommand" : `unknown kit subcommand: ${sub}`);
|
|
56
|
+
activity(io, USAGE);
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
if (!registry) {
|
|
60
|
+
activity(io, "jr2 kit push: no target registry");
|
|
61
|
+
activity(io, USAGE);
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A trailing slash is the one spelling a human types and no registry accepts.
|
|
66
|
+
const target = registry.replace(/\/+$/, "");
|
|
67
|
+
|
|
68
|
+
// Both ends come out of `publishedKitRefs`: bare, it is the canonical home; given a registry, it
|
|
69
|
+
// is what that registry re-homes the refs to — which is precisely what a converge with
|
|
70
|
+
// `kitRegistry: <target>` will ask its nodes to pull. So the mirror cannot fill an address the
|
|
71
|
+
// deployment does not read, and neither side of ADR-0044 can drift from the other.
|
|
72
|
+
const sources = publishedKitRefs();
|
|
73
|
+
const targets = publishedKitRefs(target);
|
|
74
|
+
|
|
75
|
+
activity(io, `jr2 kit push — mirroring the v${KIT_VERSION} kit images to ${target}`);
|
|
76
|
+
const rows: Mirrored[] = [];
|
|
77
|
+
for (const image of Object.keys(KIT_IMAGES) as KitImageName[]) {
|
|
78
|
+
rows.push(await mirror(io, docker, image, sources[image], targets[image]));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const copied = rows.filter((r) => r.copied).length;
|
|
82
|
+
activity(io, `mirrored ${copied} image(s), ${rows.length - copied} already present`);
|
|
83
|
+
result(io, {
|
|
84
|
+
registry: target,
|
|
85
|
+
version: KIT_VERSION,
|
|
86
|
+
images: Object.fromEntries(rows.map((r) => [r.image, r.target])),
|
|
87
|
+
});
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One image, skip-checked first. A published version tag never moves — the release train ties it to
|
|
93
|
+
* one npm version (ADR-0019) — so a tag already resolving in the target is not merely present, it is
|
|
94
|
+
* CURRENT, and re-copying it would buy nothing. That is also why this asks the target before the
|
|
95
|
+
* source: the common repeat run costs one inspect and no transfer at all.
|
|
96
|
+
*/
|
|
97
|
+
async function mirror(
|
|
98
|
+
io: Io,
|
|
99
|
+
docker: RunDocker,
|
|
100
|
+
image: KitImageName,
|
|
101
|
+
source: string,
|
|
102
|
+
target: string,
|
|
103
|
+
): Promise<Mirrored> {
|
|
104
|
+
if (await resolves(docker, target)) {
|
|
105
|
+
activity(io, `${image}: already present — ${target}`);
|
|
106
|
+
return { image, source, target, copied: false };
|
|
107
|
+
}
|
|
108
|
+
await requireSource(docker, image, source, target);
|
|
109
|
+
await docker(["buildx", "imagetools", "create", "-t", target, source]);
|
|
110
|
+
activity(io, `${image}: copied ${source} → ${target}`);
|
|
111
|
+
return { image, source, target, copied: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Does this ref resolve in its registry? `imagetools inspect` reads the manifest and nothing else,
|
|
115
|
+
* so the question costs a metadata round-trip rather than a pull. Any failure reads as "not there":
|
|
116
|
+
* a registry that cannot answer is one this command must not assume it has already filled, and the
|
|
117
|
+
* copy that follows fails loudly enough for both cases. */
|
|
118
|
+
async function resolves(docker: RunDocker, ref: string): Promise<boolean> {
|
|
119
|
+
try {
|
|
120
|
+
await docker(["buildx", "imagetools", "inspect", ref]);
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The missing-source failure, named on both ends. It is the common case in a dev loop — the home has
|
|
129
|
+
* nothing at `0.0.0`, and nothing ever will — so the message must not read as a broken mirror. What
|
|
130
|
+
* it points at is the other arm of ADR-0044: `just kit-push`, in the checkout, is what puts images
|
|
131
|
+
* at a published name in the first place; this command only moves what is already there.
|
|
132
|
+
*/
|
|
133
|
+
async function requireSource(docker: RunDocker, image: KitImageName, source: string, target: string): Promise<void> {
|
|
134
|
+
if (await resolves(docker, source)) return;
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${image}: ${source} is not there, so nothing can be mirrored to ${target}\n` +
|
|
137
|
+
` a kit image reaches ${target} only by being copied from its published home;\n` +
|
|
138
|
+
` if v${KIT_VERSION} was never published (a dev version never is), seed a registry from a kit\n` +
|
|
139
|
+
` checkout with \`just kit-push <registry>\`, which builds the three images from source`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// `jr2 logs <runId|abbrev> [-f]` (ADR-0009): re-attach to a run's feed. The orchestrator replays the current
|
|
2
|
+
// status on attach, so even without -f you see where the run IS right now (status → stdout as JSON;
|
|
3
|
+
// author emits → stderr). `-f`/`--follow` keeps streaming status deltas until the run settles; without
|
|
4
|
+
// it, we print the replayed status and stop. A run that already settled streams its final status once.
|
|
5
|
+
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { JR2Client } from "../client.ts";
|
|
8
|
+
import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
|
|
9
|
+
import { activity, result, type Io } from "../output.ts";
|
|
10
|
+
import { resolveRunId } from "../run-id.ts";
|
|
11
|
+
|
|
12
|
+
export async function logs(args: string[], io: Io): Promise<number> {
|
|
13
|
+
const { values, positionals } = parseArgs({
|
|
14
|
+
args,
|
|
15
|
+
allowPositionals: true,
|
|
16
|
+
strict: false,
|
|
17
|
+
options: { follow: { type: "boolean", short: "f" }, ...TARGET_ARGS },
|
|
18
|
+
});
|
|
19
|
+
const given = positionals[0];
|
|
20
|
+
if (!given) {
|
|
21
|
+
activity(io, "usage: jr2 logs <runId|abbrev> [-f]");
|
|
22
|
+
return 2;
|
|
23
|
+
}
|
|
24
|
+
const target = await resolveTarget(io, targetOptions(values));
|
|
25
|
+
try {
|
|
26
|
+
const client = new JR2Client(target.url, io.fetch, target.token);
|
|
27
|
+
const ref = await resolveRunId(client, given);
|
|
28
|
+
if (!ref.ok) {
|
|
29
|
+
activity(io, ref.message);
|
|
30
|
+
return ref.code;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for await (const ev of client.events(ref.runId)) {
|
|
34
|
+
if (ev.kind === "emit") {
|
|
35
|
+
activity(io, `emit ${JSON.stringify(ev.event)}`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (ev.kind === "retry") {
|
|
39
|
+
activity(io, `retry ${ev.child} attempt ${ev.attempt} (${ev.reason})`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
result(io, ev.status);
|
|
43
|
+
if (!values.follow) break;
|
|
44
|
+
if (ev.status.status !== "active") break;
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
} finally {
|
|
48
|
+
target.close?.();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// `jr2 run <workflow> [--input <json>] [--detach]` (ADR-0009). BLOCKING + attach-by-default, mirroring
|
|
2
|
+
// `flue run`: start the run, then attach to its SSE feed — status deltas + author emits go to stderr
|
|
3
|
+
// as they happen; the terminal RunStatus is printed as JSON on stdout and we exit. So `jr2 run ping`
|
|
4
|
+
// shows progress to a human while `jr2 run ping | jq` yields just the result.
|
|
5
|
+
//
|
|
6
|
+
// Diverges from flue: this ATTACHES to the deployed orchestrator (port-forwarded via the current
|
|
7
|
+
// kube context — ADR-0019) rather than a per-invocation runtime, because jr2 runs are durable and may
|
|
8
|
+
// park indefinitely on an approval gate. `--detach` prints the runId and returns, leaving the run
|
|
9
|
+
// going server-side.
|
|
10
|
+
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { JR2Client } from "../client.ts";
|
|
13
|
+
import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
|
|
14
|
+
import { activity, result, type Io } from "../output.ts";
|
|
15
|
+
|
|
16
|
+
export async function run(args: string[], io: Io): Promise<number> {
|
|
17
|
+
const { values, positionals } = parseArgs({
|
|
18
|
+
args,
|
|
19
|
+
allowPositionals: true,
|
|
20
|
+
strict: false,
|
|
21
|
+
options: {
|
|
22
|
+
input: { type: "string" },
|
|
23
|
+
detach: { type: "boolean" },
|
|
24
|
+
...TARGET_ARGS,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const workflow = positionals[0];
|
|
29
|
+
if (!workflow) {
|
|
30
|
+
activity(io, "usage: jr2 run <workflow> [--input <json>] [--detach]");
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
const input = values.input ? (JSON.parse(String(values.input)) as Record<string, unknown>) : {};
|
|
34
|
+
const target = await resolveTarget(io, targetOptions(values));
|
|
35
|
+
try {
|
|
36
|
+
const client = new JR2Client(target.url, io.fetch, target.token);
|
|
37
|
+
|
|
38
|
+
const { runId } = await client.start(workflow, input);
|
|
39
|
+
if (values.detach) {
|
|
40
|
+
result(io, { runId });
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
activity(io, `run ${runId} (${workflow}) — attached`);
|
|
45
|
+
for await (const ev of client.events(runId)) {
|
|
46
|
+
if (ev.kind === "emit") {
|
|
47
|
+
activity(io, ` emit ${JSON.stringify(ev.event)}`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (ev.kind === "retry") {
|
|
51
|
+
activity(io, ` retry ${ev.child} attempt ${ev.attempt} (${ev.reason})`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
activity(io, ` → ${ev.status.status} ${JSON.stringify(ev.status.value)}`);
|
|
55
|
+
if (ev.status.status !== "active") {
|
|
56
|
+
result(io, ev.status);
|
|
57
|
+
return ev.status.status === "error" ? 1 : 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
} finally {
|
|
62
|
+
target.close?.();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// `jr2 runs` (ADR-0009): list the live runs as JSON on stdout. (Settled runs leave the live registry;
|
|
2
|
+
// read a specific one through to the store with `jr2 status <runId>`.)
|
|
3
|
+
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { JR2Client } from "../client.ts";
|
|
6
|
+
import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
|
|
7
|
+
import { result, type Io } from "../output.ts";
|
|
8
|
+
|
|
9
|
+
export async function runs(args: string[], io: Io): Promise<number> {
|
|
10
|
+
const { values } = parseArgs({ args, allowPositionals: true, strict: false, options: { ...TARGET_ARGS } });
|
|
11
|
+
const target = await resolveTarget(io, targetOptions(values));
|
|
12
|
+
try {
|
|
13
|
+
const client = new JR2Client(target.url, io.fetch, target.token);
|
|
14
|
+
result(io, await client.list());
|
|
15
|
+
return 0;
|
|
16
|
+
} finally {
|
|
17
|
+
target.close?.();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `jr2 send` (ADR-0009/0011/0013): the two down-channels a human has into a live run.
|
|
2
|
+
//
|
|
3
|
+
// jr2 send <runId|abbrev> --event CANCEL run control (the only verb left)
|
|
4
|
+
// jr2 send <runId|abbrev> --gate <gate> --event <name> [--input '<json>'] deliver to an open Gate
|
|
5
|
+
//
|
|
6
|
+
// Run control used to carry APPROVE and STEER too. Those rode the `deferred` (held tool result)
|
|
7
|
+
// and `poll` (steer inbox) semantics, which ADR-0013 reserves but does not build — so they are
|
|
8
|
+
// gone rather than left pretending. Workflow-defined events reach a run through its GATES
|
|
9
|
+
// (`POST /runs/:id/gates/:gate/events` — ADR-0011): the gate names the pending decision, the
|
|
10
|
+
// event name must be in its derived accepts, and the input is validated against the event's
|
|
11
|
+
// schema host-side. Discover a run's open gates (names + accepts + meta) with `jr2 status <runId>`.
|
|
12
|
+
//
|
|
13
|
+
// The run id resolves BEFORE either branch: both are writes, and a write must never be
|
|
14
|
+
// prefix-sensitive (ADR-0009). It also gives a bad id a real error — `host.stop()` returns silently
|
|
15
|
+
// for an unknown run, so an unresolved CANCEL used to report success.
|
|
16
|
+
|
|
17
|
+
import { parseArgs } from "node:util";
|
|
18
|
+
import { JR2Client } from "../client.ts";
|
|
19
|
+
import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
|
|
20
|
+
import { activity, type Io } from "../output.ts";
|
|
21
|
+
import { resolveRunId } from "../run-id.ts";
|
|
22
|
+
|
|
23
|
+
const USAGE =
|
|
24
|
+
"usage: jr2 send <runId|abbrev> --event CANCEL | jr2 send <runId|abbrev> --gate <gate> --event <name> [--input '<json>']";
|
|
25
|
+
|
|
26
|
+
export async function send(args: string[], io: Io): Promise<number> {
|
|
27
|
+
const { values, positionals } = parseArgs({
|
|
28
|
+
args,
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
strict: false,
|
|
31
|
+
options: {
|
|
32
|
+
event: { type: "string" },
|
|
33
|
+
gate: { type: "string" },
|
|
34
|
+
input: { type: "string" },
|
|
35
|
+
...TARGET_ARGS,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const given = positionals[0];
|
|
39
|
+
const gate = values.gate as string | undefined;
|
|
40
|
+
const event = values.event as string | undefined;
|
|
41
|
+
if (!given || !event) {
|
|
42
|
+
activity(io, USAGE);
|
|
43
|
+
return 2;
|
|
44
|
+
}
|
|
45
|
+
const target = await resolveTarget(io, targetOptions(values));
|
|
46
|
+
try {
|
|
47
|
+
const client = new JR2Client(target.url, io.fetch, target.token);
|
|
48
|
+
const ref = await resolveRunId(client, given);
|
|
49
|
+
if (!ref.ok) {
|
|
50
|
+
activity(io, ref.message);
|
|
51
|
+
return ref.code;
|
|
52
|
+
}
|
|
53
|
+
const runId = ref.runId;
|
|
54
|
+
|
|
55
|
+
// Gate delivery: the event name is the workflow's own vocabulary — case-preserved, never
|
|
56
|
+
// normalized here (the host validates it against the gate's accepts).
|
|
57
|
+
if (gate) {
|
|
58
|
+
let input: Record<string, unknown> = {};
|
|
59
|
+
if (values.input !== undefined) {
|
|
60
|
+
try {
|
|
61
|
+
input = JSON.parse(values.input as string) as Record<string, unknown>;
|
|
62
|
+
} catch {
|
|
63
|
+
activity(io, `jr2 send: --input is not valid JSON`);
|
|
64
|
+
return 2;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await client.sendToGate(runId, gate, { type: event, ...input });
|
|
68
|
+
activity(io, `delivered ${event} to gate "${gate}" on ${runId}`);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const control = event.toUpperCase();
|
|
73
|
+
if (control !== "CANCEL") {
|
|
74
|
+
activity(io, `jr2 send: unknown event "${event}" (run control accepts: CANCEL; workflow events need --gate)`);
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
await client.send(runId, { type: control });
|
|
78
|
+
activity(io, `sent ${control} to ${runId}`);
|
|
79
|
+
return 0;
|
|
80
|
+
} finally {
|
|
81
|
+
target.close?.();
|
|
82
|
+
}
|
|
83
|
+
}
|