@jr2/cli 0.5.1 → 0.7.0

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,96 +1,35 @@
1
- // `jr2 runs` (ADR-0009): list the live runs as JSON on stdout. (Settled runs leave the live registry;
2
- // read a specific one through to the store with `jr2 status <runId>`.)
1
+ // `jr2 runs` (ADR-0009): list runs as JSON on stdout, newest start first: the live runs by default,
2
+ // the history with `--status ended|all|<list>`, one Workflow with `--workflow`, and the runs that
3
+ // started in a time with `--since` (`2h`, `7d`, or an ISO-8601 time). An ended run is listed while
4
+ // its row is kept (`runs.retain`, ADR-0065); a `drifted` run is listed with its reason (ADR-0030).
5
+ // Each item is the Orchestrator's summary, and the verb reads every page of `GET /runs`.
3
6
  //
4
7
  // A run with a Workspace in `placing` (ADR-0064) carries `placing`: the child paths of those
5
8
  // Workspaces, in `RunWaiting.child`'s form. It is the at-a-glance marker for a run that waits for
6
9
  // capacity — why it waits is `jr2 status <runId>`'s `waiting`. A run with none has no `placing` key.
7
- // A dispatched run (ADR-0071) carries `dispatchedBy` after its id, as the Orchestrator sends it: the
8
- // run that started it, the caller's slot and the chain's depth, so the chain reads off the listing.
10
+ // A dispatched run (ADR-0071) carries `dispatchedBy`, as the Orchestrator sends it: the run that
11
+ // started it, the caller's slot and the chain's depth, so the chain reads off the listing.
9
12
  //
10
- // The verb also lists the Workflows a run can start (ADR-0070), from `GET /workflows`, as activity on
11
- // stderr so the result on stdout stays the live runs: one line per Workflow with its origin
12
- // (`machine`, `chart file`, `chart`), a Chart wrapper's Charts indented under it, and `unrunnable`
13
- // with its reason. `--workflows` prints that listing as the result instead. No verb puts or deletes
14
- // a runtime Chart in this step: `PUT`/`DELETE /workflows/<wrapper>/charts/<name>` is for an MCP or curl.
13
+ // The Workflows that a run can start are `jr2 workflows`, not this verb.
15
14
 
16
- import type { RunChild } from "@jr2/orchestrator/internal";
17
- import { JR2Client, type RunStatus, type WorkflowListing, type WorkflowOrigin } from "../client.ts";
15
+ import { JR2Client } from "../client.ts";
18
16
  import { resolveTarget, targetOptions } from "../instance.ts";
19
- import { activity, result, type Io } from "../output.ts";
17
+ import { result, type Io } from "../output.ts";
20
18
  import { parseCommand } from "../command-table.ts";
21
19
 
22
20
  export async function runs(args: string[], io: Io): Promise<number> {
23
21
  const { values } = parseCommand("runs", args);
24
22
  const target = await resolveTarget(io, targetOptions(values));
25
23
  try {
26
- const client = new JR2Client(target.url, io.fetch, target.token);
27
- if (values.workflows) {
28
- result(io, await client.workflows());
29
- return 0;
30
- }
31
- const live = (await client.list()).map(markListed);
32
- // A report beside the result: a listing that cannot be read says why, and the runs still print.
33
- try {
34
- activity(io, renderWorkflows(await client.workflows()));
35
- } catch (e) {
36
- activity(io, `workflows: unknown — ${e instanceof Error ? e.message : String(e)}`);
37
- }
38
- result(io, live);
24
+ const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
25
+ const listed = await client.allRuns({
26
+ ...(typeof values.status === "string" ? { status: values.status } : {}),
27
+ ...(typeof values.workflow === "string" ? { workflow: values.workflow } : {}),
28
+ ...(typeof values.since === "string" ? { since: values.since } : {}),
29
+ });
30
+ result(io, listed);
39
31
  return 0;
40
32
  } finally {
41
33
  target.close?.();
42
34
  }
43
35
  }
44
-
45
- /** How a listing names an origin (ADR-0070): a runtime Chart is plainly a `chart`. */
46
- const ORIGIN_LABEL: Record<WorkflowOrigin, string> = { machine: "machine", file: "chart file", runtime: "chart" };
47
-
48
- /**
49
- * The Workflow listing as lines: each Workflow with its origin, a Chart wrapper's Open body and
50
- * whether its door takes a Chart one-shot, its Charts indented under it, and `unrunnable` with the
51
- * reason. A Chart the listing also gives at the top level (or whose wrapper it does not list) prints
52
- * once: under its wrapper when the wrapper is listed, else at the top level.
53
- */
54
- export function renderWorkflows(listing: readonly WorkflowListing[]): string {
55
- const underWrapper = new Set<string>();
56
- for (const w of listing) for (const c of w.wrapper?.charts ?? []) underWrapper.add(chartFullName(w.name, c.name));
57
- const line = (indent: string, name: string, label: string, unrunnable?: string): string =>
58
- `${indent}${name} (${label})${unrunnable ? ` unrunnable: ${unrunnable}` : ""}`;
59
- const out = ["workflows:"];
60
- for (const w of listing) {
61
- if (w.origin !== "machine" && underWrapper.has(w.name)) continue;
62
- const wrapper = w.wrapper;
63
- const label = wrapper
64
- ? `${ORIGIN_LABEL[w.origin]}, Chart wrapper: body "${wrapper.body}"${wrapper.oneShot ? ", one-shot" : ""}`
65
- : ORIGIN_LABEL[w.origin];
66
- out.push(line(" ", w.name, label, w.unrunnable));
67
- for (const c of wrapper?.charts ?? []) {
68
- out.push(line(" ", chartFullName(w.name, c.name), ORIGIN_LABEL[c.origin], c.unrunnable));
69
- }
70
- }
71
- if (out.length === 1) out.push(" none registered — `jr2 up` publishes the Assembly");
72
- return out.join("\n");
73
- }
74
-
75
- /** A Chart's Workflow name, `<wrapper>/<name>` (ADR-0070), whether the listing gives it short or whole. */
76
- function chartFullName(wrapper: string, name: string): string {
77
- return name.includes("/") ? name : `${wrapper}/${name}`;
78
- }
79
-
80
- /** The run as `jr2 runs` lists it: `dispatchedBy` after its id when a `dispatch` Effect started it
81
- * (ADR-0071), and `placing` after that when any Workspace in it is in `placing`. */
82
- export function markListed(run: RunStatus): RunStatus & { placing?: string[] } {
83
- const placing = [...(run.value === "placing" ? [""] : []), ...placingIn(run.children ?? [], [])];
84
- if (!placing.length && !run.dispatchedBy) return run;
85
- const { runId, dispatchedBy, ...rest } = run;
86
- return { runId, ...(dispatchedBy ? { dispatchedBy } : {}), ...(placing.length ? { placing } : {}), ...rest };
87
- }
88
-
89
- /** The paths of every child in `placing`, at any depth — read off the state values alone, which
90
- * are public (a Workspace's value is the flat state key). */
91
- function placingIn(children: RunChild[], path: string[]): string[] {
92
- return children.flatMap((child) => {
93
- const here = [...path, child.id];
94
- return [...(child.value === "placing" ? [here.join("/")] : []), ...placingIn(child.children, here)];
95
- });
96
- }
@@ -43,7 +43,7 @@ export async function send(args: string[], io: Io): Promise<number> {
43
43
  }
44
44
  const target = await resolveTarget(io, targetOptions(values));
45
45
  try {
46
- const client = new JR2Client(target.url, io.fetch, target.token);
46
+ const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
47
47
  const ref = await resolveRunId(client, given);
48
48
  if (!ref.ok) {
49
49
  activity(io, ref.message);
@@ -20,10 +20,22 @@
20
20
  // started: `dispatched: <run> (<slot>)`. The result itself carries `dispatchedBy`, `dispatched`
21
21
  // and, once the run is no longer live, its `output`, as the Orchestrator sends them.
22
22
  //
23
+ // A run that cut a Worktree (ADR-0075) says each one beside the result, one line per cut:
24
+ // `worktree: <path> (<slot>), branch <branch>`, or `, detached`. The result carries `worktrees`.
25
+ //
23
26
  // A failed run (ADR-0073) says its reason beside the result: `failed: <reason>`. The result's
24
27
  // `output` holds the whole Failure, `{ reason, detail? }`.
28
+ //
29
+ // A run says beside the result who started it (ADR-0078): `started by: <via>/<name>`, with the
30
+ // name jr2 shows and the party the Caller said it acts for. A run that a `dispatch` started names
31
+ // its first Caller: the verb walks `startedBy` up the chain of runs to the first one that a Caller
32
+ // started, and names the runs between. A run that jr2 started says why (`started by: jr2 (boot)`).
33
+ // A cancelled run says who cancelled it: `cancelled by: <via>/<name>`. A standing run (ADR-0017)
34
+ // says who resumed it (`resumed by:`) and, once a hand-over ended it, which run took over
35
+ // (`handed over to:`). Bare `jr2 status` prints each standing Workflow: live, paused with the number
36
+ // of items it holds, or waiting out a back-off.
25
37
 
26
- import type { AssemblyView, RepoStatus } from "@jr2/orchestrator/internal";
38
+ import type { AssemblyView, CallerRecord, RepoStatus, StandingView } from "@jr2/orchestrator/internal";
27
39
  import { JR2Client, type FetchLike, type RunStatus, workflowPath } from "../client.ts";
28
40
  import { resolveTarget, targetOptions } from "../instance.ts";
29
41
  import { activity, result, type Io } from "../output.ts";
@@ -38,7 +50,7 @@ export async function status(args: string[], io: Io): Promise<number> {
38
50
  const given = positionals.length === 0 ? undefined : positionals[0]!;
39
51
  const target = await resolveTarget(io, targetOptions(values));
40
52
  try {
41
- const client = new JR2Client(target.url, io.fetch, target.token);
53
+ const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
42
54
  if (given === undefined) return await instanceStatus(client, io, target.url, values.json === true);
43
55
  const ref = await resolveRunId(client, given);
44
56
  if (!ref.ok) {
@@ -51,7 +63,16 @@ export async function status(args: string[], io: Io): Promise<number> {
51
63
  return 1;
52
64
  }
53
65
  result(io, s);
54
- const notes = [failureActivity(s), dispatchedActivity(s), ...dispatchedLines(s), boundActivity(s)];
66
+ const notes = [
67
+ await startedByActivity(client, s),
68
+ cancelledByActivity(s),
69
+ ...standingRunLines(s),
70
+ failureActivity(s),
71
+ dispatchedActivity(s),
72
+ ...dispatchedLines(s),
73
+ boundActivity(s),
74
+ ...worktreeLines(s),
75
+ ];
55
76
  for (const note of notes) if (note) activity(io, note);
56
77
  return 0;
57
78
  } finally {
@@ -67,6 +88,70 @@ export function failureActivity(s: Pick<RunStatus, "status" | "output">): string
67
88
  return typeof reason === "string" ? `failed: ${reason}` : undefined;
68
89
  }
69
90
 
91
+ /** A Caller as one line names it (ADR-0078): `<via>/<name>`, the name jr2 shows, and the party it
92
+ * said it acts for. */
93
+ export function callerText(c: CallerRecord): string {
94
+ return (
95
+ `${c.via}/${c.name}` +
96
+ (c.shown !== undefined && c.shown !== c.name ? ` (${c.shown})` : "") +
97
+ (c.onBehalfOf !== undefined ? ` on behalf of ${c.onBehalfOf}` : "")
98
+ );
99
+ }
100
+
101
+ /** The most runs the `started by:` walk reads: a chain of dispatches longer than this is named as
102
+ * cut, never read without end. */
103
+ const STARTED_BY_HOPS = 16;
104
+
105
+ /**
106
+ * The `started by:` line (ADR-0078): the run's first Caller. A run that a `dispatch` started is
107
+ * walked up its `startedBy` chain, one read per run, to the first run that a Caller or jr2 started;
108
+ * the line names the runs between, nearest first. A run of the chain that the Caller may not read,
109
+ * or that is no longer known, ends the walk, and the line says so. Undefined for a run that records
110
+ * no starter.
111
+ */
112
+ export async function startedByActivity(
113
+ client: Pick<JR2Client, "read">,
114
+ s: Pick<RunStatus, "startedBy">,
115
+ ): Promise<string | undefined> {
116
+ let by = s.startedBy;
117
+ if (!by) return undefined;
118
+ const through: string[] = [];
119
+ const via = () => (through.length ? `, through ${through.join(" ← ")}` : "");
120
+ while (by.kind === "run") {
121
+ if (through.length >= STARTED_BY_HOPS) {
122
+ return `started by: not read — the chain is longer than ${STARTED_BY_HOPS} runs${via()}`;
123
+ }
124
+ const hop = `run ${by.run} (${by.slot})`;
125
+ let next: RunStatus | undefined;
126
+ try {
127
+ next = await client.read(by.run);
128
+ } catch (e) {
129
+ return `started by: not read — ${hop}: ${e instanceof Error ? e.message : String(e)}${via()}`;
130
+ }
131
+ if (!next) return `started by: not known — ${hop} is no longer known${via()}`;
132
+ through.push(hop);
133
+ if (!next.startedBy) return `started by: not recorded${via()}`;
134
+ by = next.startedBy;
135
+ }
136
+ if (by.kind === "jr2") return `started by: jr2 (${by.why})${via()}`;
137
+ const { kind: _kind, ...caller } = by;
138
+ return `started by: ${callerText(caller)}${via()}`;
139
+ }
140
+
141
+ /** A standing run's lines (ADR-0017): the Caller whose `jr2 run` resumed it, and the run that took
142
+ * over from it at a hand-over. */
143
+ export function standingRunLines(s: Pick<RunStatus, "resumedBy" | "handedOverTo">): string[] {
144
+ return [
145
+ ...(s.resumedBy ? [`resumed by: ${callerText(s.resumedBy)}`] : []),
146
+ ...(s.handedOverTo ? [`handed over to: ${s.handedOverTo}`] : []),
147
+ ];
148
+ }
149
+
150
+ /** The `cancelled by:` line for a run a Caller cancelled (ADR-0078). */
151
+ export function cancelledByActivity(s: Pick<RunStatus, "cancelledBy">): string | undefined {
152
+ return s.cancelledBy ? `cancelled by: ${callerText(s.cancelledBy)}` : undefined;
153
+ }
154
+
70
155
  /** The most `dispatched:` lines `jr2 status` prints: a watcher dispatches on every pass, and the
71
156
  * result carries the whole list. */
72
157
  const DISPATCHED_LINES = 10;
@@ -83,13 +168,29 @@ export function dispatchedLines(s: RunStatus): string[] {
83
168
  ];
84
169
  }
85
170
 
171
+ /** The `worktree:` lines for the Worktrees this run's cuts made (ADR-0075), oldest first. */
172
+ export function worktreeLines(s: RunStatus): string[] {
173
+ return (s.worktrees ?? []).map(
174
+ (w) => `worktree: ${w.path} (${w.slot}), ${w.branch === null ? "detached" : `branch ${w.branch}`}`,
175
+ );
176
+ }
177
+
86
178
  /** The `dispatched by:` line for a run a `dispatch` Effect started (ADR-0071), or undefined for a
87
179
  * run nobody dispatched. A foreign caller is named by its Orchestrator, as that one sent it: opaque
88
180
  * text, which this Orchestrator could not verify. */
89
181
  export function dispatchedActivity(s: RunStatus): string | undefined {
90
182
  const by = s.dispatchedBy;
91
183
  if (!by) return undefined;
92
- return `dispatched by: ${by.run} (${by.slot}), depth ${by.depth}${by.instance ? `, from Orchestrator ${by.instance}` : ""}`;
184
+ // A Pool of runs (ADR-0017) names the item's key, and the Hook delivery that carried it.
185
+ const pool =
186
+ by.key !== undefined
187
+ ? `, key ${by.key}${
188
+ by.hook
189
+ ? `, hook ${by.hook.name}, delivery ${by.hook.delivery}${by.hook.onBehalfOf ? `, on behalf of ${by.hook.onBehalfOf}` : ""}`
190
+ : ""
191
+ }`
192
+ : "";
193
+ return `dispatched by: ${by.run} (${by.slot}), depth ${by.depth}${by.instance ? `, from Orchestrator ${by.instance}` : ""}${pool}`;
93
194
  }
94
195
 
95
196
  /** The `chart: bound` line for a run that binds a Chart (ADR-0070), or undefined for one that binds
@@ -128,17 +229,19 @@ async function instanceStatus(client: JR2Client, io: Io, url: string, json: bool
128
229
 
129
230
  /** `GET /assembly` (ADR-0069): open, like `GET /workflows`, so it needs no token. An answer that
130
231
  * 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 }> {
232
+ async function readAssembly(fetchImpl: FetchLike, url: string): Promise<InstanceAssembly | { error: string }> {
132
233
  try {
133
234
  const res = await fetchImpl(`${url.replace(/\/+$/, "")}/assembly`);
134
235
  if (!res.ok) return { error: `GET /assembly answered HTTP ${res.status}` };
135
236
  // Read loosely: a `held` that is absent or not a list reads as none held, so a report never
136
237
  // fails on the shape of one field (ADR-0048: a report is not a failure of the asking).
137
238
  const view = (await res.json()) as Partial<AssemblyView>;
239
+ const standing = (view as { standing?: unknown }).standing;
138
240
  return {
139
241
  kitVersion: view.kitVersion ?? "",
140
242
  current: view.current ?? null,
141
243
  held: Array.isArray(view.held) ? view.held : [],
244
+ ...(Array.isArray(standing) ? { standing: standing as StandingView[] } : {}),
142
245
  };
143
246
  } catch (e) {
144
247
  return { error: e instanceof Error ? e.message : String(e) };
@@ -152,7 +255,7 @@ async function readAssembly(fetchImpl: FetchLike, url: string): Promise<Assembly
152
255
  * The current Assembly prints first, whatever order `held` uses; the other held Assemblies follow
153
256
  * in the server's order.
154
257
  */
155
- function assemblyLines(view: AssemblyView | { error: string } | undefined): string {
258
+ function assemblyLines(view: InstanceAssembly | { error: string } | undefined): string {
156
259
  if (view === undefined) return "";
157
260
  if ("error" in view) return `assembly: unknown — ${view.error}\n`;
158
261
  const current = view.current;
@@ -171,9 +274,37 @@ function assemblyLines(view: AssemblyView | { error: string } | undefined): stri
171
274
  if (held.hash === current?.hash) continue;
172
275
  out += line(held.hash, held.kitVersion, held.runs.length, false);
173
276
  }
277
+ for (const s of view.standing ?? []) out += `${standingLine(s)}\n`;
174
278
  return out;
175
279
  }
176
280
 
281
+ /** What `GET /assembly` answers, with its standing Workflows (ADR-0017). */
282
+ export type InstanceAssembly = AssemblyView & { standing?: StandingView[] };
283
+
284
+ /**
285
+ * One standing Workflow as one line (ADR-0017): its Pool's chain to its callee, and its state. A
286
+ * paused one says why, how many items it holds and how to resume it; one with no live run and no
287
+ * pause waits out the back-off of a hand-over.
288
+ */
289
+ export function standingLine(s: StandingView): string {
290
+ const items = `${s.held} held item${s.held === 1 ? "" : "s"}`;
291
+ const head = `standing ${s.workflow} → ${s.callee}`;
292
+ if (s.state === "live") return `${head}: live, run ${s.run ?? "?"}, ${items}`;
293
+ if (s.state === "paused") {
294
+ const why =
295
+ s.why === "errors"
296
+ ? "4 runs in a row in which no item that the run took started a run; the last one ended error or failed"
297
+ : s.why === "bound"
298
+ ? "4 runs in a row in which no item that the run took started a run; the last one took its bound of items"
299
+ : "a cancel";
300
+ return (
301
+ `${head}: paused (${why}), ${items}${s.run ? ` in run ${s.run}` : ""} — \`jr2 run ${s.workflow}\` resumes it, ` +
302
+ "and `--drop-held` resumes it with no held item"
303
+ );
304
+ }
305
+ return `${head}: no live run — jr2 starts its next run after the back-off`;
306
+ }
307
+
177
308
  /** The human table — the held Assemblies, current first, one line per Repo, one per node under it, then the
178
309
  * hints for what was seen. */
179
310
  export function renderInstanceStatus({
@@ -181,7 +312,7 @@ export function renderInstanceStatus({
181
312
  dataPlane,
182
313
  repos,
183
314
  }: {
184
- assembly?: AssemblyView | { error: string };
315
+ assembly?: InstanceAssembly | { error: string };
185
316
  dataPlane: boolean;
186
317
  repos: RepoStatus[];
187
318
  }): string {
@@ -0,0 +1,47 @@
1
+ // `jr2 steer` (ADR-0002): a Caller's Steering message to one Agent of a live run.
2
+ //
3
+ // jr2 steer <runId|abbrev> <agent> "<text>" send the message
4
+ // jr2 steer <runId|abbrev> list the run's Agent slots
5
+ //
6
+ // The address is the Agent SLOT, as the run's list names it (`<machine path>/<agent>`), never an
7
+ // Instance ID: jr2 computes those, and a fresh Turn gets a new one. An open Turn of the slot hears
8
+ // the message at its next step boundary; otherwise the slot's next Turn in the same Machine
9
+ // instance hears it. The run id resolves first, as for every write (ADR-0009).
10
+
11
+ import { JR2Client } from "../client.ts";
12
+ import { resolveTarget, targetOptions } from "../instance.ts";
13
+ import { activity, result, type Io } from "../output.ts";
14
+ import { resolveRunId } from "../run-id.ts";
15
+ import { commandNamed, parseCommand, renderCommand } from "../command-table.ts";
16
+
17
+ export async function steer(args: string[], io: Io): Promise<number> {
18
+ const { values, positionals } = parseCommand("steer", args);
19
+ const [given, agent, text] = positionals;
20
+ if (!given || (agent !== undefined && text === undefined) || positionals.length > 3) {
21
+ activity(io, renderCommand(commandNamed("steer")));
22
+ return 2;
23
+ }
24
+ const target = await resolveTarget(io, targetOptions(values));
25
+ try {
26
+ const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
27
+ const ref = await resolveRunId(client, given);
28
+ if (!ref.ok) {
29
+ activity(io, ref.message);
30
+ return ref.code;
31
+ }
32
+ if (agent === undefined || text === undefined) {
33
+ result(io, await client.agents(ref.runId));
34
+ return 0;
35
+ }
36
+ const sent = await client.steer(ref.runId, agent, text);
37
+ activity(
38
+ io,
39
+ sent.open
40
+ ? `sent a Steering message to ${sent.agent} on ${ref.runId}: its open Turn hears it at its next step boundary, or its next Turn hears it if this Turn ends first`
41
+ : `sent a Steering message to ${sent.agent} on ${ref.runId}: its next Turn hears it`,
42
+ );
43
+ return 0;
44
+ } finally {
45
+ target.close?.();
46
+ }
47
+ }