@jr2/cli 0.6.0 → 0.7.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.
@@ -0,0 +1,57 @@
1
+ // `jr2 watch <runId|abbrev> [--json]` (ADR-0009): attach to a run's feed and follow it to its end.
2
+ // The orchestrator replays the current status on attach, so a parked run shows where it is, and an
3
+ // ended run prints its last status once. It prints the lines that `jr2 run` prints while it is
4
+ // attached, in the same form: the header `run <id> (<workflow>) — attached`, then each
5
+ // `attachedLine` with the same indent, and after a failed run's last status its reason
6
+ // (`failureActivity`). They go on stdout: a report verb. Its exit code is the watch's, not the
7
+ // run's: 0 when it followed the feed to its end, a failed or a parked run too. `jr2 run` exits with
8
+ // the run's result. `--json` prints each status as one JSON line on stdout and the other
9
+ // lines as activity on stderr. A run that is parked before it settles ends the watch, as it ends
10
+ // `jr2 run`'s attach.
11
+ import { JR2Client } from "../client.ts";
12
+ import { resolveTarget, targetOptions } from "../instance.ts";
13
+ import { activity, result, type Io } from "../output.ts";
14
+ import { resolveRunId } from "../run-id.ts";
15
+ import { attachedLine } from "./run.ts";
16
+ import { failureActivity } from "./status.ts";
17
+ import { parseCommand, usageLine } from "../command-table.ts";
18
+
19
+ export async function watch(args: string[], io: Io): Promise<number> {
20
+ const { values, positionals } = parseCommand("watch", args);
21
+ const given = positionals[0];
22
+ if (!given || positionals.length > 1) {
23
+ activity(io, usageLine("watch"));
24
+ return 2;
25
+ }
26
+ const target = await resolveTarget(io, targetOptions(values));
27
+ try {
28
+ const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
29
+ const ref = await resolveRunId(client, given);
30
+ if (!ref.ok) {
31
+ activity(io, ref.message);
32
+ return ref.code;
33
+ }
34
+ let headed = false;
35
+ for await (const ev of client.events(ref.runId)) {
36
+ if (values.json !== true && !headed) {
37
+ headed = true;
38
+ const workflow = ev.kind === "status" ? ev.status.workflow : "?";
39
+ io.stdout(`run ${ref.runId} (${workflow}) — attached\n`);
40
+ }
41
+ if (values.json !== true) io.stdout(` ${attachedLine(ev)}\n`);
42
+ else if (ev.kind === "status") result(io, ev.status);
43
+ else activity(io, attachedLine(ev));
44
+ if (ev.kind === "status" && ev.status.status !== "active") {
45
+ // A failed run's reason, as `jr2 run` prints it after the last status.
46
+ const failed = failureActivity(ev.status);
47
+ if (failed && values.json !== true) io.stdout(`${failed}\n`);
48
+ else if (failed) activity(io, failed);
49
+ return 0;
50
+ }
51
+ }
52
+ activity(io, `run ${ref.runId} is parked: stopped before it settled`);
53
+ return 0;
54
+ } finally {
55
+ target.close?.();
56
+ }
57
+ }
@@ -0,0 +1,22 @@
1
+ // `jr2 whoami` (ADR-0078): the Caller that this credential is, as `GET /whoami` gives it — how jr2
2
+ // knows it, its name, the name jr2 shows, what it may do, and the party it says it acts for. A
3
+ // person runs it to see what a credential may do before an act fails. On a kube context it is
4
+ // `token/instance`, on behalf of the kube user; with `--url` and `JR2_TOKEN` it is that token's
5
+ // Caller. The route needs no scope, so a credential that may do nothing still sees why.
6
+
7
+ import { JR2Client } from "../client.ts";
8
+ import { parseCommand } from "../command-table.ts";
9
+ import { resolveTarget, targetOptions } from "../instance.ts";
10
+ import { result, type Io } from "../output.ts";
11
+
12
+ export async function whoami(args: string[], io: Io): Promise<number> {
13
+ const { values } = parseCommand("whoami", args);
14
+ const target = await resolveTarget(io, targetOptions(values));
15
+ try {
16
+ const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
17
+ result(io, await client.whoami());
18
+ return 0;
19
+ } finally {
20
+ target.close?.();
21
+ }
22
+ }
@@ -0,0 +1,58 @@
1
+ // `jr2 workflows` (ADR-0009, ADR-0070): the Workflows that a run can start, from `GET /workflows`.
2
+ // One line per Workflow with its origin (`machine`, `chart file`, `chart`), a Chart wrapper's Charts
3
+ // indented under it, and `unrunnable` with its reason. `--json` prints the listing as the report.
4
+ // No verb puts or deletes a runtime Chart: `PUT`/`DELETE /workflows/<wrapper>/charts/<name>` is for
5
+ // an MCP or curl.
6
+
7
+ import { JR2Client, type WorkflowListing, type WorkflowOrigin } from "../client.ts";
8
+ import { resolveTarget, targetOptions } from "../instance.ts";
9
+ import { result, type Io } from "../output.ts";
10
+ import { parseCommand } from "../command-table.ts";
11
+
12
+ export async function workflows(args: string[], io: Io): Promise<number> {
13
+ const { values } = parseCommand("workflows", args);
14
+ const target = await resolveTarget(io, targetOptions(values));
15
+ try {
16
+ const listing = await new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf).workflows();
17
+ if (values.json === true) result(io, listing);
18
+ else io.stdout(`${renderWorkflows(listing)}\n`);
19
+ return 0;
20
+ } finally {
21
+ target.close?.();
22
+ }
23
+ }
24
+
25
+ /** How a listing names an origin (ADR-0070): a runtime Chart is plainly a `chart`. */
26
+ const ORIGIN_LABEL: Record<WorkflowOrigin, string> = { machine: "machine", file: "chart file", runtime: "chart" };
27
+
28
+ /**
29
+ * The Workflow listing as lines: each Workflow with its origin, a Chart wrapper's Open body and
30
+ * whether its door takes a Chart one-shot, its Charts indented under it, and `unrunnable` with the
31
+ * reason. A Chart the listing also gives at the top level (or whose wrapper it does not list) prints
32
+ * once: under its wrapper when the wrapper is listed, else at the top level.
33
+ */
34
+ export function renderWorkflows(listing: readonly WorkflowListing[]): string {
35
+ const underWrapper = new Set<string>();
36
+ for (const w of listing) for (const c of w.wrapper?.charts ?? []) underWrapper.add(chartFullName(w.name, c.name));
37
+ const line = (indent: string, name: string, label: string, unrunnable?: string): string =>
38
+ `${indent}${name} (${label})${unrunnable ? ` unrunnable: ${unrunnable}` : ""}`;
39
+ const out: string[] = [];
40
+ for (const w of listing) {
41
+ if (w.origin !== "machine" && underWrapper.has(w.name)) continue;
42
+ const wrapper = w.wrapper;
43
+ const label = wrapper
44
+ ? `${ORIGIN_LABEL[w.origin]}, Chart wrapper: body "${wrapper.body}"${wrapper.oneShot ? ", one-shot" : ""}`
45
+ : ORIGIN_LABEL[w.origin];
46
+ out.push(line("", w.name, label, w.unrunnable));
47
+ for (const c of wrapper?.charts ?? []) {
48
+ out.push(line(" ", chartFullName(w.name, c.name), ORIGIN_LABEL[c.origin], c.unrunnable));
49
+ }
50
+ }
51
+ if (out.length === 0) out.push("none registered — `jr2 up` publishes the Assembly");
52
+ return out.join("\n");
53
+ }
54
+
55
+ /** A Chart's Workflow name, `<wrapper>/<name>` (ADR-0070), whether the listing gives it short or whole. */
56
+ function chartFullName(wrapper: string, name: string): string {
57
+ return name.includes("/") ? name : `${wrapper}/${name}`;
58
+ }
@@ -0,0 +1,316 @@
1
+ // `jr2 workspace ls | shell | forward | rm` (ADR-0009, ADR-0012, ADR-0021): the Workspaces as the
2
+ // cluster holds them. Each sub-verb reads the Sandbox CRs over the kube API (`workspaces.ts`) and
3
+ // never the Orchestrator, so it works when the Orchestrator is down, and kube RBAC is the gate. They
4
+ // take the deployment's addressing (`-n`, `--context`) and not `--url`. With `-n`, no instance
5
+ // folder is needed: an ssh ProxyCommand runs in any folder.
6
+ //
7
+ // ls [run] the Workspaces, and whether a run holds each
8
+ // shell <ws> [--user] [-- cmd] a shell in the Agents' Landlock domain (ADR-0076), or in the User Container
9
+ // forward <ws> <port> [--stdio] a local port to the pod, or one connection on stdin and stdout
10
+ // rm <ws> remove a Sandbox that no run holds
11
+
12
+ import { connect as netConnect, type Socket } from "node:net";
13
+ import { constants as osConstants } from "node:os";
14
+ import { basename } from "node:path";
15
+ import { loadConfig } from "@jr2/orchestrator/internal";
16
+ import { commandNamed, parseCommand, renderCommand, usageLine } from "../command-table.ts";
17
+ import { resolveRoot } from "../instance.ts";
18
+ import { activity, result, type Io } from "../output.ts";
19
+ import {
20
+ age,
21
+ kubectlWorkspaces,
22
+ matchHandle,
23
+ removable,
24
+ rowOf,
25
+ WorkspaceChangedError,
26
+ type WorkspaceKube,
27
+ type WorkspaceRow,
28
+ } from "../workspaces.ts";
29
+
30
+ /** How `jr2 workspace shell` starts in the Harness container: the client that asks the Harness for the
31
+ * shell, which its spawner starts in the Agents' domain (ADR-0076). It starts as the Harness starts,
32
+ * with the client's entry in place of the Harness's. */
33
+ export const SHELL_ENTRY: readonly string[] = [
34
+ "/opt/jr2/bin/effector",
35
+ "nocore",
36
+ "--harness-env",
37
+ "--",
38
+ "/opt/jr2/bin/node",
39
+ "--disable-sigusr1",
40
+ "/opt/jr2/src/workspace-shell-main.ts",
41
+ ];
42
+
43
+ /** A User Container's shell: bash where the image has it, else sh. */
44
+ export const USER_SHELL: readonly string[] = ["sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"];
45
+
46
+ /** What `forward --stdio` needs of the process: its stdin and stdout, and a TCP connect. Seams for tests. */
47
+ export type StdioPipe = {
48
+ stdin: NodeJS.ReadableStream;
49
+ stdout: NodeJS.WritableStream;
50
+ connect(port: number): Socket;
51
+ };
52
+
53
+ const SUBS = ["ls", "shell", "forward", "rm"] as const;
54
+ type Sub = (typeof SUBS)[number];
55
+
56
+ function usage(io: Io, sub: string | undefined): number {
57
+ activity(io, sub === undefined ? "jr2 workspace: no subcommand" : `unknown workspace subcommand: ${sub}`);
58
+ activity(io, SUBS.map((s) => renderCommand(commandNamed(`workspace ${s}`))).join("\n\n"));
59
+ return 2;
60
+ }
61
+
62
+ /** The deployment: `-n` as given, else the instance folder's config name, else its folder's name. */
63
+ async function deployment(
64
+ io: Io,
65
+ kube: WorkspaceKube,
66
+ values: { namespace?: string | boolean; context?: string | boolean },
67
+ ): Promise<{ namespace: string; context?: string }> {
68
+ let namespace = typeof values.namespace === "string" ? values.namespace : undefined;
69
+ if (namespace === undefined) {
70
+ const root = resolveRoot(io.cwd);
71
+ namespace = (await loadConfig(root))?.name ?? basename(root);
72
+ }
73
+ const context = typeof values.context === "string" ? values.context : await kube.context();
74
+ if (!context) throw new Error("no kube context — `kind create cluster` (or point kubectl at one), then `jr2 up`");
75
+ activity(io, `→ context ${context} / namespace ${namespace}`);
76
+ return { namespace, ...(typeof values.context === "string" ? { context: values.context } : {}) };
77
+ }
78
+
79
+ /** Every Workspace of the instance, and the API server's clock. */
80
+ async function rows(
81
+ kube: WorkspaceKube,
82
+ where: { namespace: string; context?: string },
83
+ ): Promise<{ rows: WorkspaceRow[]; serverNow?: number }> {
84
+ let listed;
85
+ try {
86
+ listed = await kube.list(where);
87
+ } catch (e) {
88
+ throw new Error(`cannot list the Sandboxes in namespace ${where.namespace}: ${(e as Error).message}`);
89
+ }
90
+ return {
91
+ rows: listed.items.map((cr) => rowOf(cr, listed.serverNow)),
92
+ ...(listed.serverNow !== undefined ? { serverNow: listed.serverNow } : {}),
93
+ };
94
+ }
95
+
96
+ /** The one Workspace a handle names, or a refusal that lists the candidates. */
97
+ function one(io: Io, all: readonly WorkspaceRow[], handle: string): WorkspaceRow | undefined {
98
+ const found = matchHandle(all, handle);
99
+ if (found.length === 1) return found[0];
100
+ if (found.length === 0) {
101
+ activity(io, `no Workspace "${handle}" — \`jr2 workspace ls\` lists them`);
102
+ } else {
103
+ activity(io, `"${handle}" names ${found.length} Workspaces — give more of the run id:`);
104
+ for (const r of found) activity(io, ` ${r.runId}/${r.path} (${r.name})`);
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ /** The `ls` table: one row a Workspace, the columns padded. */
110
+ export function workspaceTable(all: readonly WorkspaceRow[], now: number): string {
111
+ const LIVE: Record<WorkspaceRow["hold"], string> = {
112
+ held: "held",
113
+ released: "released",
114
+ idle: "idle",
115
+ unknown: "unknown",
116
+ };
117
+ const table = [
118
+ ["WORKSPACE", "SANDBOX", "WORKFLOW", "PHASE", "AGE", "RUN"],
119
+ ...all.map((r) => [r.handle, r.name, r.workflow, r.phase, age(r.created, now), LIVE[r.hold]]),
120
+ ];
121
+ const widths = table[0]!.map((_, i) => Math.max(...table.map((row) => row[i]!.length)));
122
+ return table
123
+ .map((row) =>
124
+ row
125
+ .map((cell, i) => cell.padEnd(widths[i]!))
126
+ .join(" ")
127
+ .trimEnd(),
128
+ )
129
+ .join("\n");
130
+ }
131
+
132
+ /** The signals that end a forward: ssh's SIGHUP to its ProxyCommand, SIGTERM, and Ctrl-C. */
133
+ const ENDING_SIGNALS = ["SIGHUP", "SIGTERM", "SIGINT"] as const;
134
+
135
+ /**
136
+ * On each of `ENDING_SIGNALS`, `close` runs, then this process exits as that signal would have
137
+ * ended it (128 + its number). Without it a signal ends node by its default action, and the
138
+ * kubectl child that holds the forward runs on. Returns the unhook.
139
+ */
140
+ export function endOnSignals(close: () => void): () => void {
141
+ const handlers = ENDING_SIGNALS.map((signal) => {
142
+ const handler = () => {
143
+ close();
144
+ process.exit(128 + osConstants.signals[signal]);
145
+ };
146
+ process.once(signal, handler);
147
+ return [signal, handler] as const;
148
+ });
149
+ return () => {
150
+ for (const [signal, handler] of handlers) process.removeListener(signal, handler);
151
+ };
152
+ }
153
+
154
+ /** `forward --stdio`: one connection to the forwarded port, on stdin and stdout, then the forward
155
+ * closes. */
156
+ function stdioForward(
157
+ io: Io,
158
+ given: StdioPipe | undefined,
159
+ fwd: { port: number; close: () => void },
160
+ where: string,
161
+ ): Promise<number> {
162
+ const pipe: StdioPipe = given ?? {
163
+ stdin: process.stdin,
164
+ stdout: process.stdout,
165
+ connect: (p) => netConnect({ host: "127.0.0.1", port: p }),
166
+ };
167
+ return new Promise<number>((resolve) => {
168
+ const socket = pipe.connect(fwd.port);
169
+ const end = (code: number) => {
170
+ fwd.close();
171
+ resolve(code);
172
+ };
173
+ socket.on("error", (err) => {
174
+ activity(io, `the forward to ${where} failed: ${err.message}`);
175
+ end(1);
176
+ });
177
+ socket.on("close", () => end(0));
178
+ pipe.stdin.pipe(socket);
179
+ socket.pipe(pipe.stdout, { end: false });
180
+ });
181
+ }
182
+
183
+ export async function workspace(
184
+ args: string[],
185
+ io: Io,
186
+ opts: { pipe?: StdioPipe; onSignals?: (close: () => void) => () => void } = {},
187
+ ): Promise<number> {
188
+ const sub = args[0] as Sub | undefined;
189
+ if (sub === undefined || !SUBS.includes(sub)) return usage(io, sub);
190
+ const kube = io.workspaces ?? kubectlWorkspaces;
191
+
192
+ if (sub === "ls") {
193
+ const { values, positionals } = parseCommand("workspace ls", args);
194
+ const where = await deployment(io, kube, values);
195
+ const run = positionals[1];
196
+ const listed = await rows(kube, where);
197
+ const shown = listed.rows
198
+ .filter((r) => run === undefined || r.runId.startsWith(run))
199
+ .sort((a, b) => (a.created ?? "").localeCompare(b.created ?? "") || a.handle.localeCompare(b.handle));
200
+ if (values.json === true) result(io, { workspaces: shown });
201
+ else io.stdout(`${workspaceTable(shown, listed.serverNow ?? Date.now())}\n`);
202
+ return 0;
203
+ }
204
+
205
+ if (sub === "shell") {
206
+ // `-- <cmd>` is the command, kept apart before the parse.
207
+ const dash = args.indexOf("--");
208
+ const own = dash >= 0 ? args.slice(0, dash) : args;
209
+ const command = dash >= 0 ? args.slice(dash + 1) : [];
210
+ const { values, positionals } = parseCommand("workspace shell", own);
211
+ const handle = positionals[1];
212
+ if (!handle || positionals.length > 2) {
213
+ activity(io, usageLine("workspace shell"));
214
+ return 2;
215
+ }
216
+ const where = await deployment(io, kube, values);
217
+ const row = one(io, (await rows(kube, where)).rows, handle);
218
+ if (!row) return 1;
219
+ if (values.user === true) {
220
+ const listed = await kube.list(where);
221
+ const cr = listed.items.find((c) => c.metadata.name === row.name);
222
+ if (!cr?.spec?.sidecars?.some((s) => s.name === "user")) {
223
+ activity(io, `Workspace ${row.handle} has no User Container: its workspace() names no \`user\` image`);
224
+ return 1;
225
+ }
226
+ const code = await kube.exec({
227
+ ...where,
228
+ pod: row.name,
229
+ container: "user",
230
+ argv: command.length ? command : USER_SHELL,
231
+ tty: command.length === 0,
232
+ });
233
+ if (code === 126 || code === 127) {
234
+ activity(
235
+ io,
236
+ command.length
237
+ ? `the User Container could not run ${command[0]} (exit ${code})`
238
+ : "the User Container's image has no sh: give it a shell, or name a command after --",
239
+ );
240
+ }
241
+ return code;
242
+ }
243
+ return await kube.exec({
244
+ ...where,
245
+ pod: row.name,
246
+ container: "harness",
247
+ argv: command.length ? [...SHELL_ENTRY, "--", ...command] : SHELL_ENTRY,
248
+ tty: command.length === 0,
249
+ });
250
+ }
251
+
252
+ if (sub === "forward") {
253
+ const { values, positionals } = parseCommand("workspace forward", args);
254
+ const [, handle, portText] = positionals;
255
+ const port = Number(portText);
256
+ if (!handle || !Number.isInteger(port) || port < 1 || port > 65535 || positionals.length > 3) {
257
+ activity(io, usageLine("workspace forward"));
258
+ return 2;
259
+ }
260
+ const where = await deployment(io, kube, values);
261
+ const row = one(io, (await rows(kube, where)).rows, handle);
262
+ if (!row) return 1;
263
+ const fwd = await kube.forward({ ...where, pod: row.name, port });
264
+ // The forward ends with this verb. ssh ends a ProxyCommand with SIGHUP, a supervisor with
265
+ // SIGTERM, a person with Ctrl-C: each closes the forward first, then exits as the signal would.
266
+ const unhook = (opts.onSignals ?? endOnSignals)(() => fwd.close());
267
+ try {
268
+ if (values.stdio !== true) {
269
+ io.stdout(`127.0.0.1:${fwd.port}\n`);
270
+ activity(io, `forwarding 127.0.0.1:${fwd.port} → ${row.handle}:${port} — Ctrl-C ends it`);
271
+ return await fwd.closed;
272
+ }
273
+ return await stdioForward(io, opts.pipe, fwd, `${row.handle}:${port}`);
274
+ } finally {
275
+ unhook();
276
+ }
277
+ }
278
+
279
+ const { values, positionals } = parseCommand("workspace rm", args);
280
+ const handle = positionals[1];
281
+ if (!handle || positionals.length > 2) {
282
+ activity(io, usageLine("workspace rm"));
283
+ return 2;
284
+ }
285
+ const where = await deployment(io, kube, values);
286
+ const row = one(io, (await rows(kube, where)).rows, handle);
287
+ if (!row) return 1;
288
+ if (!removable(row)) {
289
+ const why =
290
+ row.hold === "held" ? `run ${row.runId} holds it` : `it has no hold mark, so it may be held by run ${row.runId}`;
291
+ activity(
292
+ io,
293
+ `Workspace ${row.handle} (${row.name}) is not removed: ${why} — cancel the run first ` +
294
+ `(\`jr2 send ${row.runId} --event CANCEL\`)`,
295
+ );
296
+ return 1;
297
+ }
298
+ try {
299
+ // Only the version that was read: a run that placed the same name again since then holds it.
300
+ await kube.delete({
301
+ ...where,
302
+ name: row.name,
303
+ ...(row.resourceVersion !== undefined ? { resourceVersion: row.resourceVersion } : {}),
304
+ });
305
+ } catch (err) {
306
+ if (!(err instanceof WorkspaceChangedError)) throw err;
307
+ activity(
308
+ io,
309
+ `Workspace ${row.handle} (${row.name}) is not removed: it changed after it was read, ` +
310
+ "so a run may hold it again — run `jr2 workspace ls` and try again",
311
+ );
312
+ return 1;
313
+ }
314
+ activity(io, `removed Workspace ${row.handle} (${row.name})`);
315
+ return 0;
316
+ }