@jr2/cli 0.6.0 → 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.
@@ -40,8 +40,9 @@ export type Arg = { readonly name: string; readonly optional?: boolean; readonly
40
40
  /** One env var the verb reads. */
41
41
  export type EnvVar = { readonly name: string; readonly description: string };
42
42
 
43
- /** The ADR-0009 output classes: one JSON result on stdout, a report table on stdout, or activity alone. */
44
- export type OutputKind = "result" | "report" | "activity";
43
+ /** The ADR-0009 output classes: one JSON result on stdout, a report (a table or lines) on stdout,
44
+ * activity alone, or one value alone on stdout (a credential, for `$(…)`). */
45
+ export type OutputKind = "result" | "report" | "activity" | "value";
45
46
 
46
47
  /** What a verb prints. `form` names the spelling that prints it, when that is not the whole verb. */
47
48
  export type Output = { readonly kind: OutputKind; readonly form?: string };
@@ -122,7 +123,7 @@ export const JR2_URL = {
122
123
  export const JR2_TOKEN = {
123
124
  name: "JR2_TOKEN",
124
125
  description:
125
- "the Instance token to send: with `--url` / `JR2_URL` the only one, and otherwise used in place of the one read from the cluster",
126
+ "the Caller's credential to send, which makes the CLI that Caller: with `--url` / `JR2_URL` the only one, and otherwise used in place of the Instance token read from the cluster",
126
127
  } as const satisfies EnvVar;
127
128
 
128
129
  /** The addressing every run verb takes: a deployment by kube, or an orchestrator by url. */
@@ -187,7 +188,7 @@ const TABLE = [
187
188
  env: [],
188
189
  summary: "typecheck this instance and load its workflows, as `jr2 up` does first, with no cluster",
189
190
  lines: [
190
- "runs the check that `jr2 up` runs before it stages the Assembly, and exits 1 with the same refusal: a type error, a module that refuses to load (a refusal of `createMachine`, `defineEvent` or `workspace()`), an Open part, a Chart file, a dispatch, an Effect secret or a Size",
191
+ "runs the check that `jr2 up` runs before it stages the Assembly, and exits 1 with its refusal: a type error, a module that refuses to load (`createMachine`, `defineEvent` or `workspace()`), an Open part, a Chart file, a dispatch, an Effect secret or a Size",
191
192
  "reads no kube context and writes no file, so an Assistant runs it before it gives a change to the user",
192
193
  ],
193
194
  output: ACTIVITY,
@@ -206,7 +207,7 @@ const TABLE = [
206
207
  {
207
208
  name: "skip-provider-preflight",
208
209
  description:
209
- "do not run the provider preflight: no check pod checks the models, so a check that fails when the model works does not stop the converge",
210
+ "do not run the provider preflight: no check pod checks the models or reads an MCP server's tool list, so a check that fails while the model or server works does not stop the converge",
210
211
  },
211
212
  NAMESPACE_FLAG,
212
213
  CONTEXT_FLAG,
@@ -215,7 +216,7 @@ const TABLE = [
215
216
  summary: "converge the current kube context to this instance",
216
217
  lines: [
217
218
  "reads each `git.credentials` token from the env var it names, after the Instance's .env",
218
- "before the cluster work, sets the text between the jr2-docs markers in AGENTS.md to the docs index of the Kit version, and changes nothing outside them; a folder with no markers is left alone",
219
+ "before the cluster work, sets the text between the jr2-docs markers in AGENTS.md to the docs index of the Kit version, and nothing else; a folder with no markers is left alone",
219
220
  ],
220
221
  output: ACTIVITY,
221
222
  adrs: ["0019", "0051", "0069", "0072"],
@@ -262,7 +263,7 @@ const TABLE = [
262
263
  env: [],
263
264
  summary: "mirror the published kit images into a registry",
264
265
  lines: [
265
- `the v${KIT_VERSION} kit images (harness, operator, orchestrator) from their canonical home, and the Custodian's pinned Envoy from its own, for a cluster whose \`kitRegistry\` names the registry`,
266
+ `the v${KIT_VERSION} kit images (harness, operator, orchestrator) from their canonical home, and the Custodian's pinned Envoy from its own, for a cluster whose \`kitRegistry\` names it`,
266
267
  ],
267
268
  output: RESULT,
268
269
  adrs: ["0044", "0059", "0069"],
@@ -273,6 +274,10 @@ const TABLE = [
273
274
  flags: [
274
275
  { name: "input", value: "json", description: "the run's input, as a JSON object (default: {})" },
275
276
  { name: "detach", description: "print the runId and return, and do not stream" },
277
+ {
278
+ name: "drop-held",
279
+ description: "resume a paused standing Workflow with no held item: its held items are not carried",
280
+ },
276
281
  ...TARGET_FLAGS,
277
282
  ],
278
283
  env: TARGET_ENV,
@@ -281,25 +286,70 @@ const TABLE = [
281
286
  "--detach: just print the runId and return",
282
287
  "a Chart that the door refuses prints one line for each rule that it fails",
283
288
  "exits 0 only for a run that ended done, and 1 for every other end: failed, error, cancelled, lost, drifted, or parked before it settled; a failed run prints its reason",
289
+ "a standing Workflow: jr2 starts its run, so `jr2 run` only resumes a paused one, prints the new run, and returns",
284
290
  ],
285
291
  output: RESULT,
286
- adrs: ["0009", "0070", "0073"],
292
+ adrs: ["0009", "0017", "0070", "0073"],
287
293
  },
288
294
  {
289
295
  verb: "runs",
290
296
  args: [],
291
297
  flags: [
292
- { name: "workflows", description: "print the Workflow listing as the JSON result instead" },
298
+ {
299
+ name: "status",
300
+ value: "s",
301
+ description: "live (the default), ended, all, or a comma list such as done,failed,drifted",
302
+ },
303
+ { name: "workflow", value: "name", description: "only the runs of this Workflow" },
304
+ {
305
+ name: "since",
306
+ value: "when",
307
+ description: "only the runs that started since: a duration (2h, 7d) or an ISO-8601 time",
308
+ },
309
+ ...TARGET_FLAGS,
310
+ ],
311
+ env: TARGET_ENV,
312
+ summary:
313
+ 'list runs, newest first (live by default); "placing" names the Workspaces that wait for capacity, and "status <runId>" says why',
314
+ lines: [
315
+ "each run is a summary: runId, workflow, status, reason, startedBy, startedAt, endedAt, assembly, openGates; an ended run is listed while jr2 keeps it (runs.retain)",
316
+ "`jr2 workflows` lists the Workflows that a run can start",
317
+ ],
318
+ output: RESULT,
319
+ adrs: ["0009", "0030", "0064", "0065"],
320
+ },
321
+ {
322
+ verb: "workflows",
323
+ args: [],
324
+ flags: [
325
+ { name: "json", description: "print the listing (GET /workflows) as the one report object instead of the tree" },
293
326
  ...TARGET_FLAGS,
294
327
  ],
295
328
  env: TARGET_ENV,
296
- summary: 'list live runs; "placing" names the Workspaces that wait for capacity, and "status <runId>" says why',
329
+ summary: "list the Workflows that a run can start, each with its origin: machine, chart file or chart",
297
330
  lines: [
298
- "the Workflows a run can start on stderr, each with its origin (machine, chart file, chart) and a Chart wrapper's Charts under it; --workflows prints that listing as the result",
331
+ "a Chart wrapper's named Charts indent under it; an unrunnable one says why",
299
332
  "a runtime Chart has no verb: PUT or DELETE /workflows/<wrapper>/charts/<name> with the Instance token (an MCP or curl), then `jr2 run <wrapper>/<name>` starts it",
300
333
  ],
334
+ output: [{ kind: "report" }],
335
+ adrs: ["0009", "0070"],
336
+ },
337
+ {
338
+ verb: "gates",
339
+ args: [],
340
+ flags: [
341
+ { name: "workflow", value: "name", description: "only the Gates of this Workflow's runs" },
342
+ ...TARGET_FLAGS,
343
+ ],
344
+ env: TARGET_ENV,
345
+ summary: "list the open Gates that this Caller may answer, across the live runs, oldest first",
346
+ lines: [
347
+ "each Gate is an entry: runId, workflow, gate, path, accepts, acceptsNow, meta, openedAt, and hook (the Hook kinds that it hears); never a run's context",
348
+ "needs the answer scope; a scope that names single Gates ({ workflow, gates }) lists only those Gates",
349
+ "`jr2 send <runId> --gate <gate> --event <name>` answers one",
350
+ ],
301
351
  output: RESULT,
302
- adrs: ["0009", "0064", "0070"],
352
+ adrs: ["0009", "0011", "0078"],
303
353
  },
304
354
  {
305
355
  verb: "status",
@@ -315,14 +365,44 @@ const TABLE = [
315
365
  adrs: ["0048", "0051", "0073"],
316
366
  },
317
367
  {
318
- verb: "logs",
368
+ verb: "watch",
319
369
  args: [RUN_ID],
320
- flags: [{ name: "follow", short: "f", description: "follow until the run settles" }, ...TARGET_FLAGS],
370
+ flags: [
371
+ { name: "json", description: "print each status as one JSON line, and the other events on stderr" },
372
+ ...TARGET_FLAGS,
373
+ ],
321
374
  env: TARGET_ENV,
322
- summary: "replay a run's status; -f to follow until it settles",
323
- lines: [],
324
- output: RESULT,
325
- adrs: ["0009"],
375
+ summary: "follow a run's feed to its end, with the lines that `jr2 run` prints while it is attached",
376
+ lines: ["on attach it prints where the run is now; an ended run prints its last status once"],
377
+ output: [{ kind: "report" }],
378
+ adrs: ["0009", "0022"],
379
+ },
380
+ {
381
+ verb: "logs",
382
+ args: [
383
+ RUN_ID,
384
+ {
385
+ name: "agent",
386
+ optional: true,
387
+ description: "one Agent slot, as `jr2 steer <runId>` lists it, or an Agent name (default: every Agent)",
388
+ },
389
+ ],
390
+ flags: [
391
+ {
392
+ name: "follow",
393
+ short: "f",
394
+ description: "print each new entry as it is kept, until the run is not live and no conversation is open",
395
+ },
396
+ ...TARGET_FLAGS,
397
+ ],
398
+ env: TARGET_ENV,
399
+ summary: "print a run's conversations as the Harness prints them, every Agent interleaved by time, or one Agent",
400
+ lines: [
401
+ "each line is `[<agent>] [<instance id>] [<label>] <text>`; Menu-only Agents too; a Working tool's result is never in it",
402
+ "reads what the Orchestrator keeps of each conversation, so it works after the Sandbox is gone, while jr2 keeps the run (runs.retain); pod logs stay `kubectl logs`",
403
+ ],
404
+ output: [{ kind: "report" }],
405
+ adrs: ["0009", "0023", "0065"],
326
406
  },
327
407
  {
328
408
  verb: "send",
@@ -358,6 +438,174 @@ const TABLE = [
358
438
  output: ACTIVITY,
359
439
  adrs: ["0009", "0011", "0013", "0025"],
360
440
  },
441
+ {
442
+ verb: "steer",
443
+ args: [
444
+ RUN_ID,
445
+ { name: "agent", optional: true, description: "the Agent slot, as the run's list names it" },
446
+ { name: "text", optional: true, description: "the message, at most 16 KiB" },
447
+ ],
448
+ flags: [...TARGET_FLAGS],
449
+ env: TARGET_ENV,
450
+ summary: "send a Steering message to one Agent of a live run, or list the Agent slots that can hear one",
451
+ lines: [
452
+ "an open Turn of the slot hears it at its next step boundary; else, or when that Turn ends first, the slot's next Turn in its Machine instance hears it",
453
+ "with a runId alone it prints the run's Agent slots, each with open and steerable; `jr2 status` prints the messages sent to the run, in `steering`",
454
+ "needs the steer scope for the run's Workflow",
455
+ ],
456
+ output: [
457
+ { kind: "activity", form: "steer <runId> <agent> <text>" },
458
+ { kind: "result", form: "steer <runId>" },
459
+ ],
460
+ adrs: ["0002", "0062", "0078"],
461
+ },
462
+ {
463
+ verb: "whoami",
464
+ args: [],
465
+ flags: [...TARGET_FLAGS],
466
+ env: TARGET_ENV,
467
+ summary: "print the Caller this credential is: how jr2 knows it, its name, what it may do, and whom it acts for",
468
+ lines: [
469
+ "on a kube context the Caller is token/instance, on behalf of the kube user; with --url and JR2_TOKEN it is that token's Caller",
470
+ "needs no scope, so a credential that may do nothing still sees why",
471
+ ],
472
+ output: RESULT,
473
+ adrs: ["0078"],
474
+ },
475
+ {
476
+ verb: "hooks",
477
+ args: [{ name: "name", description: "the Hook, as `hooks` in jr2.config.ts names it" }],
478
+ flags: [
479
+ { name: "limit", value: "n", description: "how many lines, newest first (default 50, at most 1000)" },
480
+ ...TARGET_FLAGS,
481
+ ],
482
+ env: TARGET_ENV,
483
+ summary:
484
+ "print a Hook's log: each delivery that jr2 keeps (runs.retain), newest first, and what the door did with it",
485
+ lines: [
486
+ "a line is a handshake, a delivery and its outcome, a repeat (seen), a payload the parse refused (with zod's issues), a request the kind could not read, or a failed follow-up post",
487
+ "needs the Instance token: no scope opens it",
488
+ ],
489
+ output: RESULT,
490
+ adrs: ["0011", "0078"],
491
+ },
492
+ {
493
+ verb: "caller",
494
+ sub: "token",
495
+ args: [{ name: "name", description: "the named Caller token, as `callers.tokens` declares it" }],
496
+ flags: [NAMESPACE_FLAG, CONTEXT_FLAG],
497
+ env: [],
498
+ summary: "print a named Caller token's value, read from its Secret over the kube API",
499
+ lines: ["`jr2 up` makes the value once, for each name that `callers.tokens` declares"],
500
+ output: [{ kind: "value" }],
501
+ adrs: ["0078"],
502
+ },
503
+ {
504
+ verb: "caller",
505
+ sub: "rotate",
506
+ args: [{ name: "name", description: "the named Caller token, as `callers.tokens` declares it" }],
507
+ flags: [NAMESPACE_FLAG, CONTEXT_FLAG],
508
+ env: [],
509
+ summary: "make a new value for a named Caller token; the old value stops working",
510
+ lines: [
511
+ "returns when the Orchestrator has loaded the new digest (about a minute), so the old value is then refused; after 150s it fails, and the old value works until the load",
512
+ "prints no value: `jr2 caller token <name>` prints the new one",
513
+ ],
514
+ output: ACTIVITY,
515
+ adrs: ["0078"],
516
+ },
517
+ {
518
+ verb: "workspace",
519
+ sub: "ls",
520
+ args: [{ name: "run", optional: true, description: "only the Workspaces of this run, or of a prefix of its id" }],
521
+ flags: [JSON_REPORT, NAMESPACE_FLAG, CONTEXT_FLAG],
522
+ env: [],
523
+ summary: "list the Workspaces: handle, Sandbox name, Workflow, phase, age, and whether a run holds it",
524
+ lines: [
525
+ "reads the Sandbox CRs over the kube API, with no Orchestrator: kube RBAC is the gate",
526
+ "a handle is <run-prefix>/<workspace path>, or the Sandbox's name",
527
+ "held: the run's Lease marked it (a live run, or one that an Orchestrator crash left); released: its run is over; idle: no Lease renewed it for the idle timeout",
528
+ ],
529
+ output: [{ kind: "report" }],
530
+ adrs: ["0009", "0012", "0021"],
531
+ },
532
+ {
533
+ verb: "workspace",
534
+ sub: "shell",
535
+ args: [
536
+ { name: "ws", description: "the Workspace: <run-prefix>/<path>, or its Sandbox's name" },
537
+ { name: "-- cmd", optional: true, description: "one command to run in place of a shell" },
538
+ ],
539
+ flags: [
540
+ {
541
+ name: "user",
542
+ description: "a shell in the User Container (`kubectl exec -c user`), which needs a shell in its image",
543
+ },
544
+ NAMESPACE_FLAG,
545
+ CONTEXT_FLAG,
546
+ ],
547
+ env: [],
548
+ summary:
549
+ "a shell in the Workspace as its Agents work: started by the Harness's spawner in the Agents' Landlock domain, with the Workspace's granted Stand-ins and its folder",
550
+ lines: [
551
+ "it never reaches the control Stand-in or a Stand-in that the Workspace does not grant",
552
+ "`jr2 workspace shell <ws> -- git status` runs one command with no terminal: Ctrl-C ends jr2, and the command gets the end of its input but no signal, so one that ignores it (a dev server) runs on",
553
+ ],
554
+ output: ACTIVITY,
555
+ adrs: ["0009", "0076"],
556
+ },
557
+ {
558
+ verb: "workspace",
559
+ sub: "forward",
560
+ args: [
561
+ { name: "ws", description: "the Workspace: <run-prefix>/<path>, its Sandbox's name, or <name>.jr2" },
562
+ { name: "port", description: "the port in the Sandbox pod" },
563
+ ],
564
+ flags: [
565
+ {
566
+ name: "stdio",
567
+ description: "carry one connection on stdin and stdout, for an ssh ProxyCommand",
568
+ },
569
+ NAMESPACE_FLAG,
570
+ CONTEXT_FLAG,
571
+ ],
572
+ env: [],
573
+ summary: "forward a local port to a port in the Workspace's pod; prints 127.0.0.1:<port> and holds it until Ctrl-C",
574
+ lines: [
575
+ "ssh: Host *.jr2 with ProxyCommand jr2 workspace forward %h 22 --stdio -n <ns>, then ssh <sandbox name>.jr2",
576
+ "there is no Service for the User Container: the forward goes through the kube API",
577
+ ],
578
+ output: [{ kind: "value" }],
579
+ adrs: ["0005", "0009"],
580
+ },
581
+ {
582
+ verb: "workspace",
583
+ sub: "rm",
584
+ args: [{ name: "ws", description: "the Workspace: <run-prefix>/<path>, or its Sandbox's name" }],
585
+ flags: [NAMESPACE_FLAG, CONTEXT_FLAG],
586
+ env: [],
587
+ summary: "remove a Sandbox that no run holds: released, or idle past its idle timeout",
588
+ lines: [
589
+ "refuses the Sandbox of a live run: cancel the run first; there is no --force",
590
+ "the keepalive's age is read on the API server's clock",
591
+ ],
592
+ output: ACTIVITY,
593
+ adrs: ["0009", "0021"],
594
+ },
595
+ {
596
+ verb: "completion",
597
+ args: [{ name: "shell", description: "bash, zsh or fish" }],
598
+ flags: [],
599
+ env: [],
600
+ summary: "print the shell completion script",
601
+ lines: [
602
+ "the script asks this jr2 for each completion, so the verbs and flags are the command table's",
603
+ "it completes Workspace handles, live run ids, Workflow names and a run's Agent slots too; a lookup that takes more than 2 s offers nothing",
604
+ "bash: source <(jr2 completion bash); zsh: source <(jr2 completion zsh); fish: jr2 completion fish | source",
605
+ ],
606
+ output: [{ kind: "value" }],
607
+ adrs: ["0009"],
608
+ },
361
609
  ] as const satisfies readonly Command[];
362
610
 
363
611
  /** Every verb, in help order. */
@@ -403,8 +651,9 @@ export const SECTIONS: readonly Section[] = [
403
651
  title: "output",
404
652
  text:
405
653
  `result verbs (${printing("result").join(", ")}) print one JSON result on stdout and activity on stderr, ` +
406
- `so \`jr2 run … | jq\` yields the result; report verbs (${printing("report").join(", ")}) print a table on ` +
407
- `stdout, or the one object with --json; activity verbs (${printing("activity").join(", ")}) print activity alone`,
654
+ `so \`jr2 run … | jq\` yields the result; report verbs (${printing("report").join(", ")}) print a table or lines on ` +
655
+ `stdout, or their JSON with --json (watch: one JSON line for each status; logs has no --json); activity verbs (${printing("activity").join(", ")}) print activity alone; ` +
656
+ `value verbs (${printing("value").join(", ")}) print one value alone on stdout, for \`$(…)\`, and activity on stderr`,
408
657
  },
409
658
  ];
410
659
 
@@ -0,0 +1,115 @@
1
+ // `jr2 caller token <name>` and `jr2 caller rotate <name>` (ADR-0078): a named Caller token's value,
2
+ // and a new one.
3
+ //
4
+ // Both read and write the values over the kube API and never through the Orchestrator, as the
5
+ // Instance token's read does (ADR-0019): kube RBAC on the token's Secret is the gate. So they take
6
+ // the deployment's addressing (`-n`, `--context`) and not `--url`: an Orchestrator holds only the
7
+ // digests, and no route of it gives a value.
8
+ //
9
+ // `token` prints the value alone on stdout, so `JR2_TOKEN=$(jr2 caller token ci)` holds it. `rotate`
10
+ // makes a new value and prints none. The old value stops working when the Orchestrator loads the
11
+ // new digest, after the kubelet updates its mount. So `rotate` waits for that, as `jr2 up` does,
12
+ // over a port-forward to the Orchestrator's open `GET /assembly`, which gives digests and no value.
13
+
14
+ import { basename } from "node:path";
15
+ import { callerTokenSecret, loadConfig, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "@jr2/orchestrator/internal";
16
+ import { rotateCallerToken } from "../caller-tokens.ts";
17
+ import { commandNamed, parseCommand, renderCommand, usageLine } from "../command-table.ts";
18
+ import { CALLER_TOKEN_KEY } from "../deploy.ts";
19
+ import { resolveRoot } from "../instance.ts";
20
+ import { kubectlAdmin, kubectlKube } from "../kube.ts";
21
+ import { awaitMounts, staleLine, type MountsView } from "../mount-wait.ts";
22
+ import { activity, type Io } from "../output.ts";
23
+
24
+ export async function caller(args: string[], io: Io): Promise<number> {
25
+ const sub = args[0];
26
+ if (sub !== "token" && sub !== "rotate") {
27
+ activity(io, sub === undefined ? "jr2 caller: no subcommand" : `unknown caller subcommand: ${sub}`);
28
+ activity(io, [commandNamed("caller token"), commandNamed("caller rotate")].map(renderCommand).join("\n\n"));
29
+ return 2;
30
+ }
31
+ const { values, positionals } =
32
+ sub === "token" ? parseCommand("caller token", args) : parseCommand("caller rotate", args);
33
+ const name = positionals[1];
34
+ if (!name) {
35
+ activity(io, `jr2 caller ${sub}: no token name`);
36
+ activity(io, usageLine(sub === "token" ? "caller token" : "caller rotate"));
37
+ return 2;
38
+ }
39
+
40
+ const root = resolveRoot(io.cwd);
41
+ const config = (await loadConfig(root)) ?? {};
42
+ const instance = config.name ?? basename(root);
43
+ const namespace = (values.namespace as string | undefined) ?? instance;
44
+ const ctx = values.context ? { context: values.context as string } : {};
45
+
46
+ if (sub === "token") {
47
+ const kube = io.kube ?? kubectlKube;
48
+ const context = (values.context as string | undefined) ?? (await kube.currentContext());
49
+ if (!context) throw new Error("no kube context — `kind create cluster` (or point kubectl at one), then `jr2 up`");
50
+ activity(io, `→ context ${context} / namespace ${namespace}`);
51
+ let value: string | undefined;
52
+ try {
53
+ value = await kube.readSecret({ namespace, name: callerTokenSecret(name), key: CALLER_TOKEN_KEY, ...ctx });
54
+ } catch (e) {
55
+ throw new Error(`cannot reach the cluster — context ${context}: ${(e as Error).message}`);
56
+ }
57
+ if (!value) {
58
+ throw new Error(
59
+ `no Caller token "${name}" here (context ${context}, namespace ${namespace}) — declare it in ` +
60
+ "callers.tokens of jr2.config.ts, then `jr2 up` makes it",
61
+ );
62
+ }
63
+ io.stdout(`${value}\n`);
64
+ return 0;
65
+ }
66
+
67
+ const kube = io.kubeAdmin ?? kubectlAdmin;
68
+ const context = (values.context as string | undefined) ?? (await kube.context());
69
+ if (!context) throw new Error("no kube context — `kind create cluster` (or point kubectl at one), then `jr2 up`");
70
+ activity(io, `→ context ${context} / namespace ${namespace}`);
71
+ const applied = await rotateCallerToken(kube, { instance, namespace, name, ctx });
72
+ const stale = await awaitLoaded(io, { namespace, ctx, want: applied, prefix: `caller token "${name}"` });
73
+ if (stale.length) {
74
+ activity(
75
+ io,
76
+ `caller token "${name}": rotated, but ${staleLine(stale)}; the old value still works until it does. ` +
77
+ `\`jr2 caller token ${name}\` prints the new one`,
78
+ );
79
+ return 1;
80
+ }
81
+ activity(
82
+ io,
83
+ `caller token "${name}": rotated — the old value no longer works; \`jr2 caller token ${name}\` prints the new one`,
84
+ );
85
+ return 0;
86
+ }
87
+
88
+ /**
89
+ * Wait until the Orchestrator has loaded the digests that `want` names (ADR-0078), over a
90
+ * port-forward of its Service, as `jr2 up` reaches it. Returns the names that were still older when
91
+ * the wait ended: none when it succeeded.
92
+ */
93
+ async function awaitLoaded(
94
+ io: Io,
95
+ opts: { namespace: string; ctx: { context?: string }; want: Record<string, string>; prefix: string },
96
+ ): Promise<string[]> {
97
+ const fetchImpl = io.fetch ?? globalThis.fetch;
98
+ const fwd = await (io.kube ?? kubectlKube).portForward({
99
+ namespace: opts.namespace,
100
+ service: ORCHESTRATOR_SERVICE,
101
+ port: ORCHESTRATOR_PORT,
102
+ ...opts.ctx,
103
+ });
104
+ try {
105
+ const base = fwd.url.replace(/\/+$/, "");
106
+ const view = async (): Promise<MountsView> => {
107
+ const res = await fetchImpl(`${base}/assembly`);
108
+ if (!res.ok) throw new Error(`the Orchestrator answered GET /assembly with HTTP ${res.status}`);
109
+ return (await res.json()) as MountsView;
110
+ };
111
+ return (await awaitMounts(io, { seen: await view(), view, want: opts.want, prefix: opts.prefix })).stale;
112
+ } finally {
113
+ fwd.close();
114
+ }
115
+ }