@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.
@@ -1,6 +1,7 @@
1
- // `jr2 down [--all] [-n <ns>] [--context <ctx>]` (ADR-0019): remove the instance from the cluster —
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
1
+ // `jr2 down [--all] [--yes] [-n <ns>] [--context <ctx>]` (ADR-0019): remove the instance from the
2
+ // cluster — its namespace and everything `jr2 up` converged into it. Confirms unless `--yes`
3
+ // (deleting a namespace takes the runs, the store PVC, the Instance Harness's conversations PVC
4
+ // (ADR-0031), and every 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
  //
@@ -21,27 +22,17 @@
21
22
  // bounded by the node's disk; on kind they live inside the node container and go with the cluster.
22
23
 
23
24
  import { basename } from "node:path";
24
- import { parseArgs } from "node:util";
25
- import { loadConfig } from "@jr2/orchestrator";
25
+ import { loadConfig } from "@jr2/orchestrator/internal";
26
26
  import { pnpmDockerBuild } from "../build.ts";
27
27
  import { KIT_VERSION, LABEL_INSTANCE, operatorManifest } from "../deploy.ts";
28
28
  import { resolveRoot } from "../instance.ts";
29
29
  import { kubectlAdmin } from "../kube.ts";
30
30
  import { activity, confirmOrBail, type Io } from "../output.ts";
31
31
  import { sweepImages } from "../sweep.ts";
32
+ import { parseCommand } from "../command-table.ts";
32
33
 
33
34
  export async function down(args: string[], io: Io): Promise<number> {
34
- const { values } = parseArgs({
35
- args,
36
- allowPositionals: true,
37
- strict: false,
38
- options: {
39
- all: { type: "boolean" },
40
- yes: { type: "boolean" },
41
- namespace: { type: "string", short: "n" },
42
- context: { type: "string" },
43
- },
44
- });
35
+ const { values } = parseCommand("down", args);
45
36
 
46
37
  const root = resolveRoot(io.cwd);
47
38
  const config = (await loadConfig(root)) ?? {};
@@ -15,24 +15,15 @@
15
15
  // resolves no instance folder: "disk is full now" has to work from anywhere, and a kube context is
16
16
  // the only address this command needs.
17
17
 
18
- import { parseArgs } from "node:util";
19
18
  import { pnpmDockerBuild } from "../build.ts";
20
19
  import { kubectlAdmin } from "../kube.ts";
21
20
  import { activity, type Io } from "../output.ts";
22
21
  import { DEFAULT_REPO_TTL, parseRepoTtl, sweepRepos } from "../repo-sweep.ts";
23
22
  import { sweepImages } from "../sweep.ts";
23
+ import { parseCommand } from "../command-table.ts";
24
24
 
25
25
  export async function gc(args: string[], io: Io): Promise<number> {
26
- const { values } = parseArgs({
27
- args,
28
- allowPositionals: true,
29
- strict: false,
30
- options: {
31
- "dry-run": { type: "boolean" },
32
- "repo-ttl": { type: "string" },
33
- context: { type: "string" },
34
- },
35
- });
26
+ const { values } = parseCommand("gc", args);
36
27
  const ttl = (values["repo-ttl"] as string | undefined) ?? DEFAULT_REPO_TTL;
37
28
  // Refused before anything is read: a TTL that does not parse must not become "sweep now".
38
29
  parseRepoTtl(ttl);
@@ -4,7 +4,8 @@
4
4
  // does `jr2 up`, which refuses to converge a folder the compiler rejects — ADR-0050), a
5
5
  // `.gitignore` for the runtime `.jr2/`, ONE starter workflow (`ping`) that runs end-to-end with no
6
6
  // Workspace/Agent — a small "respond directly" Machine to trim down and build on — and
7
- // `images/default/Dockerfile`, the Sandbox Image every Workspace falls back to (ADR-0037).
7
+ // `images/default/Dockerfile`, the Sandbox Image every Workspace falls back to (ADR-0037), and
8
+ // the `AGENTS.md` an Assistant reads first, with a `CLAUDE.md` that imports it (ADR-0072).
8
9
  //
9
10
  // What it does NOT scaffold is as deliberate: there is no `agents/` folder and no `images/` scan,
10
11
  // because a Machine carries its own Agents and Sandbox Images (ADR-0049) and only what the CLI
@@ -15,7 +16,8 @@
15
16
  // `test/init.test.ts` enforces that; without it the two drift silently, and since the manifest
16
17
  // carries the kit version that same test is the version-bump tripwire (bump the kit, re-render the
17
18
  // template). Existing files are left untouched (init is additive); created paths are reported on
18
- // stderr.
19
+ // stderr. A user's own AGENTS.md or CLAUDE.md that keeps an Assistant from the docs index gets one
20
+ // hint line under its skip line (ADR-0072).
19
21
  //
20
22
  // ONE template serves both checkout and installed mode (ADR-0043) — a branch there would mean the
21
23
  // tested output and the shipped output diverge. So the scaffold names no package manager, and pins
@@ -24,20 +26,32 @@
24
26
  // not the orchestrator's `KIT_VERSION`: a global `jr2` with no Instance around it must scaffold
25
27
  // without a peer resolved, and the two numbers are equal by construction (one release train,
26
28
  // lockstep — ADR-0055).
29
+ //
30
+ // The `AGENTS.md` block is the one scaffolded text that comes from the Kit, not from this file: the
31
+ // bundle of the CLI's own peer `@jr2/orchestrator`, when that peer is the version the scaffold pins.
32
+ // A global `jr2` has that peer (npm installs a global's peers with it, and a checkout links it), so
33
+ // a fresh folder indexes its docs before the first install — an Assistant can write the first
34
+ // Machine before the first `jr2 up`. Without the bundle (a checkout before `docs:build`), the
35
+ // markers stay empty and `jr2 up` fills them. The template mirrors the file with the block taken
36
+ // out, since the block is generated and nothing generated is committed (ADR-0072).
27
37
 
28
38
  import { mkdir, readFile, writeFile } from "node:fs/promises";
29
39
  import { basename, dirname, join, resolve } from "node:path";
30
- import { parseArgs } from "node:util";
31
- import { CLI_VERSION } from "../kit-version.ts";
40
+ import { parseCommand } from "../command-table.ts";
41
+ import {
42
+ AGENTS_MD,
43
+ BEGIN_MARKER,
44
+ CLAUDE_MD,
45
+ END_MARKER,
46
+ placeAgentsBlock,
47
+ readAgentsBlock,
48
+ scaffoldAgentsMd,
49
+ } from "../agents-md.ts";
50
+ import { CLI_ROOT, CLI_VERSION, resolvePackage, type ResolvedPackage } from "../kit-version.ts";
32
51
  import { activity, type Io } from "../output.ts";
33
52
 
34
53
  export async function init(args: string[], io: Io): Promise<number> {
35
- const { values, positionals } = parseArgs({
36
- args,
37
- allowPositionals: true,
38
- strict: false,
39
- options: { name: { type: "string" } },
40
- });
54
+ const { values, positionals } = parseCommand("init", args);
41
55
 
42
56
  const dir = positionals[0] ? resolve(io.cwd, String(positionals[0])) : io.cwd;
43
57
  const name = (values.name as string | undefined) ?? basename(dir);
@@ -49,6 +63,8 @@ export async function init(args: string[], io: Io): Promise<number> {
49
63
  { path: ".gitignore", content: GITIGNORE },
50
64
  { path: join("workflows", "ping.ts"), content: PING_TS },
51
65
  { path: join("images", "default", "Dockerfile"), content: IMAGE_DOCKERFILE },
66
+ { path: AGENTS_MD, content: scaffoldAgentsMd(scaffoldBlock(resolvePackage("@jr2/orchestrator", CLI_ROOT))) },
67
+ { path: "CLAUDE.md", content: CLAUDE_MD },
52
68
  ];
53
69
 
54
70
  activity(io, `jr2 init — scaffolding ${name} in ${dir}`);
@@ -56,6 +72,8 @@ export async function init(args: string[], io: Io): Promise<number> {
56
72
  const full = join(dir, file.path);
57
73
  if (await exists(full)) {
58
74
  activity(io, ` skip ${file.path} (exists)`);
75
+ const hint = await existingHint(file.path, full);
76
+ if (hint) activity(io, ` ${hint}`);
59
77
  continue;
60
78
  }
61
79
  await mkdir(dirname(full), { recursive: true });
@@ -66,6 +84,29 @@ export async function init(args: string[], io: Io): Promise<number> {
66
84
  return 0;
67
85
  }
68
86
 
87
+ /** The block `init` writes between the markers: the bundle of `peer` when it is the exact version
88
+ * the scaffold pins, else nothing (`jr2 up` fills it once the Instance resolves its Kit). */
89
+ export function scaffoldBlock(peer: ResolvedPackage | undefined, pinned: string = CLI_VERSION): string | undefined {
90
+ return peer && peer.version === pinned ? readAgentsBlock(peer.root) : undefined;
91
+ }
92
+
93
+ /** One line for a user's own `AGENTS.md` or `CLAUDE.md` that init left alone and that keeps an
94
+ * Assistant from the docs index (ADR-0072): a file with no markers has opted out, and Claude Code
95
+ * reads `AGENTS.md` only through a `CLAUDE.md` that imports it. Said, never fixed: the file is the
96
+ * user's. */
97
+ export async function existingHint(path: string, full: string): Promise<string | undefined> {
98
+ if (path === AGENTS_MD) {
99
+ const place = placeAgentsBlock(await readFile(full, "utf8"));
100
+ if (place.kind === "opted-out")
101
+ return `AGENTS.md has no jr2-docs markers: add the lines ${BEGIN_MARKER} and ${END_MARKER}, and jr2 up fills them`;
102
+ if (place.kind === "broken") return `AGENTS.md: ${place.reason}, so jr2 up does not update the jr2 block`;
103
+ return undefined;
104
+ }
105
+ if (path === "CLAUDE.md" && !/^@AGENTS\.md\s*$/m.test(await readFile(full, "utf8")))
106
+ return "CLAUDE.md does not import AGENTS.md: add the line @AGENTS.md, so that Claude Code reads it";
107
+ return undefined;
108
+ }
109
+
69
110
  async function exists(path: string): Promise<boolean> {
70
111
  try {
71
112
  await readFile(path);
@@ -85,6 +126,17 @@ async function exists(path: string): Promise<boolean> {
85
126
  // rule for `@jr2/*`, applied to the checker): the instance's PROGRAM includes the kit's `.ts`
86
127
  // sources — zero-build, `exports` point at source — so the compiler is part of the contract, not
87
128
  // the user's choice, and it moves when the kit moves.
129
+ //
130
+ // `zod` is the other import the scaffold's own files do not make but every Machine an author writes
131
+ // does: each defineEvent and each input schema is a zod object, and the Reference's examples start
132
+ // with `import { z } from "zod"`. A strict node_modules (pnpm) does not let the Instance import a
133
+ // package it does not list, so without it a Machine copied from the bundled Reference is refused by
134
+ // `jr2 up`'s typecheck (ADR-0050). npm hoisted it, by accident. The range is the kit's own.
135
+ //
136
+ // `check` runs `jr2 check`: the typecheck AND the load of each Workflow, which `jr2 up` runs first
137
+ // (ADR-0050). `typecheck` alone does not load a module, so it misses each refusal of
138
+ // `createMachine`, `defineEvent` and `workspace()`. The `jr2` it runs is the Instance's own
139
+ // `@jr2/cli`, which strips the types of the kit sources under `node_modules`.
88
140
  function packageJson(name: string): string {
89
141
  return `${JSON.stringify(
90
142
  {
@@ -92,8 +144,8 @@ function packageJson(name: string): string {
92
144
  version: "0.0.0",
93
145
  private: true,
94
146
  type: "module",
95
- scripts: { typecheck: "tsc --noEmit" },
96
- dependencies: { "@jr2/orchestrator": CLI_VERSION, xstate: "^5.18.0" },
147
+ scripts: { check: "jr2 check", typecheck: "tsc --noEmit" },
148
+ dependencies: { "@jr2/orchestrator": CLI_VERSION, xstate: "^5.18.0", zod: "^4.4.3" },
97
149
  devDependencies: { "@jr2/cli": CLI_VERSION, "@types/node": "^26.0.1", typescript: "^5.6.0" },
98
150
  },
99
151
  null,
@@ -117,9 +169,16 @@ export default defineConfig({
117
169
  // attach refuses it, so nothing can spend this cluster's credential against an arbitrary host. \`*\` is
118
170
  // today's two implicit defaults made visible (JR2_GIT_TOKEN from .env for https, the jr2-git-ssh Secret
119
171
  // 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.
172
+ // a run. The longest match wins; the url's scheme picks token vs sshKey. It also PUSHES: a \`push()\` whose
173
+ // Repo Slot url it matches — a per-run url included — spends the same token or key (ADR-0051, ADR-0067).
121
174
  credentials: [{ match: "*", token: "JR2_GIT_TOKEN", sshKey: "jr2-git-ssh" }],
122
175
  },
176
+ // Every secret of the Instance, one list (ADR-0068). Where each goes is read, never declared — a pod gets
177
+ // what something in it names. A model key goes to the pods whose Agents' provider reads it, and a
178
+ // \`workspace()\` that lists a name under \`secrets\` gets that one: its Custodian holds it and the Agent sees
179
+ // only a Stand-in (ADR-0059). One an Effect names with \`secret("name")\` is a file in that Effect's Effector
180
+ // alone. \`hosts\` says where a held value may go. Read values from .env, never commit them.
181
+ // secrets: [{ name: "ANTHROPIC_API_KEY", value: process.env.ANTHROPIC_API_KEY }],
123
182
  // Where Sandboxes land (ADR-0052): by default wherever an ordinary pod lands — not cordoned, no taint — and
124
183
  // the Repo cache agent follows the same set. No node label is needed. To admit a tainted pool or narrow to
125
184
  // a labeled one, name it in raw pod-spec shapes; \`jr2 up\` reports the Sandbox nodes it sees.
@@ -152,7 +211,9 @@ const IMAGE_DOCKERFILE = `# A Sandbox Image (ADR-0037): the tools your agents ca
152
211
  # (\`user:\` on \`workspace()\`, ADR-0005).
153
212
  #
154
213
  # Two things jr2 cannot vendor, both proven by \`jr2 up\` before it converges:
155
- # - \`git\` — the agent clones, worktrees, and commits with the git you chose;
214
+ # - \`git\` — the agent clones, worktrees, and commits with the git you chose. With no identity
215
+ # here, the agent commits as \`jr2 Agent <agent@jr2.invalid>\`, jr2's default at the lowest rung
216
+ # of git's config: an identity you set at any level wins over it (ADR-0037);
156
217
  # - a glibc base no older than jr2's node. alpine/musl cannot run it at all.
157
218
  #
158
219
  # This one folder is a path convention; there is no \`images/\` scan (ADR-0049/0050). A SECOND Sandbox
@@ -182,16 +243,18 @@ node_modules/
182
243
  `;
183
244
 
184
245
  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) —
246
+ // respond to the request" with a plain actor (ADR-0001: a worktree is one piece a coding Machine bolts on) —
186
247
  // this is that case, and the one workflow that runs end-to-end on a fresh instance before any Sandbox /
187
248
  // Harness infrastructure exists. Filename \`ping.ts\` → workflow "ping".
188
249
  //
189
250
  // Shape: take the run input, invoke a plain \`fromPromise\` actor, fold its result into context, finish.
190
251
  // \`jr2 run ping --input '{"message":"hi"}'\` → the run reaches \`done\` and \`jr2 status\` shows the reply.
191
252
  //
192
- // Module contract (ADR-0011/0015): one named export — \`machine\`. A workflow that accepts
193
- // external events authors with \`jr2Setup({ events: [...] })\`; ping accepts none, so plain
194
- // xstate \`setup()\` is all it needs.
253
+ // Module contract (ADR-0011/0015): one named export — \`machine\`. ping takes no workflow event and
254
+ // declares no door, so plain xstate \`setup()\` is enough. A Machine with a door (a zod \`input\` on
255
+ // \`createMachine\`, ADR-0033) uses \`jr2Setup\`, also when it takes no event: then it leaves out
256
+ // \`events\`, as in \`jr2Setup()\`. \`setup()\` does not read that \`input\`, so \`jr2 up\` refuses a
257
+ // zod \`input\` on a Machine that \`setup()\` made.
195
258
  //
196
259
  // The next step is a Machine the kit already ships (ADR-0054). \`@jr2/machines\` exports \`task\` — one
197
260
  // prompt, one Workspace, one human says done — and a Workflow is only the name an Instance
@@ -202,7 +265,7 @@ const PING_TS = `// The simplest jr2 workflow: no Agent, no Workspace, no data p
202
265
  //
203
266
  // export const machine = customize(task, {
204
267
  // repos: { target: { url: "https://github.com/you/repo.git" } },
205
- // agents: { coder: { model: "anthropic/claude-sonnet-4-6" } },
268
+ // agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
206
269
  // });
207
270
  //
208
271
  // A packaged Machine leaves the parts it cannot honestly fill OPEN: it does not know your
@@ -24,19 +24,14 @@
24
24
  // swap — it is the decision.
25
25
 
26
26
  import { execFile } from "node:child_process";
27
- import { parseArgs, promisify } from "node:util";
28
- import { custodianRef, KIT_VERSION } from "@jr2/orchestrator";
27
+ import { promisify } from "node:util";
28
+ import { custodianRef, KIT_VERSION } from "@jr2/orchestrator/internal";
29
29
  import { KIT_IMAGES, publishedKitRefs, type KitImageName } from "../build.ts";
30
30
  import { activity, result, type Io } from "../output.ts";
31
+ import { commandNamed, parseCommand, renderCommand } from "../command-table.ts";
31
32
 
32
33
  const exec = promisify(execFile);
33
34
 
34
- const USAGE = `usage: jr2 kit push <registry>
35
-
36
- mirror the v${KIT_VERSION} kit images (harness, operator) from their canonical home, and the
37
- Custodian's pinned Envoy from its own, into <registry>, for a cluster whose \`kitRegistry\`
38
- names it (ADR-0044, ADR-0059)`;
39
-
40
35
  /** Run one `docker` invocation, rejecting on a non-zero exit. The seam this command is tested
41
36
  * through: the claims are which argv each ref produces and what a failure means, and both are
42
37
  * decidable with no registry anywhere near the test. Narrower than `build.ts`'s `RunCommand` on
@@ -51,17 +46,17 @@ const dockerCli: RunDocker = async (args) => {
51
46
  type Mirrored = { image: KitImageName | "custodian"; source: string; target: string; copied: boolean };
52
47
 
53
48
  export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli): Promise<number> {
54
- const { positionals } = parseArgs({ args, allowPositionals: true, strict: false, options: {} });
49
+ const { positionals } = parseCommand("kit push", args);
55
50
  const [sub, registry] = positionals;
56
51
 
57
52
  if (sub !== "push") {
58
53
  activity(io, sub === undefined ? "jr2 kit: no subcommand" : `unknown kit subcommand: ${sub}`);
59
- activity(io, USAGE);
54
+ activity(io, renderCommand(commandNamed("kit push")));
60
55
  return 2;
61
56
  }
62
57
  if (!registry) {
63
58
  activity(io, "jr2 kit push: no target registry");
64
- activity(io, USAGE);
59
+ activity(io, renderCommand(commandNamed("kit push")));
65
60
  return 2;
66
61
  }
67
62
 
@@ -3,22 +3,18 @@
3
3
  // author emits → stderr). `-f`/`--follow` keeps streaming status deltas until the run settles; without
4
4
  // it, we print the replayed status and stop. A run that already settled streams its final status once.
5
5
 
6
- import { parseArgs } from "node:util";
7
6
  import { JR2Client } from "../client.ts";
8
- import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
7
+ import { resolveTarget, targetOptions } from "../instance.ts";
9
8
  import { activity, result, type Io } from "../output.ts";
10
9
  import { resolveRunId } from "../run-id.ts";
10
+ import { childErrorActivity, placingActivity } from "./run.ts";
11
+ import { parseCommand, usageLine } from "../command-table.ts";
11
12
 
12
13
  export async function logs(args: string[], io: Io): Promise<number> {
13
- const { values, positionals } = parseArgs({
14
- args,
15
- allowPositionals: true,
16
- strict: false,
17
- options: { follow: { type: "boolean", short: "f" }, ...TARGET_ARGS },
18
- });
14
+ const { values, positionals } = parseCommand("logs", args);
19
15
  const given = positionals[0];
20
16
  if (!given) {
21
- activity(io, "usage: jr2 logs <runId|abbrev> [-f]");
17
+ activity(io, usageLine("logs"));
22
18
  return 2;
23
19
  }
24
20
  const target = await resolveTarget(io, targetOptions(values));
@@ -39,6 +35,14 @@ export async function logs(args: string[], io: Io): Promise<number> {
39
35
  activity(io, `retry ${ev.child} attempt ${ev.attempt} (${ev.reason})`);
40
36
  continue;
41
37
  }
38
+ if (ev.kind === "placing" || ev.kind === "placed") {
39
+ activity(io, placingActivity(ev));
40
+ continue;
41
+ }
42
+ if (ev.kind === "child-error") {
43
+ activity(io, childErrorActivity(ev));
44
+ continue;
45
+ }
42
46
  result(io, ev.status);
43
47
  if (!values.follow) break;
44
48
  if (ev.status.status !== "active") break;
@@ -7,27 +7,29 @@
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.
14
+ //
15
+ // The exit code (ADR-0073): 0 only for a run that ended `done`. 1 for every other end — `failed`,
16
+ // `error`, `cancelled`, `lost`, `drifted` — and for a run that was parked before it settled. 2 is a
17
+ // usage error, as for every verb. The status line on stderr says which end it was, and a failed
18
+ // run's reason follows it.
10
19
 
11
- import { parseArgs } from "node:util";
12
- import { JR2Client } from "../client.ts";
13
- import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
20
+ import type { ChildErrorMarker, PlacingMarker } from "@jr2/orchestrator/internal";
21
+ import { JR2Client, JR2HttpError } from "../client.ts";
22
+ import { failureActivity } from "./status.ts";
23
+ import { resolveTarget, targetOptions } from "../instance.ts";
14
24
  import { activity, result, type Io } from "../output.ts";
25
+ import { parseCommand, usageLine } from "../command-table.ts";
15
26
 
16
27
  export async function run(args: string[], io: Io): Promise<number> {
17
- const { values, positionals } = parseArgs({
18
- args,
19
- allowPositionals: true,
20
- strict: false,
21
- options: {
22
- input: { type: "string" },
23
- detach: { type: "boolean" },
24
- ...TARGET_ARGS,
25
- },
26
- });
28
+ const { values, positionals } = parseCommand("run", args);
27
29
 
28
30
  const workflow = positionals[0];
29
31
  if (!workflow) {
30
- activity(io, "usage: jr2 run <workflow> [--input <json>] [--detach]");
32
+ activity(io, usageLine("run"));
31
33
  return 2;
32
34
  }
33
35
  const input = values.input ? (JSON.parse(String(values.input)) as Record<string, unknown>) : {};
@@ -35,7 +37,14 @@ export async function run(args: string[], io: Io): Promise<number> {
35
37
  try {
36
38
  const client = new JR2Client(target.url, io.fetch, target.token);
37
39
 
38
- const { runId } = await client.start(workflow, input);
40
+ let runId: string;
41
+ try {
42
+ ({ runId } = await client.start(workflow, input));
43
+ } catch (err) {
44
+ if (!(err instanceof JR2HttpError) || !err.report) throw err;
45
+ activity(io, refusalActivity(workflow, err.report));
46
+ return 1;
47
+ }
39
48
  if (values.detach) {
40
49
  result(io, { runId });
41
50
  return 0;
@@ -51,14 +60,58 @@ export async function run(args: string[], io: Io): Promise<number> {
51
60
  activity(io, ` retry ${ev.child} attempt ${ev.attempt} (${ev.reason})`);
52
61
  continue;
53
62
  }
63
+ if (ev.kind === "placing" || ev.kind === "placed") {
64
+ activity(io, ` ${placingActivity(ev)}`);
65
+ continue;
66
+ }
67
+ if (ev.kind === "child-error") {
68
+ activity(io, ` ${childErrorActivity(ev)}`);
69
+ continue;
70
+ }
54
71
  activity(io, ` → ${ev.status.status} ${JSON.stringify(ev.status.value)}`);
55
72
  if (ev.status.status !== "active") {
73
+ const failed = failureActivity(ev.status);
74
+ if (failed) activity(io, failed);
56
75
  result(io, ev.status);
57
- return ev.status.status === "error" ? 1 : 0;
76
+ return ev.status.status === "done" ? 0 : 1;
58
77
  }
59
78
  }
60
- return 0;
79
+ // `events()` re-attaches until the run settles (ADR-0022), so reaching here means the run left
80
+ // the live set without settling: stopped, parked for the next boot's restore (ADR-0025). Never
81
+ // a success, and nothing on stdout to be mistaken for a result.
82
+ activity(
83
+ io,
84
+ `run ${runId} is parked: stopped before it settled — \`jr2 logs -f ${runId}\` follows it once restored`,
85
+ );
86
+ return 1;
61
87
  } finally {
62
88
  target.close?.();
63
89
  }
64
90
  }
91
+
92
+ /**
93
+ * The door's refusal of a Chart (ADR-0070) as activity: the Workflow, then each report line, which
94
+ * names the path in the Chart and the rule it fails.
95
+ */
96
+ export function refusalActivity(workflow: string, report: readonly string[]): string {
97
+ return [`error: the door refused the Chart for "${workflow}"`, ...report.map((line) => ` ${line}`)].join("\n");
98
+ }
99
+
100
+ /**
101
+ * A wait for capacity as one activity line (ADR-0064): the Workspace, what it waits for, and the
102
+ * scheduler's or the quota's own words — then, once it has a node, how long it waited. The run's
103
+ * root Workspace has no child path, so its line names none.
104
+ */
105
+ export function placingActivity(ev: PlacingMarker): string {
106
+ const who = ev.child ? ` ${ev.child}` : "";
107
+ if (ev.kind === "placed") return `placed${who} after ${Math.round(ev.after / 1000)}s`;
108
+ return `placing${who}: waits for ${ev.on === "quota" ? "quota" : "a node"} — ${ev.message}`;
109
+ }
110
+
111
+ /**
112
+ * A child's error below an Agent's state as one activity line (ADR-0024): the pick's answer carries
113
+ * no reply for it, so the Agent is not told, and this line is where the operator reads it.
114
+ */
115
+ export function childErrorActivity(ev: ChildErrorMarker): string {
116
+ return `child-error: \`${ev.child}\` failed below ${ev.agent}'s pick \`${ev.pick}\` — ${ev.error}`;
117
+ }
@@ -1,19 +1,96 @@
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
+ // 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.
9
+ //
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.
3
15
 
4
- import { parseArgs } from "node:util";
5
- import { JR2Client } from "../client.ts";
6
- import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
7
- import { result, type Io } from "../output.ts";
16
+ import type { RunChild } from "@jr2/orchestrator/internal";
17
+ import { JR2Client, type RunStatus, type WorkflowListing, type WorkflowOrigin } from "../client.ts";
18
+ import { resolveTarget, targetOptions } from "../instance.ts";
19
+ import { activity, result, type Io } from "../output.ts";
20
+ import { parseCommand } from "../command-table.ts";
8
21
 
9
22
  export async function runs(args: string[], io: Io): Promise<number> {
10
- const { values } = parseArgs({ args, allowPositionals: true, strict: false, options: { ...TARGET_ARGS } });
23
+ const { values } = parseCommand("runs", args);
11
24
  const target = await resolveTarget(io, targetOptions(values));
12
25
  try {
13
26
  const client = new JR2Client(target.url, io.fetch, target.token);
14
- result(io, await client.list());
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);
15
39
  return 0;
16
40
  } finally {
17
41
  target.close?.();
18
42
  }
19
43
  }
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
+ }