@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
package/src/commands/send.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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,
|
|
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 } =
|
|
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,
|
|
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?.();
|
package/src/commands/status.ts
CHANGED
|
@@ -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):
|
|
5
|
-
//
|
|
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 {
|
|
15
|
-
import type
|
|
16
|
-
import {
|
|
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 } =
|
|
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 — "
|
|
58
|
-
*
|
|
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
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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) {
|