@jr2/cli 0.2.1 → 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.
package/src/cli.ts CHANGED
@@ -31,11 +31,15 @@ usage: jr2 <command> [args]
31
31
  kit push <registry> mirror the published kit images into a registry (ADR-0044)
32
32
  run <workflow> [--input <json>] start a run; stream activity, print terminal result
33
33
  [--detach] ...or just print the runId and return
34
- runs list live runs
34
+ runs list live runs; "placing" names the Workspaces that wait for
35
+ capacity (ADR-0064), and "status <runId>" says why; the
36
+ Workflows and their named Charts on stderr (ADR-0070)
37
+ [--workflows] ...or print that listing as the result (see "runs --help")
35
38
  status [runId] print a run's current status (read-through),
36
39
  [--json] or the instance's Repos, per node, when given none (ADR-0048/0051)
37
40
  logs <runId> [-f] replay a run's status; -f to follow until it settles
38
- send <runId> --event CANCEL abandon a live run
41
+ send <runId> --event CANCEL abandon a live run and destroy its Workspaces
42
+ [--keep] ...or keep them for inspection until the idle timeout
39
43
  send <runId> --gate <gate> --event <name> [--input <json>]
40
44
  deliver a workflow event to an open gate
41
45
 
package/src/client.ts CHANGED
@@ -6,7 +6,7 @@
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 { ChildErrorMarker, MachineDoc, PlacingMarker, RepoStatus, RunChild, RunWaiting } from "@jr2/orchestrator";
10
10
  import { parseSSE } from "./sse.ts";
11
11
 
12
12
  /** A run's current observable state — mirrors the orchestrator's `RunStatus` (run-host.ts). */
@@ -17,19 +17,56 @@ export type RunStatus = {
17
17
  status: string;
18
18
  value: unknown;
19
19
  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). */
20
+ /** The live child machines beneath the root, context-free — where most of a run is. */
21
+ children?: RunChild[];
22
+ /** Every Workspace waiting for capacity, and why (ADR-0064). Optional: an orchestrator older
23
+ * than ADR-0064 does not send it. */
24
+ waiting?: RunWaiting[];
25
+ /** Why the host set this status, for statuses the Machine did not choose — `drifted` names the
26
+ * Assembly hash and why this Orchestrator cannot read it: not on the volume, published for
27
+ * another Kit version, or does not load (ADR-0030, ADR-0069). */
22
28
  reason?: string;
29
+ /** The hash of the Assembly the run started under, and holds while it is live (ADR-0069).
30
+ * Absent for a run no Assembly started. */
31
+ assembly?: string;
32
+ /** The run binds at least one Chart (ADR-0070): its Machine is the Chart wrapper's with each bound
33
+ * Open body's Chart compiled in, which `GET /workflows/:name/runs/:runId/machine` serves.
34
+ * Absent when the run binds none. */
35
+ bound?: true;
36
+ /** The hashes of the Charts the run binds now, distinct and sorted (ADR-0070). */
37
+ chartHashes?: string[];
38
+ };
39
+
40
+ /** Where a Workflow came from (ADR-0070): a Machine in the Assembly, a Chart file in the Assembly, or
41
+ * a runtime Chart put by `PUT /workflows/:wrapper/charts/:name`. */
42
+ export type WorkflowOrigin = "machine" | "file" | "runtime";
43
+
44
+ /** One entry of `GET /workflows` (ADR-0070) — mirrors the orchestrator's `WorkflowListing`
45
+ * (run-host.ts). A Chart wrapper's entry carries its Open body slot, whether its door takes a Chart
46
+ * one-shot, and its named Charts, inside its entry and not beside it. `unrunnable` is why a
47
+ * Workflow cannot start now (a runtime Chart a publish no longer admits). */
48
+ export type WorkflowListing = {
49
+ name: string;
50
+ origin: WorkflowOrigin;
51
+ wrapper?: {
52
+ body: string;
53
+ oneShot: boolean;
54
+ charts: Array<{ name: string; origin: "file" | "runtime"; unrunnable?: string }>;
55
+ };
56
+ unrunnable?: string;
23
57
  };
24
58
 
25
59
  /** One item on a run's observation feed — mirrors the orchestrator's `RunFeedEvent`. */
26
60
  export type RunFeedEvent =
27
61
  | { kind: "status"; status: RunStatus }
28
62
  | { kind: "emit"; event: { type: string } & Record<string, unknown> }
29
- | { kind: "retry"; child: string; attempt: number; reason: string };
63
+ | { kind: "retry"; child: string; attempt: number; reason: string }
64
+ | PlacingMarker
65
+ | ChildErrorMarker;
30
66
 
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 };
67
+ /** A run-control event posted to a live run (ADR-0013): CANCEL is the vocabulary that is left.
68
+ * `keep` leaves a cancelled run's Workspaces instead of destroying them (ADR-0025). */
69
+ export type RunEvent = { type: string; keep?: boolean };
33
70
 
34
71
  /** The subset of `fetch` the client uses. `globalThis.fetch` and hono's `app.request` both satisfy it. */
35
72
  export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
@@ -57,16 +94,27 @@ export class JR2HttpError extends Error {
57
94
  readonly status: number;
58
95
  readonly path: string;
59
96
  readonly instance?: InstanceIdentity;
97
+ /** The door's report lines, when the answer carried them: a refused Chart is
98
+ * `422 { error: "refused", report }` (ADR-0070), one line per rule it fails. */
99
+ readonly report?: string[];
60
100
 
61
- constructor(message: string, status: number, path: string, instance?: InstanceIdentity) {
101
+ constructor(message: string, status: number, path: string, instance?: InstanceIdentity, report?: string[]) {
62
102
  super(message);
63
103
  this.name = "JR2HttpError";
64
104
  this.status = status;
65
105
  this.path = path;
66
106
  this.instance = instance;
107
+ if (report) this.report = report;
67
108
  }
68
109
  }
69
110
 
111
+ /** The path of a Workflow's routes. The name is ONE path segment, encoded: a named Chart's
112
+ * `<wrapper>/<name>` rides as `<wrapper>%2F<name>` (ADR-0070), which the Orchestrator reads as the
113
+ * same Workflow as `/workflows/<wrapper>/charts/<name>`. */
114
+ export function workflowPath(workflow: string): string {
115
+ return `/workflows/${encodeURIComponent(workflow)}`;
116
+ }
117
+
70
118
  export class JR2Client {
71
119
  readonly baseUrl: string;
72
120
  private readonly fetchImpl: FetchLike;
@@ -85,20 +133,40 @@ export class JR2Client {
85
133
  return this.token ? { ...extra, authorization: `Bearer ${this.token}` } : extra;
86
134
  }
87
135
 
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[];
136
+ /** `GET /workflows` — every registered Workflow with its origin, each Chart wrapper's Charts inside
137
+ * its entry (ADR-0070). An orchestrator older than ADR-0070 answers bare names; each reads as a
138
+ * Machine, so the listing has one shape for every caller. */
139
+ async workflows(): Promise<WorkflowListing[]> {
140
+ const body = await this.json(await this.fetchImpl(`${this.baseUrl}/workflows`), "/workflows");
141
+ // An answer that is no listing is not "none registered": say what came back (`jr2 runs` prints
142
+ // `workflows: unknown — ...` for it, never "none registered").
143
+ if (!Array.isArray(body)) throw new Error("GET /workflows: the answer is not a listing");
144
+ return body.map((w: unknown) =>
145
+ typeof w === "string" ? { name: w, origin: "machine" as const } : (w as WorkflowListing),
146
+ );
91
147
  }
92
148
 
93
149
  /** `GET /workflows/:name/machine` — the workflow's Machine as the Console's DTO. */
94
150
  async machine(workflow: string): Promise<MachineDoc> {
95
- const res = await this.fetchImpl(`${this.baseUrl}/workflows/${encodeURIComponent(workflow)}/machine`);
151
+ const res = await this.fetchImpl(`${this.baseUrl}${workflowPath(workflow)}/machine`);
96
152
  return (await this.json(res, "/workflows/:name/machine")) as MachineDoc;
97
153
  }
98
154
 
99
- /** `POST /workflows/:name/runs` — start a run; unknown workflow → 404 → throws. */
155
+ /** `GET /workflows/:name/runs/:runId/machine` — the run's bound Machine (ADR-0070): the Chart
156
+ * wrapper's Machine with each bound Open body's Chart compiled in. An unknown run → undefined. */
157
+ async runMachine(workflow: string, runId: string): Promise<MachineDoc | undefined> {
158
+ const res = await this.fetchImpl(
159
+ `${this.baseUrl}${workflowPath(workflow)}/runs/${encodeURIComponent(runId)}/machine`,
160
+ );
161
+ if (res.status === 404) return undefined;
162
+ return (await this.json(res, "/workflows/:name/runs/:runId/machine")) as MachineDoc;
163
+ }
164
+
165
+ /** `POST /workflows/:name/runs` — start a run; unknown workflow → 404 → throws. A named Chart's
166
+ * name `<wrapper>/<name>` rides the path as one encoded segment (ADR-0070); a refused Chart in
167
+ * the input → 422 → throws with the door's `report`. */
100
168
  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`, {
169
+ const res = await this.fetchImpl(`${this.baseUrl}${workflowPath(workflow)}/runs`, {
102
170
  method: "POST",
103
171
  headers: this.headers(JSON_HEADERS),
104
172
  body: JSON.stringify(input),
@@ -142,7 +210,8 @@ export class JR2Client {
142
210
 
143
211
  /** `POST /runs/:runId/gates/:gate/events` — deliver a workflow-defined event to an open gate
144
212
  * (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. */
213
+ * an unknown gate (404), a rejected payload (400) or an answer the run would not take now (409,
214
+ * ADR-0013) surfaces as a throw. */
146
215
  async sendToGate(runId: string, gate: string, event: { type: string } & Record<string, unknown>): Promise<void> {
147
216
  const res = await this.fetchImpl(
148
217
  `${this.baseUrl}/runs/${encodeURIComponent(runId)}/gates/${encodeURIComponent(gate)}/events`,
@@ -165,26 +234,48 @@ export class JR2Client {
165
234
  * `GET /runs/:runId/events` — the SSE feed as an async-iterable of `RunFeedEvent`. The current status
166
235
  * is replayed first (attach), then deltas + author `emit`s until the terminal status (or the consumer
167
236
  * breaks, which cancels the stream). A settled run streams its final status once and ends.
237
+ *
238
+ * A feed that ends BEFORE a terminal status is re-attached, never read as the run's end: the
239
+ * server closes a reader that fell behind (a paused pager on `jr2 run | less`), and the feed is
240
+ * level-triggered, so the re-attach opens with where the run stands now (ADR-0022). What falls in
241
+ * the gap is activity — Emits, retries — never state. The one end that IS the run's own without
242
+ * a terminal status is `gone`: the run was stopped and is parked (ADR-0025); the generator ends.
168
243
  */
169
244
  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 };
245
+ for (;;) {
246
+ const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/events`, {
247
+ headers: this.headers({ accept: "text/event-stream" }),
248
+ });
249
+ if (res.status === 404) {
250
+ throw new JR2HttpError(`no run "${runId}"`, 404, "/runs/:runId/events", await this.identify());
251
+ }
252
+ if (!res.body) return;
253
+ let settled = false;
254
+ for await (const frame of parseSSE(res.body)) {
255
+ if (frame.event === "emit") {
256
+ yield { kind: "emit", event: JSON.parse(frame.data) as { type: string } & Record<string, unknown> };
257
+ } else if (frame.event === "retry") {
258
+ yield { kind: "retry", ...(JSON.parse(frame.data) as { child: string; attempt: number; reason: string }) };
259
+ } else if (frame.event === "status") {
260
+ const status = JSON.parse(frame.data) as RunStatus;
261
+ settled = status.status !== "active";
262
+ yield { kind: "status", status };
263
+ } else if (frame.event === "placing" || frame.event === "placed") {
264
+ // A wait for capacity starting and ending (ADR-0064) — activity, never a status.
265
+ yield { ...(JSON.parse(frame.data) as object), kind: frame.event } as PlacingMarker;
266
+ } else if (frame.event === "child-error") {
267
+ // A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024).
268
+ yield { ...(JSON.parse(frame.data) as object), kind: "child-error" } as ChildErrorMarker;
269
+ } else if (frame.event === "gone") {
270
+ // The run left the live set without settling — stopped, parked for the next boot's
271
+ // restore (ADR-0025). This end is the run's own (ADR-0022): nothing to re-attach to.
272
+ return;
273
+ }
274
+ // Any other frame (a Turn marker — ADR-0023 — or a kind this CLI predates) is skipped, not
275
+ // misread as a status: `jr2 logs -f` decides "settled" off `status.status`, and a marker
276
+ // parsed as a status would end the follow mid-run.
184
277
  }
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.
278
+ if (settled) return;
188
279
  }
189
280
  }
190
281
 
@@ -209,10 +300,12 @@ export class JR2Client {
209
300
  const text = await res.text();
210
301
  const body = text ? JSON.parse(text) : undefined;
211
302
  if (!res.ok) {
212
- const message = (body as { error?: string } | undefined)?.error ?? `HTTP ${res.status}`;
303
+ const { error, report } = (body ?? {}) as { error?: string; report?: unknown };
304
+ const message = error ?? `HTTP ${res.status}`;
305
+ const lines = Array.isArray(report) ? report.map(String) : undefined;
213
306
  // Probe HERE, not at the catch site: by the time the error reaches `cli.ts` the command's
214
307
  // `finally` has closed the port-forward, and there is nothing left to ask.
215
- throw new JR2HttpError(message, res.status, path, await this.identify());
308
+ throw new JR2HttpError(message, res.status, path, await this.identify(), lines);
216
309
  }
217
310
  return body;
218
311
  }
@@ -1,6 +1,7 @@
1
1
  // `jr2 down [--all] [-n <ns>] [--context <ctx>]` (ADR-0019): remove the instance from the cluster —
2
2
  // its namespace and everything `jr2 up` converged into it. ALWAYS confirms (deleting a namespace
3
- // takes the runs, the store PVC, and every live Sandbox with it). `--all` also uninstalls the
3
+ // takes the runs, the store PVC, the Instance Harness's conversations PVC (ADR-0031), and every
4
+ // live Sandbox with it). `--all` also uninstalls the
4
5
  // per-cluster operator — only sane when this was the cluster's last instance, which is the
5
6
  // caller's judgment, not derivable here.
6
7
  //
@@ -117,9 +117,16 @@ export default defineConfig({
117
117
  // attach refuses it, so nothing can spend this cluster's credential against an arbitrary host. \`*\` is
118
118
  // today's two implicit defaults made visible (JR2_GIT_TOKEN from .env for https, the jr2-git-ssh Secret
119
119
  // for ssh). Narrow it to your hosts (\`match: "github.com/yourorg/"\`) before anything untrusted can start
120
- // a run. The longest match wins; the url's scheme picks token vs sshKey.
120
+ // a run. The longest match wins; the url's scheme picks token vs sshKey. It also PUSHES: a \`push()\` whose
121
+ // Repo Slot url it matches — a per-run url included — spends the same token or key (ADR-0051, ADR-0067).
121
122
  credentials: [{ match: "*", token: "JR2_GIT_TOKEN", sshKey: "jr2-git-ssh" }],
122
123
  },
124
+ // Every secret of the Instance, one list (ADR-0068). Where each goes is read, never declared — a pod gets
125
+ // what something in it names. A model key goes to the pods whose Agents' provider reads it, and a
126
+ // \`workspace()\` that lists a name under \`secrets\` gets that one: its Custodian holds it and the Agent sees
127
+ // only a Stand-in (ADR-0059). One an Effect names with \`secret("name")\` is a file in that Effect's Effector
128
+ // alone. \`hosts\` says where a held value may go. Read values from .env, never commit them.
129
+ // secrets: [{ name: "ANTHROPIC_API_KEY", value: process.env.ANTHROPIC_API_KEY }],
123
130
  // Where Sandboxes land (ADR-0052): by default wherever an ordinary pod lands — not cordoned, no taint — and
124
131
  // the Repo cache agent follows the same set. No node label is needed. To admit a tainted pool or narrow to
125
132
  // a labeled one, name it in raw pod-spec shapes; \`jr2 up\` reports the Sandbox nodes it sees.
@@ -182,7 +189,7 @@ node_modules/
182
189
  `;
183
190
 
184
191
  const PING_TS = `// The simplest jr2 workflow: no Agent, no Workspace, no data plane at all. A Machine is free to "just
185
- // respond to the request" with a plain actor (CONTEXT.md: a workflow need not spawn a Workspace) —
192
+ // respond to the request" with a plain actor (ADR-0001: a worktree is one piece a coding Machine bolts on) —
186
193
  // this is that case, and the one workflow that runs end-to-end on a fresh instance before any Sandbox /
187
194
  // Harness infrastructure exists. Filename \`ping.ts\` → workflow "ping".
188
195
  //
@@ -2,16 +2,18 @@
2
2
  //
3
3
  // Kit images live at a canonical public home (`ghcr.io/snapwich`, baked into `publishedKitRefs()`).
4
4
  // A cluster that cannot reach it — air-gapped, mirror-only, or simply policy-bound to one registry —
5
- // needs the three refs at its own address, which is what `kitRegistry` names in the config. This
6
- // command is how the bytes get there.
5
+ // needs those refs at its own address, which is what `kitRegistry` names in the config. This
6
+ // command is how the bytes get there. It moves one more image the same way: the Custodian's, the
7
+ // pinned upstream Envoy every Harness pod runs (ADR-0059), which jr2 deploys and never builds — a
8
+ // mirror-only cluster needs it just as much, and `kitRegistry` re-homes it the same way.
7
9
  //
8
10
  // It is deliberately INSTANCE-LESS: no `jr2.config.ts`, no kube context, no namespace. Kit images are
9
11
  // shared by every instance on a cluster (and possibly by every instance in an org), so moving them
10
12
  // is its own deliberate act rather than a side effect of converging one instance — which is exactly
11
13
  // why ADR-0044 keeps this out of `jr2 up`.
12
14
  //
13
- // It is a MIRROR and nothing else. There is no build arm here: the npm packages carry no Harness or
14
- // Adapter source, and an installed CLI that could build Kit images would be the patched-Harness
15
+ // It is a MIRROR and nothing else. There is no build arm here: the npm packages carry no Harness
16
+ // source, and an installed CLI that could build Kit images would be the patched-Harness
15
17
  // eject hatch ADR-0027/ADR-0038 welded shut. The kit developer's arm is `just kit-push`, in the
16
18
  // checkout, where the sources actually are.
17
19
  //
@@ -23,7 +25,7 @@
23
25
 
24
26
  import { execFile } from "node:child_process";
25
27
  import { parseArgs, promisify } from "node:util";
26
- import { KIT_VERSION } from "@jr2/orchestrator";
28
+ import { custodianRef, KIT_VERSION } from "@jr2/orchestrator";
27
29
  import { KIT_IMAGES, publishedKitRefs, type KitImageName } from "../build.ts";
28
30
  import { activity, result, type Io } from "../output.ts";
29
31
 
@@ -31,8 +33,9 @@ const exec = promisify(execFile);
31
33
 
32
34
  const USAGE = `usage: jr2 kit push <registry>
33
35
 
34
- mirror the v${KIT_VERSION} kit images (harness, adapter, operator) from their canonical
35
- home into <registry>, for a cluster whose \`kitRegistry\` names it (ADR-0044)`;
36
+ mirror the v${KIT_VERSION} kit images (harness, operator, orchestrator) from their canonical
37
+ home, and the Custodian's pinned Envoy from its own, into <registry>, for a cluster whose
38
+ \`kitRegistry\` names it (ADR-0044, ADR-0059, ADR-0069)`;
36
39
 
37
40
  /** Run one `docker` invocation, rejecting on a non-zero exit. The seam this command is tested
38
41
  * through: the claims are which argv each ref produces and what a failure means, and both are
@@ -45,7 +48,7 @@ const dockerCli: RunDocker = async (args) => {
45
48
  };
46
49
 
47
50
  /** What one image's mirror did — the per-image line, and the machine-readable result's rows. */
48
- type Mirrored = { image: KitImageName; source: string; target: string; copied: boolean };
51
+ type Mirrored = { image: KitImageName | "custodian"; source: string; target: string; copied: boolean };
49
52
 
50
53
  export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli): Promise<number> {
51
54
  const { positionals } = parseArgs({ args, allowPositionals: true, strict: false, options: {} });
@@ -77,6 +80,9 @@ export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli)
77
80
  for (const image of Object.keys(KIT_IMAGES) as KitImageName[]) {
78
81
  rows.push(await mirror(io, docker, image, sources[image], targets[image]));
79
82
  }
83
+ // The Custodian's: pinned by digest, and a registry-to-registry copy keeps the digest — so the
84
+ // ref a converge with this `kitRegistry` deploys (`custodianRef`) resolves in the mirror.
85
+ rows.push(await mirror(io, docker, "custodian", custodianRef(), custodianRef(target)));
80
86
 
81
87
  const copied = rows.filter((r) => r.copied).length;
82
88
  activity(io, `mirrored ${copied} image(s), ${rows.length - copied} already present`);
@@ -97,7 +103,7 @@ export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli)
97
103
  async function mirror(
98
104
  io: Io,
99
105
  docker: RunDocker,
100
- image: KitImageName,
106
+ image: KitImageName | "custodian",
101
107
  source: string,
102
108
  target: string,
103
109
  ): Promise<Mirrored> {
@@ -106,7 +112,8 @@ async function mirror(
106
112
  return { image, source, target, copied: false };
107
113
  }
108
114
  await requireSource(docker, image, source, target);
109
- await docker(["buildx", "imagetools", "create", "-t", target, source]);
115
+ // A digest-pinned target is pushed by its tag; the copy lands at that same digest.
116
+ await docker(["buildx", "imagetools", "create", "-t", target.replace(/@sha256:[0-9a-f]+$/, ""), source]);
110
117
  activity(io, `${image}: copied ${source} → ${target}`);
111
118
  return { image, source, target, copied: true };
112
119
  }
@@ -130,12 +137,17 @@ async function resolves(docker: RunDocker, ref: string): Promise<boolean> {
130
137
  * it points at is the other arm of ADR-0044: `just kit-push`, in the checkout, is what puts images
131
138
  * at a published name in the first place; this command only moves what is already there.
132
139
  */
133
- async function requireSource(docker: RunDocker, image: KitImageName, source: string, target: string): Promise<void> {
140
+ async function requireSource(
141
+ docker: RunDocker,
142
+ image: KitImageName | "custodian",
143
+ source: string,
144
+ target: string,
145
+ ): Promise<void> {
134
146
  if (await resolves(docker, source)) return;
135
147
  throw new Error(
136
148
  `${image}: ${source} is not there, so nothing can be mirrored to ${target}\n` +
137
149
  ` a kit image reaches ${target} only by being copied from its published home;\n` +
138
150
  ` if v${KIT_VERSION} was never published (a dev version never is), seed a registry from a kit\n` +
139
- ` checkout with \`just kit-push <registry>\`, which builds the three images from source`,
151
+ ` checkout with \`just kit-push <registry>\`, which builds the Kit images from source`,
140
152
  );
141
153
  }
@@ -8,6 +8,7 @@ import { JR2Client } from "../client.ts";
8
8
  import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
9
9
  import { activity, result, type Io } from "../output.ts";
10
10
  import { resolveRunId } from "../run-id.ts";
11
+ import { childErrorActivity, placingActivity } from "./run.ts";
11
12
 
12
13
  export async function logs(args: string[], io: Io): Promise<number> {
13
14
  const { values, positionals } = parseArgs({
@@ -39,6 +40,14 @@ export async function logs(args: string[], io: Io): Promise<number> {
39
40
  activity(io, `retry ${ev.child} attempt ${ev.attempt} (${ev.reason})`);
40
41
  continue;
41
42
  }
43
+ if (ev.kind === "placing" || ev.kind === "placed") {
44
+ activity(io, placingActivity(ev));
45
+ continue;
46
+ }
47
+ if (ev.kind === "child-error") {
48
+ activity(io, childErrorActivity(ev));
49
+ continue;
50
+ }
42
51
  result(io, ev.status);
43
52
  if (!values.follow) break;
44
53
  if (ev.status.status !== "active") break;
@@ -7,9 +7,14 @@
7
7
  // kube context — ADR-0019) rather than a per-invocation runtime, because jr2 runs are durable and may
8
8
  // park indefinitely on an approval gate. `--detach` prints the runId and returns, leaving the run
9
9
  // going server-side.
10
+ //
11
+ // A named Chart is a Workflow (ADR-0070): `jr2 run <wrapper>/<name>` starts it as any other run, the
12
+ // name riding the path as one encoded segment. A Chart the door refuses (one-shot, in `--input`)
13
+ // is a 422 whose report lines this prints, one per rule the Chart fails.
10
14
 
11
15
  import { parseArgs } from "node:util";
12
- import { JR2Client } from "../client.ts";
16
+ import type { ChildErrorMarker, PlacingMarker } from "@jr2/orchestrator";
17
+ import { JR2Client, JR2HttpError } from "../client.ts";
13
18
  import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
14
19
  import { activity, result, type Io } from "../output.ts";
15
20
 
@@ -27,7 +32,7 @@ export async function run(args: string[], io: Io): Promise<number> {
27
32
 
28
33
  const workflow = positionals[0];
29
34
  if (!workflow) {
30
- activity(io, "usage: jr2 run <workflow> [--input <json>] [--detach]");
35
+ activity(io, "usage: jr2 run <workflow | wrapper/chart> [--input <json>] [--detach]");
31
36
  return 2;
32
37
  }
33
38
  const input = values.input ? (JSON.parse(String(values.input)) as Record<string, unknown>) : {};
@@ -35,7 +40,14 @@ export async function run(args: string[], io: Io): Promise<number> {
35
40
  try {
36
41
  const client = new JR2Client(target.url, io.fetch, target.token);
37
42
 
38
- const { runId } = await client.start(workflow, input);
43
+ let runId: string;
44
+ try {
45
+ ({ runId } = await client.start(workflow, input));
46
+ } catch (err) {
47
+ if (!(err instanceof JR2HttpError) || !err.report) throw err;
48
+ activity(io, refusalActivity(workflow, err.report));
49
+ return 1;
50
+ }
39
51
  if (values.detach) {
40
52
  result(io, { runId });
41
53
  return 0;
@@ -51,14 +63,56 @@ export async function run(args: string[], io: Io): Promise<number> {
51
63
  activity(io, ` retry ${ev.child} attempt ${ev.attempt} (${ev.reason})`);
52
64
  continue;
53
65
  }
66
+ if (ev.kind === "placing" || ev.kind === "placed") {
67
+ activity(io, ` ${placingActivity(ev)}`);
68
+ continue;
69
+ }
70
+ if (ev.kind === "child-error") {
71
+ activity(io, ` ${childErrorActivity(ev)}`);
72
+ continue;
73
+ }
54
74
  activity(io, ` → ${ev.status.status} ${JSON.stringify(ev.status.value)}`);
55
75
  if (ev.status.status !== "active") {
56
76
  result(io, ev.status);
57
77
  return ev.status.status === "error" ? 1 : 0;
58
78
  }
59
79
  }
60
- return 0;
80
+ // `events()` re-attaches until the run settles (ADR-0022), so reaching here means the run left
81
+ // the live set without settling: stopped, parked for the next boot's restore (ADR-0025). Never
82
+ // a success, and nothing on stdout to be mistaken for a result.
83
+ activity(
84
+ io,
85
+ `run ${runId} is parked: stopped before it settled — \`jr2 logs -f ${runId}\` follows it once restored`,
86
+ );
87
+ return 1;
61
88
  } finally {
62
89
  target.close?.();
63
90
  }
64
91
  }
92
+
93
+ /**
94
+ * The door's refusal of a Chart (ADR-0070) as activity: the Workflow, then each report line, which
95
+ * names the path in the Chart and the rule it fails.
96
+ */
97
+ export function refusalActivity(workflow: string, report: readonly string[]): string {
98
+ return [`error: the door refused the Chart for "${workflow}"`, ...report.map((line) => ` ${line}`)].join("\n");
99
+ }
100
+
101
+ /**
102
+ * A wait for capacity as one activity line (ADR-0064): the Workspace, what it waits for, and the
103
+ * scheduler's or the quota's own words — then, once it has a node, how long it waited. The run's
104
+ * root Workspace has no child path, so its line names none.
105
+ */
106
+ export function placingActivity(ev: PlacingMarker): string {
107
+ const who = ev.child ? ` ${ev.child}` : "";
108
+ if (ev.kind === "placed") return `placed${who} after ${Math.round(ev.after / 1000)}s`;
109
+ return `placing${who}: waits for ${ev.on === "quota" ? "quota" : "a node"} — ${ev.message}`;
110
+ }
111
+
112
+ /**
113
+ * A child's error below an Agent's state as one activity line (ADR-0024): the pick's answer carries
114
+ * no reply for it, so the Agent is not told, and this line is where the operator reads it.
115
+ */
116
+ export function childErrorActivity(ev: ChildErrorMarker): string {
117
+ return `child-error: \`${ev.child}\` failed below ${ev.agent}'s pick \`${ev.pick}\` — ${ev.error}`;
118
+ }
@@ -1,19 +1,111 @@
1
1
  // `jr2 runs` (ADR-0009): list the live runs as JSON on stdout. (Settled runs leave the live registry;
2
2
  // read a specific one through to the store with `jr2 status <runId>`.)
3
+ //
4
+ // A run with a Workspace in `placing` (ADR-0064) carries `placing`: the child paths of those
5
+ // Workspaces, in `RunWaiting.child`'s form. It is the at-a-glance marker for a run that waits for
6
+ // capacity — why it waits is `jr2 status <runId>`'s `waiting`. A run with none has no `placing` key.
7
+ //
8
+ // The verb also lists the Workflows a run can start (ADR-0070), from `GET /workflows`, as activity on
9
+ // stderr so the result on stdout stays the live runs: one line per Workflow with its origin
10
+ // (`machine`, `chart file`, `chart`), a Chart wrapper's Charts indented under it, and `unrunnable`
11
+ // with its reason. `--workflows` prints that listing as the result instead. No verb puts or deletes
12
+ // a runtime Chart in this step: `PUT`/`DELETE /workflows/<wrapper>/charts/<name>` is for an MCP or curl.
3
13
 
4
14
  import { parseArgs } from "node:util";
5
- import { JR2Client } from "../client.ts";
15
+ import type { RunChild } from "@jr2/orchestrator";
16
+ import { JR2Client, type RunStatus, type WorkflowListing, type WorkflowOrigin } from "../client.ts";
6
17
  import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
7
- import { result, type Io } from "../output.ts";
18
+ import { activity, result, type Io } from "../output.ts";
19
+
20
+ export const RUNS_USAGE = `usage: jr2 runs [--workflows]
21
+
22
+ the live runs as JSON on stdout; the Workflows a run can start on stderr, each with its origin
23
+ (machine, chart file, chart) and a Chart wrapper's Charts indented under it (ADR-0070)
24
+ --workflows print the Workflow listing as the JSON result instead
25
+
26
+ a runtime Chart has no verb here: PUT or DELETE /workflows/<wrapper>/charts/<name> with the
27
+ Instance token (an MCP or curl), then \`jr2 run <wrapper>/<name>\` starts it`;
8
28
 
9
29
  export async function runs(args: string[], io: Io): Promise<number> {
10
- const { values } = parseArgs({ args, allowPositionals: true, strict: false, options: { ...TARGET_ARGS } });
30
+ const { values } = parseArgs({
31
+ args,
32
+ allowPositionals: true,
33
+ strict: false,
34
+ options: { ...TARGET_ARGS, workflows: { type: "boolean" }, help: { type: "boolean", short: "h" } },
35
+ });
36
+ if (values.help) {
37
+ activity(io, RUNS_USAGE);
38
+ return 0;
39
+ }
11
40
  const target = await resolveTarget(io, targetOptions(values));
12
41
  try {
13
42
  const client = new JR2Client(target.url, io.fetch, target.token);
14
- result(io, await client.list());
43
+ if (values.workflows) {
44
+ result(io, await client.workflows());
45
+ return 0;
46
+ }
47
+ const live = (await client.list()).map(markPlacing);
48
+ // A report beside the result: a listing that cannot be read says why, and the runs still print.
49
+ try {
50
+ activity(io, renderWorkflows(await client.workflows()));
51
+ } catch (e) {
52
+ activity(io, `workflows: unknown — ${e instanceof Error ? e.message : String(e)}`);
53
+ }
54
+ result(io, live);
15
55
  return 0;
16
56
  } finally {
17
57
  target.close?.();
18
58
  }
19
59
  }
60
+
61
+ /** How a listing names an origin (ADR-0070): a runtime Chart is plainly a `chart`. */
62
+ const ORIGIN_LABEL: Record<WorkflowOrigin, string> = { machine: "machine", file: "chart file", runtime: "chart" };
63
+
64
+ /**
65
+ * The Workflow listing as lines: each Workflow with its origin, a Chart wrapper's Open body and
66
+ * whether its door takes a Chart one-shot, its Charts indented under it, and `unrunnable` with the
67
+ * reason. A Chart the listing also gives at the top level (or whose wrapper it does not list) prints
68
+ * once: under its wrapper when the wrapper is listed, else at the top level.
69
+ */
70
+ export function renderWorkflows(listing: readonly WorkflowListing[]): string {
71
+ const underWrapper = new Set<string>();
72
+ for (const w of listing) for (const c of w.wrapper?.charts ?? []) underWrapper.add(chartFullName(w.name, c.name));
73
+ const line = (indent: string, name: string, label: string, unrunnable?: string): string =>
74
+ `${indent}${name} (${label})${unrunnable ? ` unrunnable: ${unrunnable}` : ""}`;
75
+ const out = ["workflows:"];
76
+ for (const w of listing) {
77
+ if (w.origin !== "machine" && underWrapper.has(w.name)) continue;
78
+ const wrapper = w.wrapper;
79
+ const label = wrapper
80
+ ? `${ORIGIN_LABEL[w.origin]}, Chart wrapper: body "${wrapper.body}"${wrapper.oneShot ? ", one-shot" : ""}`
81
+ : ORIGIN_LABEL[w.origin];
82
+ out.push(line(" ", w.name, label, w.unrunnable));
83
+ for (const c of wrapper?.charts ?? []) {
84
+ out.push(line(" ", chartFullName(w.name, c.name), ORIGIN_LABEL[c.origin], c.unrunnable));
85
+ }
86
+ }
87
+ if (out.length === 1) out.push(" none registered — `jr2 up` publishes the Assembly");
88
+ return out.join("\n");
89
+ }
90
+
91
+ /** A Chart's Workflow name, `<wrapper>/<name>` (ADR-0070), whether the listing gives it short or whole. */
92
+ function chartFullName(wrapper: string, name: string): string {
93
+ return name.includes("/") ? name : `${wrapper}/${name}`;
94
+ }
95
+
96
+ /** The run, with `placing` after its id when any Workspace in it is in `placing`. */
97
+ export function markPlacing(run: RunStatus): RunStatus & { placing?: string[] } {
98
+ const placing = [...(run.value === "placing" ? [""] : []), ...placingIn(run.children ?? [], [])];
99
+ if (!placing.length) return run;
100
+ const { runId, ...rest } = run;
101
+ return { runId, placing, ...rest };
102
+ }
103
+
104
+ /** The paths of every child in `placing`, at any depth — read off the state values alone, which
105
+ * are public (a Workspace's value is the flat state key). */
106
+ function placingIn(children: RunChild[], path: string[]): string[] {
107
+ return children.flatMap((child) => {
108
+ const here = [...path, child.id];
109
+ return [...(child.value === "placing" ? [here.join("/")] : []), ...placingIn(child.children, here)];
110
+ });
111
+ }