@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.
@@ -1,45 +1,44 @@
1
1
  // `jr2 send` (ADR-0009/0011/0013): the two down-channels a human has into a live run.
2
2
  //
3
- // jr2 send <runId|abbrev> --event CANCEL run control (the only verb left)
3
+ // jr2 send <runId|abbrev> --event CANCEL [--keep] run control (the only verb left)
4
4
  // jr2 send <runId|abbrev> --gate <gate> --event <name> [--input '<json>'] deliver to an open Gate
5
5
  //
6
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
7
+ // and `poll` (steer inbox) semantics: `deferred` is retired (ADR-0006) and `poll` is reserved but
8
+ // not built (ADR-0013) — so they are gone rather than left pretending. Workflow-defined events reach a run through its GATES
9
9
  // (`POST /runs/:id/gates/:gate/events` — ADR-0011): the gate names the pending decision, the
10
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>`.
11
+ // schema host-side. An answer the run would not take now is refused (409, ADR-0013) and prints as
12
+ // an error naming what the Gate takes now; "delivered" is printed only when the run took it.
13
+ // Discover a run's open gates (names + accepts + meta) with `jr2 status <runId>`.
12
14
  //
13
15
  // The run id resolves BEFORE either branch: both are writes, and a write must never be
14
16
  // prefix-sensitive (ADR-0009). It also gives a bad id a real error — `host.stop()` returns silently
15
17
  // for an unknown run, so an unresolved CANCEL used to report success.
18
+ //
19
+ // A CANCEL destroys the run's Workspaces; `--keep` leaves them for inspection until the operator's
20
+ // idle timeout (ADR-0025). It is a fact of this cancel, so it is a flag here and never a config key,
21
+ // and it means nothing on any other event — refused there rather than ignored.
16
22
 
17
- import { parseArgs } from "node:util";
18
23
  import { JR2Client } from "../client.ts";
19
- import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
24
+ import { resolveTarget, targetOptions } from "../instance.ts";
20
25
  import { activity, type Io } from "../output.ts";
21
26
  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>']";
27
+ import { commandNamed, parseCommand, renderCommand } from "../command-table.ts";
25
28
 
26
29
  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
- });
30
+ const { values, positionals } = parseCommand("send", args);
38
31
  const given = positionals[0];
39
32
  const gate = values.gate as string | undefined;
40
33
  const event = values.event as string | undefined;
41
34
  if (!given || !event) {
42
- activity(io, USAGE);
35
+ activity(io, renderCommand(commandNamed("send")));
36
+ return 2;
37
+ }
38
+ const keep = values.keep === true;
39
+ // Refused before the run id resolves: a usage error needs no instance to be one.
40
+ if (values.keep !== undefined && (gate || event.toUpperCase() !== "CANCEL")) {
41
+ activity(io, `jr2 send: --keep goes with --event CANCEL alone (it keeps a cancelled run's Workspaces)`);
43
42
  return 2;
44
43
  }
45
44
  const target = await resolveTarget(io, targetOptions(values));
@@ -74,8 +73,8 @@ export async function send(args: string[], io: Io): Promise<number> {
74
73
  activity(io, `jr2 send: unknown event "${event}" (run control accepts: CANCEL; workflow events need --gate)`);
75
74
  return 2;
76
75
  }
77
- await client.send(runId, { type: control });
78
- activity(io, `sent ${control} to ${runId}`);
76
+ await client.send(runId, keep ? { type: control, keep } : { type: control });
77
+ activity(io, keep ? `sent ${control} to ${runId}; its Workspaces are kept` : `sent ${control} to ${runId}`);
79
78
  return 0;
80
79
  } finally {
81
80
  target.close?.();
@@ -1,8 +1,9 @@
1
1
  // `jr2 status [runId|abbrev]` (ADR-0009): print a run's status as JSON on stdout, reading through to the
2
2
  // store so a completed run still reports its terminal status + final context. A genuinely unknown run → 1.
3
3
  //
4
- // With NO run named, the verb answers about the INSTANCE instead (ADR-0048/0051): whether it has a
5
- // data plane at all, and every Repo resource's per-node state, so a Repo that will not clone — an
4
+ // With NO run named, the verb answers about the INSTANCE instead (ADR-0048/0051/0069): every
5
+ // Assembly a live run holds, the current one first, whether it has a data plane at all, and every Repo
6
+ // resource's per-node state, so a Repo that will not clone — an
6
7
  // unregistered deploy key (ADR-0047), a wrong url, a git host outage — is discoverable by asking
7
8
  // the thing that knows, rather than by tailing pod logs. The Orchestrator serves either way; only
8
9
  // the Repo is degraded, and the cache agent retries it on its own.
@@ -10,21 +11,27 @@
10
11
  // Two output classes (ADR-0009 as amended): `status <runId>` is a RESULT verb — the RunStatus as one
11
12
  // JSON line on stdout, for the pipe. Bare `status` is a REPORT verb — a human table on stdout, or the
12
13
  // one object with `--json`.
14
+ //
15
+ // A run that binds a Chart (ADR-0070) carries `bound: true`; the verb says so beside the result as
16
+ // `chart: bound`, with the route that draws the Machine the run binds, its Charts compiled in.
17
+ //
18
+ // A dispatched run (ADR-0071) says beside the result who dispatched it: `dispatched by: <run>
19
+ // (<slot>), depth <n>`. A run that dispatched says the other direction, one line per run it
20
+ // started: `dispatched: <run> (<slot>)`. The result itself carries `dispatchedBy`, `dispatched`
21
+ // and, once the run is no longer live, its `output`, as the Orchestrator sends them.
22
+ //
23
+ // A failed run (ADR-0073) says its reason beside the result: `failed: <reason>`. The result's
24
+ // `output` holds the whole Failure, `{ reason, detail? }`.
13
25
 
14
- import { parseArgs } from "node:util";
15
- import type { RepoStatus } from "@jr2/orchestrator";
16
- import { JR2Client } from "../client.ts";
17
- import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
26
+ import type { AssemblyView, RepoStatus } from "@jr2/orchestrator/internal";
27
+ import { JR2Client, type FetchLike, type RunStatus, workflowPath } from "../client.ts";
28
+ import { resolveTarget, targetOptions } from "../instance.ts";
18
29
  import { activity, result, type Io } from "../output.ts";
19
30
  import { resolveRunId } from "../run-id.ts";
31
+ import { parseCommand } from "../command-table.ts";
20
32
 
21
33
  export async function status(args: string[], io: Io): Promise<number> {
22
- const { values, positionals } = parseArgs({
23
- args,
24
- allowPositionals: true,
25
- strict: false,
26
- options: { ...TARGET_ARGS, json: { type: "boolean" } },
27
- });
34
+ const { values, positionals } = parseCommand("status", args);
28
35
  // NO run named, not an EMPTY one: `jr2 status "$RUNID"` with the variable unset asked about a run
29
36
  // and got an empty string, and answering that with the instance's repos would report exit 0 on a
30
37
  // question nobody asked. An empty positional falls through to the resolver, which says so.
@@ -32,7 +39,7 @@ export async function status(args: string[], io: Io): Promise<number> {
32
39
  const target = await resolveTarget(io, targetOptions(values));
33
40
  try {
34
41
  const client = new JR2Client(target.url, io.fetch, target.token);
35
- if (given === undefined) return await instanceStatus(client, io, values.json === true);
42
+ if (given === undefined) return await instanceStatus(client, io, target.url, values.json === true);
36
43
  const ref = await resolveRunId(client, given);
37
44
  if (!ref.ok) {
38
45
  activity(io, ref.message);
@@ -44,18 +51,65 @@ export async function status(args: string[], io: Io): Promise<number> {
44
51
  return 1;
45
52
  }
46
53
  result(io, s);
54
+ const notes = [failureActivity(s), dispatchedActivity(s), ...dispatchedLines(s), boundActivity(s)];
55
+ for (const note of notes) if (note) activity(io, note);
47
56
  return 0;
48
57
  } finally {
49
58
  target.close?.();
50
59
  }
51
60
  }
52
61
 
62
+ /** The `failed:` line for a run that ended `failed` (ADR-0073): the Failure's reason, read from its
63
+ * output. Undefined for every other status, and for an output with no reason text. */
64
+ export function failureActivity(s: Pick<RunStatus, "status" | "output">): string | undefined {
65
+ if (s.status !== "failed") return undefined;
66
+ const reason = (s.output as { reason?: unknown } | undefined)?.reason;
67
+ return typeof reason === "string" ? `failed: ${reason}` : undefined;
68
+ }
69
+
70
+ /** The most `dispatched:` lines `jr2 status` prints: a watcher dispatches on every pass, and the
71
+ * result carries the whole list. */
72
+ const DISPATCHED_LINES = 10;
73
+
74
+ /** The `dispatched:` lines for the runs this run's `dispatch` Effects started (ADR-0071), newest
75
+ * last, the older ones counted. */
76
+ export function dispatchedLines(s: RunStatus): string[] {
77
+ const all = s.dispatched ?? [];
78
+ const shown = all.slice(-DISPATCHED_LINES);
79
+ const older = all.length - shown.length;
80
+ return [
81
+ ...(older > 0 ? [`dispatched: ${older} older run(s); the result lists them all`] : []),
82
+ ...shown.map((d) => `dispatched: ${d.run} (${d.slot})`),
83
+ ];
84
+ }
85
+
86
+ /** The `dispatched by:` line for a run a `dispatch` Effect started (ADR-0071), or undefined for a
87
+ * run nobody dispatched. A foreign caller is named by its Orchestrator, as that one sent it: opaque
88
+ * text, which this Orchestrator could not verify. */
89
+ export function dispatchedActivity(s: RunStatus): string | undefined {
90
+ const by = s.dispatchedBy;
91
+ if (!by) return undefined;
92
+ return `dispatched by: ${by.run} (${by.slot}), depth ${by.depth}${by.instance ? `, from Orchestrator ${by.instance}` : ""}`;
93
+ }
94
+
95
+ /** The `chart: bound` line for a run that binds a Chart (ADR-0070), or undefined for one that binds
96
+ * none. The Workflow name rides the route as one encoded segment. */
97
+ export function boundActivity(s: RunStatus): string | undefined {
98
+ if (s.bound !== true) return undefined;
99
+ const route = `GET ${workflowPath(s.workflow)}/runs/${encodeURIComponent(s.runId)}/machine`;
100
+ // A drifted run's Chart may no longer admit (D-B): the route then draws the slot's own subgraph
101
+ // and names why in `chartsNotDrawn`, so the line does not claim more than the route draws.
102
+ if (s.status === "drifted") return `chart: bound, drifted — ${route} names each Chart it cannot draw`;
103
+ return `chart: bound — ${route} draws the Machine this run binds`;
104
+ }
105
+
53
106
  /**
54
- * The instance's own status: the data-plane switch and every Repo resource. As a table on stdout —
107
+ * The instance's own status: the held Assemblies, the data-plane switch and every Repo resource. As a table on stdout —
55
108
  * every node's state per Repo, and every node whose last attempt FAILED spelled out with git's own
56
109
  * error, since that is the line a human acts on — or, with `--json`, as the one object (extensible:
57
- * the instance has more to report eventually). An instance with no data plane says so — "no
58
- * registered Machine composes a Sandbox" is the answer, not an empty list.
110
+ * the instance has more to report eventually). An instance with no data plane says so — "it runs
111
+ * outside a cluster" is the answer, not an empty list. A deployed Orchestrator always has one: it
112
+ * wires its kube client whatever the current Assembly composes (ADR-0069).
59
113
  *
60
114
  * Two failures, two consequences (ADR-0004/0051): a cache that is ABSENT on the node (a cold clone
61
115
  * failed) parks every Workspace that needs it there; a cache that is PRESENT but whose last fetch
@@ -65,18 +119,76 @@ export async function status(args: string[], io: Io): Promise<number> {
65
119
  * Exit 0 even with Repos failing: this is a report, and a degraded Repo is a state the instance
66
120
  * is serving in, not a failure of the asking.
67
121
  */
68
- async function instanceStatus(client: JR2Client, io: Io, json: boolean): Promise<number> {
69
- const report = await client.repos();
122
+ async function instanceStatus(client: JR2Client, io: Io, url: string, json: boolean): Promise<number> {
123
+ const report = { assembly: await readAssembly(io.fetch ?? globalThis.fetch, url), ...(await client.repos()) };
70
124
  if (json) result(io, report);
71
125
  else io.stdout(renderInstanceStatus(report));
72
126
  return 0;
73
127
  }
74
128
 
75
- /** The human table — one line per Repo, one per node under it, then the hints for what was seen. */
76
- export function renderInstanceStatus({ dataPlane, repos }: { dataPlane: boolean; repos: RepoStatus[] }): string {
77
- let out = dataPlane
129
+ /** `GET /assembly` (ADR-0069): open, like `GET /workflows`, so it needs no token. An answer that
130
+ * cannot be read is reported as the reason, never as a failure of the verb. */
131
+ async function readAssembly(fetchImpl: FetchLike, url: string): Promise<AssemblyView | { error: string }> {
132
+ try {
133
+ const res = await fetchImpl(`${url.replace(/\/+$/, "")}/assembly`);
134
+ if (!res.ok) return { error: `GET /assembly answered HTTP ${res.status}` };
135
+ // Read loosely: a `held` that is absent or not a list reads as none held, so a report never
136
+ // fails on the shape of one field (ADR-0048: a report is not a failure of the asking).
137
+ const view = (await res.json()) as Partial<AssemblyView>;
138
+ return {
139
+ kitVersion: view.kitVersion ?? "",
140
+ current: view.current ?? null,
141
+ held: Array.isArray(view.held) ? view.held : [],
142
+ };
143
+ } catch (e) {
144
+ return { error: e instanceof Error ? e.message : String(e) };
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The Assembly lines (ADR-0069): one per Assembly a live run holds, the current one first and so
150
+ * marked, each with how many live runs hold it — or why there is none. A run holds the Assembly it
151
+ * started under, so after a publish the previous one stays on the volume while a run still holds it.
152
+ * The current Assembly prints first, whatever order `held` uses; the other held Assemblies follow
153
+ * in the server's order.
154
+ */
155
+ function assemblyLines(view: AssemblyView | { error: string } | undefined): string {
156
+ if (view === undefined) return "";
157
+ if ("error" in view) return `assembly: unknown — ${view.error}\n`;
158
+ const current = view.current;
159
+ const heldList = view.held;
160
+ const line = (hash: string, kitVersion: string, runs: number, isCurrent: boolean): string =>
161
+ `assembly: ${hash} (kit v${kitVersion}${isCurrent ? ", current" : ""}), ` +
162
+ `${runs} live run${runs === 1 ? "" : "s"} hold${runs === 1 ? "s" : ""} it\n`;
163
+ let out = "";
164
+ if (current) {
165
+ const runs = heldList.find((h) => h.hash === current.hash)?.runs.length ?? 0;
166
+ out += line(current.hash, current.kitVersion, runs, true);
167
+ } else {
168
+ out += "assembly: none — `jr2 up` publishes\n";
169
+ }
170
+ for (const held of heldList) {
171
+ if (held.hash === current?.hash) continue;
172
+ out += line(held.hash, held.kitVersion, held.runs.length, false);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /** The human table — the held Assemblies, current first, one line per Repo, one per node under it, then the
178
+ * hints for what was seen. */
179
+ export function renderInstanceStatus({
180
+ assembly,
181
+ dataPlane,
182
+ repos,
183
+ }: {
184
+ assembly?: AssemblyView | { error: string };
185
+ dataPlane: boolean;
186
+ repos: RepoStatus[];
187
+ }): string {
188
+ let out = assemblyLines(assembly);
189
+ out += dataPlane
78
190
  ? "data plane: yes\n"
79
- : "data plane: none — this instance has no data plane (no registered Machine composes a Sandbox)\n";
191
+ : "data plane: none — this Orchestrator runs outside a cluster, so it provisions no Sandbox (ADR-0069)\n";
80
192
  let absent = 0;
81
193
  let stale = 0;
82
194
  for (const repo of repos) {