@jr2/cli 0.3.0 → 0.4.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,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,10 +11,13 @@
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.
13
17
 
14
18
  import { parseArgs } from "node:util";
15
- import type { RepoStatus } from "@jr2/orchestrator";
16
- import { JR2Client } from "../client.ts";
19
+ import type { AssemblyView, RepoStatus } from "@jr2/orchestrator";
20
+ import { JR2Client, type FetchLike, type RunStatus, workflowPath } from "../client.ts";
17
21
  import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
18
22
  import { activity, result, type Io } from "../output.ts";
19
23
  import { resolveRunId } from "../run-id.ts";
@@ -32,7 +36,7 @@ export async function status(args: string[], io: Io): Promise<number> {
32
36
  const target = await resolveTarget(io, targetOptions(values));
33
37
  try {
34
38
  const client = new JR2Client(target.url, io.fetch, target.token);
35
- if (given === undefined) return await instanceStatus(client, io, values.json === true);
39
+ if (given === undefined) return await instanceStatus(client, io, target.url, values.json === true);
36
40
  const ref = await resolveRunId(client, given);
37
41
  if (!ref.ok) {
38
42
  activity(io, ref.message);
@@ -44,18 +48,32 @@ export async function status(args: string[], io: Io): Promise<number> {
44
48
  return 1;
45
49
  }
46
50
  result(io, s);
51
+ const note = boundActivity(s);
52
+ if (note) activity(io, note);
47
53
  return 0;
48
54
  } finally {
49
55
  target.close?.();
50
56
  }
51
57
  }
52
58
 
59
+ /** The `chart: bound` line for a run that binds a Chart (ADR-0070), or undefined for one that binds
60
+ * none. The Workflow name rides the route as one encoded segment. */
61
+ export function boundActivity(s: RunStatus): string | undefined {
62
+ if (s.bound !== true) return undefined;
63
+ const route = `GET ${workflowPath(s.workflow)}/runs/${encodeURIComponent(s.runId)}/machine`;
64
+ // A drifted run's Chart may no longer admit (D-B): the route then draws the slot's own subgraph
65
+ // and names why in `chartsNotDrawn`, so the line does not claim more than the route draws.
66
+ if (s.status === "drifted") return `chart: bound, drifted — ${route} names each Chart it cannot draw`;
67
+ return `chart: bound — ${route} draws the Machine this run binds`;
68
+ }
69
+
53
70
  /**
54
- * The instance's own status: the data-plane switch and every Repo resource. As a table on stdout —
71
+ * The instance's own status: the held Assemblies, the data-plane switch and every Repo resource. As a table on stdout —
55
72
  * every node's state per Repo, and every node whose last attempt FAILED spelled out with git's own
56
73
  * 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.
74
+ * the instance has more to report eventually). An instance with no data plane says so — "it runs
75
+ * outside a cluster" is the answer, not an empty list. A deployed Orchestrator always has one: it
76
+ * wires its kube client whatever the current Assembly composes (ADR-0069).
59
77
  *
60
78
  * Two failures, two consequences (ADR-0004/0051): a cache that is ABSENT on the node (a cold clone
61
79
  * failed) parks every Workspace that needs it there; a cache that is PRESENT but whose last fetch
@@ -65,18 +83,76 @@ export async function status(args: string[], io: Io): Promise<number> {
65
83
  * Exit 0 even with Repos failing: this is a report, and a degraded Repo is a state the instance
66
84
  * is serving in, not a failure of the asking.
67
85
  */
68
- async function instanceStatus(client: JR2Client, io: Io, json: boolean): Promise<number> {
69
- const report = await client.repos();
86
+ async function instanceStatus(client: JR2Client, io: Io, url: string, json: boolean): Promise<number> {
87
+ const report = { assembly: await readAssembly(io.fetch ?? globalThis.fetch, url), ...(await client.repos()) };
70
88
  if (json) result(io, report);
71
89
  else io.stdout(renderInstanceStatus(report));
72
90
  return 0;
73
91
  }
74
92
 
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
93
+ /** `GET /assembly` (ADR-0069): open, like `GET /workflows`, so it needs no token. An answer that
94
+ * cannot be read is reported as the reason, never as a failure of the verb. */
95
+ async function readAssembly(fetchImpl: FetchLike, url: string): Promise<AssemblyView | { error: string }> {
96
+ try {
97
+ const res = await fetchImpl(`${url.replace(/\/+$/, "")}/assembly`);
98
+ if (!res.ok) return { error: `GET /assembly answered HTTP ${res.status}` };
99
+ // Read loosely: a `held` that is absent or not a list reads as none held, so a report never
100
+ // fails on the shape of one field (ADR-0048: a report is not a failure of the asking).
101
+ const view = (await res.json()) as Partial<AssemblyView>;
102
+ return {
103
+ kitVersion: view.kitVersion ?? "",
104
+ current: view.current ?? null,
105
+ held: Array.isArray(view.held) ? view.held : [],
106
+ };
107
+ } catch (e) {
108
+ return { error: e instanceof Error ? e.message : String(e) };
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The Assembly lines (ADR-0069): one per Assembly a live run holds, the current one first and so
114
+ * marked, each with how many live runs hold it — or why there is none. A run holds the Assembly it
115
+ * started under, so after a publish the previous one stays on the volume while a run still holds it.
116
+ * The current Assembly prints first, whatever order `held` uses; the other held Assemblies follow
117
+ * in the server's order.
118
+ */
119
+ function assemblyLines(view: AssemblyView | { error: string } | undefined): string {
120
+ if (view === undefined) return "";
121
+ if ("error" in view) return `assembly: unknown — ${view.error}\n`;
122
+ const current = view.current;
123
+ const heldList = view.held;
124
+ const line = (hash: string, kitVersion: string, runs: number, isCurrent: boolean): string =>
125
+ `assembly: ${hash} (kit v${kitVersion}${isCurrent ? ", current" : ""}), ` +
126
+ `${runs} live run${runs === 1 ? "" : "s"} hold${runs === 1 ? "s" : ""} it\n`;
127
+ let out = "";
128
+ if (current) {
129
+ const runs = heldList.find((h) => h.hash === current.hash)?.runs.length ?? 0;
130
+ out += line(current.hash, current.kitVersion, runs, true);
131
+ } else {
132
+ out += "assembly: none — `jr2 up` publishes\n";
133
+ }
134
+ for (const held of heldList) {
135
+ if (held.hash === current?.hash) continue;
136
+ out += line(held.hash, held.kitVersion, held.runs.length, false);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** The human table — the held Assemblies, current first, one line per Repo, one per node under it, then the
142
+ * hints for what was seen. */
143
+ export function renderInstanceStatus({
144
+ assembly,
145
+ dataPlane,
146
+ repos,
147
+ }: {
148
+ assembly?: AssemblyView | { error: string };
149
+ dataPlane: boolean;
150
+ repos: RepoStatus[];
151
+ }): string {
152
+ let out = assemblyLines(assembly);
153
+ out += dataPlane
78
154
  ? "data plane: yes\n"
79
- : "data plane: none — this instance has no data plane (no registered Machine composes a Sandbox)\n";
155
+ : "data plane: none — this Orchestrator runs outside a cluster, so it provisions no Sandbox (ADR-0069)\n";
80
156
  let absent = 0;
81
157
  let stale = 0;
82
158
  for (const repo of repos) {