@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/src/cli.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  // Top-level dispatch (ADR-0009). `argv[0]` is the verb; the rest is handed to that command, which does
2
- // its own `parseArgs`. `main` returns an exit code (the bin sets `process.exitCode`) and never throws:
3
- // a command error becomes an `error: …` line on stderr + code 1. Codes: 0 ok, 1 runtime error, 2 usage.
2
+ // its own `parseArgs` against its entry in the command table (`command-table.ts`). Every help prints
3
+ // from that table too: `jr2 --help` the whole of it, `jr2 <verb> --help` (or `-h`) one entry — and
4
+ // then the verb does NOT run, so `jr2 up --help` never converges a cluster. `main` returns an exit
5
+ // code (the bin sets `process.exitCode`) and never throws: a command error becomes an `error: …` line
6
+ // on stderr + code 1. Codes: 0 ok, 1 runtime error, 2 usage.
4
7
 
5
8
  import { activity, defaultIo, type Io } from "./output.ts";
6
9
  import { JR2HttpError } from "./client.ts";
@@ -12,42 +15,12 @@ import { status } from "./commands/status.ts";
12
15
  import { logs } from "./commands/logs.ts";
13
16
  import { send } from "./commands/send.ts";
14
17
  import { up } from "./commands/up.ts";
18
+ import { check } from "./commands/check.ts";
15
19
  import { down } from "./commands/down.ts";
16
20
  import { gc } from "./commands/gc.ts";
17
21
  import { kit } from "./commands/kit.ts";
18
22
  import { version } from "./commands/version.ts";
19
-
20
- const USAGE = `jr2 — orchestrate agentic workflows (ADR-0009)
21
-
22
- usage: jr2 <command> [args]
23
-
24
- version [--local] [--json] what runs here (this jr2, the global that handed off, the
25
- Instance's Kit) and what the cluster runs; --local skips the cluster
26
- init [dir] [--name <n>] scaffold a new instance folder
27
- up [--yes] [--force] converge the current kube context to this instance (ADR-0019)
28
- down [--all] remove the instance from the cluster (--all: operator too)
29
- gc [--dry-run] [--repo-ttl 7d] remove jr2's images that no live instance names (ADR-0039), and
30
- Repo resources no Machine binds and no run attached lately (ADR-0051)
31
- kit push <registry> mirror the published kit images into a registry (ADR-0044)
32
- run <workflow> [--input <json>] start a run; stream activity, print terminal result
33
- [--detach] ...or just print the runId and return
34
- runs list live runs
35
- status [runId] print a run's current status (read-through),
36
- [--json] or the instance's Repos, per node, when given none (ADR-0048/0051)
37
- logs <runId> [-f] replay a run's status; -f to follow until it settles
38
- send <runId> --event CANCEL abandon a live run
39
- send <runId> --gate <gate> --event <name> [--input <json>]
40
- deliver a workflow event to an open gate
41
-
42
- run ids: any <runId> above may be abbreviated to a unique prefix (4+ chars, git-style);
43
- an ambiguous prefix lists the candidates and fails rather than guessing
44
-
45
- global (run verbs): -n/--namespace <ns>, --context <ctx> address the deployment (ADR-0019);
46
- --url <u> / JR2_URL attaches to a specific orchestrator (skips kube entirely)
47
-
48
- output: result verbs (run, runs, status <runId>, send, logs) print one JSON result on stdout and
49
- activity on stderr, so \`jr2 run … | jq\` yields the result; report verbs (version, bare
50
- status) print a table on stdout, or the one object with --json`;
23
+ import { asksForHelp, commandsFor, GLOBAL, renderCommand, renderHelp } from "./command-table.ts";
51
24
 
52
25
  /**
53
26
  * The catch-all half of skew reporting: a route-shaped failure gets ONE extra line naming what the
@@ -74,6 +47,15 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
74
47
  // Before any verb, so the instance's `.env` is in `process.env` by the time a command imports
75
48
  // `jr2.config.ts` (whose deployment-varying values are read from there — ADR-0019).
76
49
  loadDotenv(io);
50
+ const entries = cmd === undefined ? [] : commandsFor(cmd);
51
+ if (entries.length && asksForHelp(entries, rest)) {
52
+ activity(io, entries.map(renderCommand).join("\n\n"));
53
+ return 0;
54
+ }
55
+ if (cmd !== undefined && (GLOBAL.help as readonly string[]).includes(cmd)) {
56
+ activity(io, renderHelp());
57
+ return 0;
58
+ }
77
59
  switch (cmd) {
78
60
  case "init":
79
61
  return await init(rest, io);
@@ -87,6 +69,8 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
87
69
  return await logs(rest, io);
88
70
  case "send":
89
71
  return await send(rest, io);
72
+ case "check":
73
+ return await check(rest, io);
90
74
  case "up":
91
75
  return await up(rest, io);
92
76
  case "down":
@@ -98,17 +82,12 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
98
82
  case "version":
99
83
  case "--version":
100
84
  return await version(rest, io);
101
- case "help":
102
- case "--help":
103
- case "-h":
104
- activity(io, USAGE);
105
- return 0;
106
85
  case undefined:
107
- activity(io, USAGE);
86
+ activity(io, renderHelp());
108
87
  return 2;
109
88
  default:
110
89
  activity(io, `unknown command: ${cmd}`);
111
- activity(io, USAGE);
90
+ activity(io, renderHelp());
112
91
  return 2;
113
92
  }
114
93
  } catch (err) {
package/src/client.ts CHANGED
@@ -6,7 +6,15 @@
6
6
  // `fetchImpl` is injectable so tests drive it with a hono `app.request` (no socket) the same way the
7
7
  // orchestrator's own http tests do; in production it defaults to the global `fetch`.
8
8
 
9
- import type { MachineDoc, RepoStatus } from "@jr2/orchestrator";
9
+ import type {
10
+ ChildErrorMarker,
11
+ DispatchedBy,
12
+ MachineDoc,
13
+ PlacingMarker,
14
+ RepoStatus,
15
+ RunChild,
16
+ RunWaiting,
17
+ } from "@jr2/orchestrator/internal";
10
18
  import { parseSSE } from "./sse.ts";
11
19
 
12
20
  /** A run's current observable state — mirrors the orchestrator's `RunStatus` (run-host.ts). */
@@ -17,19 +25,66 @@ export type RunStatus = {
17
25
  status: string;
18
26
  value: unknown;
19
27
  context: unknown;
20
- /** Why the host set this status, for statuses the Machine did not choose — `drifted` says the
21
- * workflow changed shape since the run was saved, and names both fingerprints (ADR-0030). */
28
+ /** The live child machines beneath the root, context-free — where most of a run is. */
29
+ children?: RunChild[];
30
+ /** Every Workspace waiting for capacity, and why (ADR-0064). Optional: an orchestrator older
31
+ * than ADR-0064 does not send it. */
32
+ waiting?: RunWaiting[];
33
+ /** Why the host set this status, for statuses the Machine did not choose — `drifted` names the
34
+ * Assembly hash and why this Orchestrator cannot read it: not on the volume, published for
35
+ * another Kit version, or does not load (ADR-0030, ADR-0069). */
22
36
  reason?: string;
37
+ /** The hash of the Assembly the run started under, and holds while it is live (ADR-0069).
38
+ * Absent for a run no Assembly started. */
39
+ assembly?: string;
40
+ /** The run binds at least one Chart (ADR-0070): its Machine is the Chart wrapper's with each bound
41
+ * Open body's Chart compiled in, which `GET /workflows/:name/runs/:runId/machine` serves.
42
+ * Absent when the run binds none. */
43
+ bound?: true;
44
+ /** The hashes of the Charts the run binds now, distinct and sorted (ADR-0070). */
45
+ chartHashes?: string[];
46
+ /** The run that dispatched this one through a `dispatch` Effect, the caller's slot, its ledger
47
+ * row id (the idempotency key) and this run's depth in the chain (ADR-0071); `instance` names a
48
+ * foreign caller, as opaque text. Absent for a run nobody dispatched. */
49
+ dispatchedBy?: DispatchedBy;
50
+ /** The Machine's output, for a run that is no longer live (ADR-0071): what a `dispatch` with
51
+ * `wait` settles with. Never for a live run. */
52
+ output?: unknown;
53
+ /** The runs this run's `dispatch` Effects started, oldest first: the slot, the run, and when its
54
+ * ledger row was written (ADR-0071). Absent when it started none. */
55
+ dispatched?: Array<{ slot: string; run: string; at: string }>;
56
+ };
57
+
58
+ /** Where a Workflow came from (ADR-0070): a Machine in the Assembly, a Chart file in the Assembly, or
59
+ * a runtime Chart put by `PUT /workflows/:wrapper/charts/:name`. */
60
+ export type WorkflowOrigin = "machine" | "file" | "runtime";
61
+
62
+ /** One entry of `GET /workflows` (ADR-0070) — mirrors the orchestrator's `WorkflowListing`
63
+ * (run-host.ts). A Chart wrapper's entry carries its Open body slot, whether its door takes a Chart
64
+ * one-shot, and its named Charts, inside its entry and not beside it. `unrunnable` is why a
65
+ * Workflow cannot start now (a runtime Chart a publish no longer admits). */
66
+ export type WorkflowListing = {
67
+ name: string;
68
+ origin: WorkflowOrigin;
69
+ wrapper?: {
70
+ body: string;
71
+ oneShot: boolean;
72
+ charts: Array<{ name: string; origin: "file" | "runtime"; unrunnable?: string }>;
73
+ };
74
+ unrunnable?: string;
23
75
  };
24
76
 
25
77
  /** One item on a run's observation feed — mirrors the orchestrator's `RunFeedEvent`. */
26
78
  export type RunFeedEvent =
27
79
  | { kind: "status"; status: RunStatus }
28
80
  | { kind: "emit"; event: { type: string } & Record<string, unknown> }
29
- | { kind: "retry"; child: string; attempt: number; reason: string };
81
+ | { kind: "retry"; child: string; attempt: number; reason: string }
82
+ | PlacingMarker
83
+ | ChildErrorMarker;
30
84
 
31
- /** A run-control event posted to a live run (ADR-0013): CANCEL is the vocabulary that is left. */
32
- export type RunEvent = { type: string };
85
+ /** A run-control event posted to a live run (ADR-0013): CANCEL is the vocabulary that is left.
86
+ * `keep` leaves a cancelled run's Workspaces instead of destroying them (ADR-0025). */
87
+ export type RunEvent = { type: string; keep?: boolean };
33
88
 
34
89
  /** The subset of `fetch` the client uses. `globalThis.fetch` and hono's `app.request` both satisfy it. */
35
90
  export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
@@ -57,16 +112,27 @@ export class JR2HttpError extends Error {
57
112
  readonly status: number;
58
113
  readonly path: string;
59
114
  readonly instance?: InstanceIdentity;
115
+ /** The door's report lines, when the answer carried them: a refused Chart is
116
+ * `422 { error: "refused", report }` (ADR-0070), one line per rule it fails. */
117
+ readonly report?: string[];
60
118
 
61
- constructor(message: string, status: number, path: string, instance?: InstanceIdentity) {
119
+ constructor(message: string, status: number, path: string, instance?: InstanceIdentity, report?: string[]) {
62
120
  super(message);
63
121
  this.name = "JR2HttpError";
64
122
  this.status = status;
65
123
  this.path = path;
66
124
  this.instance = instance;
125
+ if (report) this.report = report;
67
126
  }
68
127
  }
69
128
 
129
+ /** The path of a Workflow's routes. The name is ONE path segment, encoded: a named Chart's
130
+ * `<wrapper>/<name>` rides as `<wrapper>%2F<name>` (ADR-0070), which the Orchestrator reads as the
131
+ * same Workflow as `/workflows/<wrapper>/charts/<name>`. */
132
+ export function workflowPath(workflow: string): string {
133
+ return `/workflows/${encodeURIComponent(workflow)}`;
134
+ }
135
+
70
136
  export class JR2Client {
71
137
  readonly baseUrl: string;
72
138
  private readonly fetchImpl: FetchLike;
@@ -85,20 +151,40 @@ export class JR2Client {
85
151
  return this.token ? { ...extra, authorization: `Bearer ${this.token}` } : extra;
86
152
  }
87
153
 
88
- /** `GET /workflows` — names of the registered workflows. */
89
- async workflows(): Promise<string[]> {
90
- return (await this.json(await this.fetchImpl(`${this.baseUrl}/workflows`), "/workflows")) as string[];
154
+ /** `GET /workflows` — every registered Workflow with its origin, each Chart wrapper's Charts inside
155
+ * its entry (ADR-0070). An orchestrator older than ADR-0070 answers bare names; each reads as a
156
+ * Machine, so the listing has one shape for every caller. */
157
+ async workflows(): Promise<WorkflowListing[]> {
158
+ const body = await this.json(await this.fetchImpl(`${this.baseUrl}/workflows`), "/workflows");
159
+ // An answer that is no listing is not "none registered": say what came back (`jr2 runs` prints
160
+ // `workflows: unknown — ...` for it, never "none registered").
161
+ if (!Array.isArray(body)) throw new Error("GET /workflows: the answer is not a listing");
162
+ return body.map((w: unknown) =>
163
+ typeof w === "string" ? { name: w, origin: "machine" as const } : (w as WorkflowListing),
164
+ );
91
165
  }
92
166
 
93
167
  /** `GET /workflows/:name/machine` — the workflow's Machine as the Console's DTO. */
94
168
  async machine(workflow: string): Promise<MachineDoc> {
95
- const res = await this.fetchImpl(`${this.baseUrl}/workflows/${encodeURIComponent(workflow)}/machine`);
169
+ const res = await this.fetchImpl(`${this.baseUrl}${workflowPath(workflow)}/machine`);
96
170
  return (await this.json(res, "/workflows/:name/machine")) as MachineDoc;
97
171
  }
98
172
 
99
- /** `POST /workflows/:name/runs` — start a run; unknown workflow → 404 → throws. */
173
+ /** `GET /workflows/:name/runs/:runId/machine` — the run's bound Machine (ADR-0070): the Chart
174
+ * wrapper's Machine with each bound Open body's Chart compiled in. An unknown run → undefined. */
175
+ async runMachine(workflow: string, runId: string): Promise<MachineDoc | undefined> {
176
+ const res = await this.fetchImpl(
177
+ `${this.baseUrl}${workflowPath(workflow)}/runs/${encodeURIComponent(runId)}/machine`,
178
+ );
179
+ if (res.status === 404) return undefined;
180
+ return (await this.json(res, "/workflows/:name/runs/:runId/machine")) as MachineDoc;
181
+ }
182
+
183
+ /** `POST /workflows/:name/runs` — start a run; unknown workflow → 404 → throws. A named Chart's
184
+ * name `<wrapper>/<name>` rides the path as one encoded segment (ADR-0070); a refused Chart in
185
+ * the input → 422 → throws with the door's `report`. */
100
186
  async start(workflow: string, input: Record<string, unknown> = {}): Promise<{ runId: string; instanceId: string }> {
101
- const res = await this.fetchImpl(`${this.baseUrl}/workflows/${encodeURIComponent(workflow)}/runs`, {
187
+ const res = await this.fetchImpl(`${this.baseUrl}${workflowPath(workflow)}/runs`, {
102
188
  method: "POST",
103
189
  headers: this.headers(JSON_HEADERS),
104
190
  body: JSON.stringify(input),
@@ -142,7 +228,8 @@ export class JR2Client {
142
228
 
143
229
  /** `POST /runs/:runId/gates/:gate/events` — deliver a workflow-defined event to an open gate
144
230
  * (ADR-0011). The body is `{ type, ...input }`, validated against the event's schema host-side;
145
- * an unknown gate (404) or rejected payload (400) surfaces as a throw. */
231
+ * an unknown gate (404), a rejected payload (400) or an answer the run would not take now (409,
232
+ * ADR-0013) surfaces as a throw. */
146
233
  async sendToGate(runId: string, gate: string, event: { type: string } & Record<string, unknown>): Promise<void> {
147
234
  const res = await this.fetchImpl(
148
235
  `${this.baseUrl}/runs/${encodeURIComponent(runId)}/gates/${encodeURIComponent(gate)}/events`,
@@ -165,26 +252,48 @@ export class JR2Client {
165
252
  * `GET /runs/:runId/events` — the SSE feed as an async-iterable of `RunFeedEvent`. The current status
166
253
  * is replayed first (attach), then deltas + author `emit`s until the terminal status (or the consumer
167
254
  * breaks, which cancels the stream). A settled run streams its final status once and ends.
255
+ *
256
+ * A feed that ends BEFORE a terminal status is re-attached, never read as the run's end: the
257
+ * server closes a reader that fell behind (a paused pager on `jr2 run | less`), and the feed is
258
+ * level-triggered, so the re-attach opens with where the run stands now (ADR-0022). What falls in
259
+ * the gap is activity — Emits, retries — never state. The one end that IS the run's own without
260
+ * a terminal status is `gone`: the run was stopped and is parked (ADR-0025); the generator ends.
168
261
  */
169
262
  async *events(runId: string): AsyncGenerator<RunFeedEvent> {
170
- const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/events`, {
171
- headers: this.headers({ accept: "text/event-stream" }),
172
- });
173
- if (res.status === 404) {
174
- throw new JR2HttpError(`no run "${runId}"`, 404, "/runs/:runId/events", await this.identify());
175
- }
176
- if (!res.body) return;
177
- for await (const frame of parseSSE(res.body)) {
178
- if (frame.event === "emit") {
179
- yield { kind: "emit", event: JSON.parse(frame.data) as { type: string } & Record<string, unknown> };
180
- } else if (frame.event === "retry") {
181
- yield { kind: "retry", ...(JSON.parse(frame.data) as { child: string; attempt: number; reason: string }) };
182
- } else if (frame.event === "status") {
183
- yield { kind: "status", status: JSON.parse(frame.data) as RunStatus };
263
+ for (;;) {
264
+ const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/events`, {
265
+ headers: this.headers({ accept: "text/event-stream" }),
266
+ });
267
+ if (res.status === 404) {
268
+ throw new JR2HttpError(`no run "${runId}"`, 404, "/runs/:runId/events", await this.identify());
269
+ }
270
+ if (!res.body) return;
271
+ let settled = false;
272
+ for await (const frame of parseSSE(res.body)) {
273
+ if (frame.event === "emit") {
274
+ yield { kind: "emit", event: JSON.parse(frame.data) as { type: string } & Record<string, unknown> };
275
+ } else if (frame.event === "retry") {
276
+ yield { kind: "retry", ...(JSON.parse(frame.data) as { child: string; attempt: number; reason: string }) };
277
+ } else if (frame.event === "status") {
278
+ const status = JSON.parse(frame.data) as RunStatus;
279
+ settled = status.status !== "active";
280
+ yield { kind: "status", status };
281
+ } else if (frame.event === "placing" || frame.event === "placed") {
282
+ // A wait for capacity starting and ending (ADR-0064) — activity, never a status.
283
+ yield { ...(JSON.parse(frame.data) as object), kind: frame.event } as PlacingMarker;
284
+ } else if (frame.event === "child-error") {
285
+ // A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024).
286
+ yield { ...(JSON.parse(frame.data) as object), kind: "child-error" } as ChildErrorMarker;
287
+ } else if (frame.event === "gone") {
288
+ // The run left the live set without settling — stopped, parked for the next boot's
289
+ // restore (ADR-0025). This end is the run's own (ADR-0022): nothing to re-attach to.
290
+ return;
291
+ }
292
+ // Any other frame (a Turn marker — ADR-0023 — or a kind this CLI predates) is skipped, not
293
+ // misread as a status: `jr2 logs -f` decides "settled" off `status.status`, and a marker
294
+ // parsed as a status would end the follow mid-run.
184
295
  }
185
- // Any other frame (a Turn marker — ADR-0023 — or a kind this CLI predates) is skipped, not
186
- // misread as a status: `jr2 logs -f` decides "settled" off `status.status`, and a marker
187
- // parsed as a status would end the follow mid-run.
296
+ if (settled) return;
188
297
  }
189
298
  }
190
299
 
@@ -209,10 +318,12 @@ export class JR2Client {
209
318
  const text = await res.text();
210
319
  const body = text ? JSON.parse(text) : undefined;
211
320
  if (!res.ok) {
212
- const message = (body as { error?: string } | undefined)?.error ?? `HTTP ${res.status}`;
321
+ const { error, report } = (body ?? {}) as { error?: string; report?: unknown };
322
+ const message = error ?? `HTTP ${res.status}`;
323
+ const lines = Array.isArray(report) ? report.map(String) : undefined;
213
324
  // Probe HERE, not at the catch site: by the time the error reaches `cli.ts` the command's
214
325
  // `finally` has closed the port-forward, and there is nothing left to ask.
215
- throw new JR2HttpError(message, res.status, path, await this.identify());
326
+ throw new JR2HttpError(message, res.status, path, await this.identify(), lines);
216
327
  }
217
328
  return body;
218
329
  }