@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.
- package/package.json +3 -3
- package/src/build.ts +4 -4
- package/src/caller-tokens.ts +108 -0
- package/src/cli.ts +33 -2
- package/src/client.ts +224 -23
- package/src/command-table.ts +269 -20
- package/src/commands/caller.ts +115 -0
- package/src/commands/completion.ts +236 -0
- package/src/commands/gates.ts +23 -0
- package/src/commands/hooks.ts +31 -0
- package/src/commands/init.ts +2 -3
- package/src/commands/logs.ts +107 -31
- package/src/commands/run.ts +77 -27
- package/src/commands/runs.ts +17 -78
- package/src/commands/send.ts +1 -1
- package/src/commands/status.ts +121 -6
- package/src/commands/steer.ts +47 -0
- package/src/commands/up.ts +373 -70
- package/src/commands/watch.ts +57 -0
- package/src/commands/whoami.ts +22 -0
- package/src/commands/workflows.ts +58 -0
- package/src/commands/workspace.ts +316 -0
- package/src/deploy.ts +233 -12
- package/src/instance-check.ts +47 -1
- package/src/instance.ts +16 -5
- package/src/kube.ts +37 -3
- package/src/mount-wait.ts +82 -0
- package/src/output.ts +3 -0
- package/src/sse.ts +1 -1
- package/src/workspaces.ts +307 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// `jr2 completion bash|zsh|fish` (ADR-0009): the shell completion script, a value on stdout.
|
|
2
|
+
//
|
|
3
|
+
// The script is small and the same for each shell: on each TAB it asks this jr2 for the words that
|
|
4
|
+
// complete the command line, with `jr2 completion __complete <words…>`, the last word being the one
|
|
5
|
+
// the cursor is in. The answer comes from the command table (`complete`), so a verb, a sub-verb or a
|
|
6
|
+
// flag that the CLI does not take never completes. Some values come from the deployment: Workspace
|
|
7
|
+
// handles (from the Sandbox CRs' labels, over kube), live run ids, Workflow names, and a run's Agent
|
|
8
|
+
// slots (from the Orchestrator). Each lookup has 2 s: a slower one offers nothing, so a slow cluster
|
|
9
|
+
// never holds the shell. `__complete` is not in the table: no person types it.
|
|
10
|
+
|
|
11
|
+
import { JR2Client } from "../client.ts";
|
|
12
|
+
import { commandsFor, COMMANDS, parseCommand, usageLine, type Command } from "../command-table.ts";
|
|
13
|
+
import { resolveRoot, resolveTarget } from "../instance.ts";
|
|
14
|
+
import { activity, type Io } from "../output.ts";
|
|
15
|
+
import { kubectlWorkspaces, rowOf } from "../workspaces.ts";
|
|
16
|
+
import { basename } from "node:path";
|
|
17
|
+
import { loadConfig } from "@jr2/orchestrator/internal";
|
|
18
|
+
|
|
19
|
+
/** The longest a dynamic lookup may take before it offers nothing. */
|
|
20
|
+
export const LOOKUP_BUDGET_MS = 2_000;
|
|
21
|
+
|
|
22
|
+
/** A value from the deployment, by what the argument or the flag names. */
|
|
23
|
+
export type DynamicKind = "run" | "workflow" | "agent" | "workspace";
|
|
24
|
+
|
|
25
|
+
/** Where `complete` finds dynamic values: `run` is the run id that the line names, for `agent`. */
|
|
26
|
+
export type Lookup = (
|
|
27
|
+
kind: DynamicKind,
|
|
28
|
+
ctx: { run?: string; namespace?: string; context?: string },
|
|
29
|
+
) => Promise<string[]>;
|
|
30
|
+
|
|
31
|
+
/** The completion scripts: each asks `jr2 completion __complete` with the words up to the cursor. */
|
|
32
|
+
export const SCRIPTS: Readonly<Record<"bash" | "zsh" | "fish", string>> = {
|
|
33
|
+
bash: [
|
|
34
|
+
"# jr2 completion for bash: source <(jr2 completion bash)",
|
|
35
|
+
"_jr2_complete() {",
|
|
36
|
+
" local IFS=$'\\n'",
|
|
37
|
+
' COMPREPLY=($(jr2 completion __complete "${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null))',
|
|
38
|
+
"}",
|
|
39
|
+
"complete -o default -F _jr2_complete jr2",
|
|
40
|
+
"",
|
|
41
|
+
].join("\n"),
|
|
42
|
+
zsh: [
|
|
43
|
+
"#compdef jr2",
|
|
44
|
+
"# jr2 completion for zsh: source <(jr2 completion zsh)",
|
|
45
|
+
"_jr2() {",
|
|
46
|
+
" local -a candidates",
|
|
47
|
+
// Unquoted, so an empty answer gives no element, and compadd adds no empty match.
|
|
48
|
+
' candidates=(${(f)"$(jr2 completion __complete "${(@)words[2,CURRENT]}" 2>/dev/null)"})',
|
|
49
|
+
' (( ${#candidates} )) && compadd -Q -- "${candidates[@]}"',
|
|
50
|
+
"}",
|
|
51
|
+
"compdef _jr2 jr2",
|
|
52
|
+
"",
|
|
53
|
+
].join("\n"),
|
|
54
|
+
fish: [
|
|
55
|
+
"# jr2 completion for fish: jr2 completion fish | source",
|
|
56
|
+
"function __jr2_complete",
|
|
57
|
+
" set -l words (commandline -opc) (commandline -ct)",
|
|
58
|
+
" jr2 completion __complete $words[2..-1] 2>/dev/null",
|
|
59
|
+
"end",
|
|
60
|
+
"complete -c jr2 -f -a '(__jr2_complete)'",
|
|
61
|
+
"",
|
|
62
|
+
].join("\n"),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The dynamic kind of a positional argument, by its name in the table. */
|
|
66
|
+
function argKind(name: string): DynamicKind | "shell" | undefined {
|
|
67
|
+
if (name === "runId" || name === "run") return "run";
|
|
68
|
+
if (name === "workflow") return "workflow";
|
|
69
|
+
if (name === "agent") return "agent";
|
|
70
|
+
if (name === "ws") return "workspace";
|
|
71
|
+
if (name === "shell") return "shell";
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Static values of a flag, or the dynamic kind it takes. */
|
|
76
|
+
function flagValues(name: string): string[] | DynamicKind | undefined {
|
|
77
|
+
if (name === "workflow") return "workflow";
|
|
78
|
+
if (name === "status")
|
|
79
|
+
return ["live", "ended", "all", "active", "done", "failed", "error", "cancelled", "lost", "drifted"];
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The words that complete `words` (the line after `jr2`; the last one is the word the cursor is in,
|
|
85
|
+
* empty at a new word). Verbs, sub-verbs, flags and their values from the command table; dynamic
|
|
86
|
+
* values from `lookup`. Every candidate starts with the current word.
|
|
87
|
+
*/
|
|
88
|
+
export async function complete(words: readonly string[], lookup: Lookup): Promise<string[]> {
|
|
89
|
+
const cur = words.at(-1) ?? "";
|
|
90
|
+
const done = words.slice(0, -1);
|
|
91
|
+
const starts = (list: Iterable<string>) => [...new Set(list)].filter((w) => w.startsWith(cur));
|
|
92
|
+
if (done.length === 0) return starts(COMMANDS.map((c) => c.verb));
|
|
93
|
+
const entries = commandsFor(done[0]!);
|
|
94
|
+
if (entries.length === 0) return [];
|
|
95
|
+
let entry: Command | undefined;
|
|
96
|
+
let rest: string[];
|
|
97
|
+
if (entries.some((c) => c.sub !== undefined)) {
|
|
98
|
+
if (done.length === 1) return starts(entries.flatMap((c) => (c.sub ? [c.sub] : [])));
|
|
99
|
+
entry = entries.find((c) => c.sub === done[1]);
|
|
100
|
+
rest = done.slice(2);
|
|
101
|
+
} else {
|
|
102
|
+
entry = entries[0];
|
|
103
|
+
rest = done.slice(1);
|
|
104
|
+
}
|
|
105
|
+
if (!entry) return [];
|
|
106
|
+
|
|
107
|
+
// The addressing on the line, for the lookups.
|
|
108
|
+
const flagOf = (word: string) =>
|
|
109
|
+
entry.flags.find((f) => word === `--${f.name}` || (f.short !== undefined && word === `-${f.short}`));
|
|
110
|
+
const given = (name: string) => {
|
|
111
|
+
const i = rest.findIndex((w) => flagOf(w)?.name === name);
|
|
112
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
113
|
+
};
|
|
114
|
+
const ctx = {
|
|
115
|
+
...(given("namespace") ? { namespace: given("namespace")! } : {}),
|
|
116
|
+
...(given("context") ? { context: given("context")! } : {}),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// A flag's value.
|
|
120
|
+
const prev = flagOf(rest.at(-1) ?? "");
|
|
121
|
+
if (prev?.value !== undefined && !rest.includes("--")) {
|
|
122
|
+
const values = flagValues(prev.name);
|
|
123
|
+
if (values === undefined) return [];
|
|
124
|
+
return starts(Array.isArray(values) ? values : await lookup(values, ctx));
|
|
125
|
+
}
|
|
126
|
+
if (rest.includes("--")) return [];
|
|
127
|
+
if (cur.startsWith("-")) return starts(entry.flags.map((f) => `--${f.name}`));
|
|
128
|
+
|
|
129
|
+
// A positional: the arguments before the cursor, less the flags and their values.
|
|
130
|
+
const positionals: string[] = [];
|
|
131
|
+
for (let i = 0; i < rest.length; i++) {
|
|
132
|
+
const f = flagOf(rest[i]!);
|
|
133
|
+
if (f) {
|
|
134
|
+
if (f.value !== undefined) i++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!rest[i]!.startsWith("-")) positionals.push(rest[i]!);
|
|
138
|
+
}
|
|
139
|
+
const arg = entry.args[positionals.length];
|
|
140
|
+
const kind = arg ? argKind(arg.name) : undefined;
|
|
141
|
+
if (kind === undefined) return [];
|
|
142
|
+
if (kind === "shell") return starts(Object.keys(SCRIPTS));
|
|
143
|
+
return starts(await lookup(kind, { ...ctx, ...(positionals[0] ? { run: positionals[0] } : {}) }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `p`, or nothing after `ms`. A lookup that fails offers nothing too. */
|
|
147
|
+
export async function bounded(p: Promise<string[]>, ms = LOOKUP_BUDGET_MS): Promise<string[]> {
|
|
148
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
149
|
+
const late = new Promise<string[]>((resolve) => {
|
|
150
|
+
timer = setTimeout(() => resolve([]), ms);
|
|
151
|
+
timer.unref?.();
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
return await Promise.race([p.catch(() => []), late]);
|
|
155
|
+
} finally {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The real lookups: the Sandbox CRs over kube, and the Orchestrator for the rest. Quiet: no
|
|
161
|
+
* activity. With them, the close of each forward to the Orchestrator that a lookup holds now: a
|
|
162
|
+
* lookup past its budget is still in flight when `__complete` exits. */
|
|
163
|
+
function deploymentLookup(io: Io): { lookup: Lookup; closeOpen: () => void } {
|
|
164
|
+
const quiet: Io = { ...io, stderr: () => {} };
|
|
165
|
+
const open = new Set<() => void>();
|
|
166
|
+
const lookup: Lookup = async (kind, ctx) => {
|
|
167
|
+
if (kind === "workspace") {
|
|
168
|
+
const kube = io.workspaces ?? kubectlWorkspaces;
|
|
169
|
+
let namespace = ctx.namespace;
|
|
170
|
+
if (namespace === undefined) {
|
|
171
|
+
const root = resolveRoot(io.cwd);
|
|
172
|
+
namespace = (await loadConfig(root))?.name ?? basename(root);
|
|
173
|
+
}
|
|
174
|
+
const listed = await kube.list({ namespace, ...(ctx.context ? { context: ctx.context } : {}) });
|
|
175
|
+
return listed.items.flatMap((cr) => {
|
|
176
|
+
const row = rowOf(cr, listed.serverNow);
|
|
177
|
+
return row.path ? [row.handle, row.name] : [row.name];
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const target = await resolveTarget(quiet, { namespace: ctx.namespace, context: ctx.context });
|
|
181
|
+
const close = () => target.close?.();
|
|
182
|
+
open.add(close);
|
|
183
|
+
try {
|
|
184
|
+
const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
|
|
185
|
+
if (kind === "run") return (await client.runs({ status: "live" })).runs.map((r) => r.runId);
|
|
186
|
+
if (kind === "workflow")
|
|
187
|
+
return (await client.workflows()).flatMap((w) => [
|
|
188
|
+
w.name,
|
|
189
|
+
...(w.wrapper?.charts ?? []).map((c) => `${w.name}/${c.name}`),
|
|
190
|
+
]);
|
|
191
|
+
if (ctx.run === undefined) return [];
|
|
192
|
+
const [live, kept] = await Promise.all([
|
|
193
|
+
client.agents(ctx.run).catch(() => []),
|
|
194
|
+
client.conversations(ctx.run).catch(() => []),
|
|
195
|
+
]);
|
|
196
|
+
return [...live.map((a) => a.agent), ...kept.flatMap((c) => (c.slot ? [c.slot] : []))];
|
|
197
|
+
} finally {
|
|
198
|
+
open.delete(close);
|
|
199
|
+
close();
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
lookup,
|
|
204
|
+
closeOpen: () => {
|
|
205
|
+
for (const close of open) close();
|
|
206
|
+
open.clear();
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function completion(
|
|
212
|
+
args: string[],
|
|
213
|
+
io: Io,
|
|
214
|
+
opts: { lookup?: Lookup; exit?: (code: number) => void; budgetMs?: number } = {},
|
|
215
|
+
): Promise<number> {
|
|
216
|
+
if (args[0] === "__complete") {
|
|
217
|
+
const deployment = opts.lookup ? undefined : deploymentLookup(io);
|
|
218
|
+
const lookup = opts.lookup ?? deployment!.lookup;
|
|
219
|
+
const words = await complete(args.slice(1), (kind, ctx) => bounded(lookup(kind, ctx), opts.budgetMs));
|
|
220
|
+
for (const w of words) io.stdout(`${w}\n`);
|
|
221
|
+
// A lookup past its budget is still in flight, and the shell waits for this process: close the
|
|
222
|
+
// forward that it holds (its kubectl child also ends on this process's exit, `endsWithProcess`),
|
|
223
|
+
// then exit, not waiting for the lookup.
|
|
224
|
+
deployment?.closeOpen();
|
|
225
|
+
(opts.exit ?? ((code: number) => process.exit(code)))(0);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
const { positionals } = parseCommand("completion", args);
|
|
229
|
+
const shell = positionals[0] as keyof typeof SCRIPTS | undefined;
|
|
230
|
+
if (shell === undefined || !(shell in SCRIPTS) || positionals.length > 1) {
|
|
231
|
+
activity(io, usageLine("completion"));
|
|
232
|
+
return 2;
|
|
233
|
+
}
|
|
234
|
+
io.stdout(SCRIPTS[shell]);
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// `jr2 gates` (ADR-0011): the Gate inbox as JSON on stdout. Each open Gate of every live run that
|
|
2
|
+
// this Caller may answer, oldest first, from `GET /gates`: its run, the run's Workflow, the Gate's
|
|
3
|
+
// id and path, the events it accepts and those it takes now, its `meta`, when it opened, and the
|
|
4
|
+
// Hook kinds that it hears. Never a run's context (ADR-0014). `--workflow` keeps one Workflow's
|
|
5
|
+
// runs. A Caller whose `answer` scope names single Gates sees only those Gates (ADR-0078).
|
|
6
|
+
// `jr2 send <runId> --gate <gate> --event <name>` answers one.
|
|
7
|
+
|
|
8
|
+
import { JR2Client } from "../client.ts";
|
|
9
|
+
import { parseCommand } from "../command-table.ts";
|
|
10
|
+
import { resolveTarget, targetOptions } from "../instance.ts";
|
|
11
|
+
import { result, type Io } from "../output.ts";
|
|
12
|
+
|
|
13
|
+
export async function gates(args: string[], io: Io): Promise<number> {
|
|
14
|
+
const { values } = parseCommand("gates", args);
|
|
15
|
+
const target = await resolveTarget(io, targetOptions(values));
|
|
16
|
+
try {
|
|
17
|
+
const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
|
|
18
|
+
result(io, await client.gates(typeof values.workflow === "string" ? values.workflow : undefined));
|
|
19
|
+
return 0;
|
|
20
|
+
} finally {
|
|
21
|
+
target.close?.();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// `jr2 hooks <name>` (ADR-0011): a Hook's log, as `GET /hooks/:name` gives it — each delivery of
|
|
2
|
+
// the time that jr2 keeps it (`runs.retain`), newest first, and what the door did with it: a handshake, the outcome of a
|
|
3
|
+
// delivery, a repeat, a payload that the parse refused, a request that the kind could not read, or
|
|
4
|
+
// a follow-up post that failed.
|
|
5
|
+
// The route takes the Instance token alone, so on a kube context the CLI reads it, as for every
|
|
6
|
+
// run-verb; with `--url` it needs `JR2_TOKEN` to be the Instance token.
|
|
7
|
+
|
|
8
|
+
import { JR2Client } from "../client.ts";
|
|
9
|
+
import { commandNamed, parseCommand, renderCommand } from "../command-table.ts";
|
|
10
|
+
import { resolveTarget, targetOptions } from "../instance.ts";
|
|
11
|
+
import { activity, result, type Io } from "../output.ts";
|
|
12
|
+
|
|
13
|
+
export async function hooks(args: string[], io: Io): Promise<number> {
|
|
14
|
+
const { values, positionals } = parseCommand("hooks", args);
|
|
15
|
+
const [name] = positionals;
|
|
16
|
+
const limit = values.limit === undefined ? undefined : Number(values.limit);
|
|
17
|
+
if (!name || positionals.length > 1 || (limit !== undefined && !(Number.isInteger(limit) && limit >= 1))) {
|
|
18
|
+
activity(io, renderCommand(commandNamed("hooks")));
|
|
19
|
+
return 2;
|
|
20
|
+
}
|
|
21
|
+
const target = await resolveTarget(io, targetOptions(values));
|
|
22
|
+
try {
|
|
23
|
+
const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
|
|
24
|
+
const log = await client.hookLog(name, limit);
|
|
25
|
+
if (!log.declared) activity(io, `note: the current Assembly's config declares no Hook "${name}"`);
|
|
26
|
+
result(io, log);
|
|
27
|
+
return 0;
|
|
28
|
+
} finally {
|
|
29
|
+
target.close?.();
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -195,9 +195,8 @@ export default defineConfig({
|
|
|
195
195
|
// injects its runtime at POD time and never builds a stage on top of this file. It also satisfies
|
|
196
196
|
// the floor by construction — a glibc base with git — so the `preflight` init step that proves it
|
|
197
197
|
// at the pod's first provision passes without the author ever meeting it.
|
|
198
|
-
const IMAGE_DOCKERFILE = `# A Sandbox Image (ADR-0037): the tools your agents can reach, and the shell you get
|
|
199
|
-
# \`
|
|
200
|
-
# nothing out of it.
|
|
198
|
+
const IMAGE_DOCKERFILE = `# A Sandbox Image (ADR-0037): the tools your agents can reach, and the shell you get with
|
|
199
|
+
# \`jr2 workspace shell\`. This file is yours — jr2 never rewrites it and reads nothing out of it.
|
|
201
200
|
#
|
|
202
201
|
# The build context is THIS directory (\`images/default/\`), so \`COPY\` paths are relative to it and
|
|
203
202
|
# nothing outside it can invalidate the image's content hash.
|
package/src/commands/logs.ts
CHANGED
|
@@ -1,53 +1,129 @@
|
|
|
1
|
-
// `jr2 logs <runId|abbrev> [-f]` (ADR-0009):
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// `jr2 logs <runId|abbrev> [agent] [-f]` (ADR-0009, ADR-0023): a run's kept conversations, printed
|
|
2
|
+
// as the Harness prints them to its pod log. Each line is `[<agent>] [<instance id>] <body line>`,
|
|
3
|
+
// and a body starts with its label (`[text]`, `[bash]`, `[<pick> answer]`, `[cut]`). The
|
|
4
|
+
// conversations of every Agent of the run are interleaved by the time of each entry, or `[agent]`
|
|
5
|
+
// keeps one: an Agent slot as `jr2 steer <runId>` lists it (`<machine path>/<agent>`), or an Agent
|
|
6
|
+
// name. Menu-only Agents too. It reads the Orchestrator's record, so it works after the Sandbox is
|
|
7
|
+
// gone, for as long as the run is kept (`runs.retain`, ADR-0065).
|
|
8
|
+
//
|
|
9
|
+
// `-f` follows: it prints what is kept, then each new entry as the Orchestrator keeps it, and
|
|
10
|
+
// starts to follow each new conversation of the run, until the run is not live and no conversation
|
|
11
|
+
// is open. The line format is the Harness's `printLines` (packages/harness/src/printer.ts); the CLI
|
|
12
|
+
// does not import the Harness, so it is stated again here, and the test holds the two the same.
|
|
5
13
|
|
|
6
|
-
import { JR2Client } from "../client.ts";
|
|
14
|
+
import { JR2Client, type ConversationEntryView, type KeptConversationView } from "../client.ts";
|
|
7
15
|
import { resolveTarget, targetOptions } from "../instance.ts";
|
|
8
|
-
import { activity,
|
|
16
|
+
import { activity, type Io } from "../output.ts";
|
|
9
17
|
import { resolveRunId } from "../run-id.ts";
|
|
10
|
-
import { childErrorActivity, placingActivity } from "./run.ts";
|
|
11
18
|
import { parseCommand, usageLine } from "../command-table.ts";
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
/** How often `-f` asks for the run's conversations, to find a new one. */
|
|
21
|
+
export const LOGS_POLL_MS = 2_000;
|
|
22
|
+
|
|
23
|
+
/** One kept entry as the lines of the print (ADR-0023): `[<agent>] [<instance id>] ` before each line
|
|
24
|
+
* of its body. An entry that kept no label prints its text alone, as the Harness printed it. */
|
|
25
|
+
export function printedLines(instanceId: string, entry: ConversationEntryView): string {
|
|
26
|
+
const body = entry.kind === "line" ? entry.text : `[${entry.kind}] ${entry.text}`;
|
|
27
|
+
return body
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((line) => `[${entry.agent}] [${instanceId}] ${line}\n`)
|
|
30
|
+
.join("");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The conversations that `agent` names: those of that slot, else those of that Agent name. */
|
|
34
|
+
export function conversationsOf(
|
|
35
|
+
all: readonly KeptConversationView[],
|
|
36
|
+
agent: string | undefined,
|
|
37
|
+
): KeptConversationView[] {
|
|
38
|
+
if (agent === undefined) return [...all];
|
|
39
|
+
const bySlot = all.filter((c) => c.slot === agent);
|
|
40
|
+
return bySlot.length ? bySlot : all.filter((c) => c.agent === agent);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every kept entry of one conversation after `after`, page by page. */
|
|
44
|
+
async function entriesAfter(client: JR2Client, runId: string, instanceId: string, after: number) {
|
|
45
|
+
const out: ConversationEntryView[] = [];
|
|
46
|
+
for (let last = after; ; ) {
|
|
47
|
+
const page = await client.conversationEntries(runId, instanceId, last);
|
|
48
|
+
out.push(...page);
|
|
49
|
+
if (page.length < 1000) return out;
|
|
50
|
+
last = page.at(-1)!.seq;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function logs(args: string[], io: Io, opts: { pollMs?: number } = {}): Promise<number> {
|
|
14
55
|
const { values, positionals } = parseCommand("logs", args);
|
|
15
|
-
const given = positionals
|
|
16
|
-
if (!given) {
|
|
56
|
+
const [given, agent] = positionals;
|
|
57
|
+
if (!given || positionals.length > 2) {
|
|
17
58
|
activity(io, usageLine("logs"));
|
|
18
59
|
return 2;
|
|
19
60
|
}
|
|
20
61
|
const target = await resolveTarget(io, targetOptions(values));
|
|
21
62
|
try {
|
|
22
|
-
const client = new JR2Client(target.url, io.fetch, target.token);
|
|
63
|
+
const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
|
|
23
64
|
const ref = await resolveRunId(client, given);
|
|
24
65
|
if (!ref.ok) {
|
|
25
66
|
activity(io, ref.message);
|
|
26
67
|
return ref.code;
|
|
27
68
|
}
|
|
69
|
+
const runId = ref.runId;
|
|
70
|
+
const all = await client.conversations(runId);
|
|
71
|
+
const chosen = conversationsOf(all, agent);
|
|
72
|
+
if (agent !== undefined && chosen.length === 0) {
|
|
73
|
+
const names = [...new Set(all.map((c) => c.slot ?? c.agent))];
|
|
74
|
+
activity(
|
|
75
|
+
io,
|
|
76
|
+
`run ${runId} keeps no conversation of "${agent}"` +
|
|
77
|
+
(names.length ? ` — its Agents: ${names.join(", ")}` : " — it keeps no conversation yet"),
|
|
78
|
+
);
|
|
79
|
+
if (!values.follow) return 1;
|
|
80
|
+
}
|
|
81
|
+
for (const c of chosen) {
|
|
82
|
+
if (c.swept) activity(io, `${c.slot ?? c.agent} [${c.instanceId}]: ${c.swept}`);
|
|
83
|
+
}
|
|
28
84
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
85
|
+
// What is kept now, every chosen conversation interleaved by time.
|
|
86
|
+
const last = new Map<string, number>();
|
|
87
|
+
const kept = await Promise.all(
|
|
88
|
+
chosen.map(async (c) => ({ c, entries: await entriesAfter(client, runId, c.instanceId, -1) })),
|
|
89
|
+
);
|
|
90
|
+
const merged = kept.flatMap(({ c, entries }) => entries.map((entry) => ({ iid: c.instanceId, entry })));
|
|
91
|
+
merged.sort((a, b) => a.entry.at.localeCompare(b.entry.at) || a.entry.seq - b.entry.seq);
|
|
92
|
+
for (const { iid, entry } of merged) io.stdout(printedLines(iid, entry));
|
|
93
|
+
for (const { c, entries } of kept) last.set(c.instanceId, entries.at(-1)?.seq ?? -1);
|
|
94
|
+
if (!values.follow) return 0;
|
|
95
|
+
|
|
96
|
+
// Follow: each conversation from its last printed entry, and each new one from its first.
|
|
97
|
+
const following = new Map<string, Promise<void>>();
|
|
98
|
+
const finished = new Set<string>(); // conversations followed to the end of their feed
|
|
99
|
+
const follow = (c: KeptConversationView) => {
|
|
100
|
+
if (following.has(c.instanceId)) return;
|
|
101
|
+
const after = last.get(c.instanceId) ?? -1;
|
|
102
|
+
following.set(
|
|
103
|
+
c.instanceId,
|
|
104
|
+
(async () => {
|
|
105
|
+
for await (const entry of client.followConversation(runId, c.instanceId, after)) {
|
|
106
|
+
last.set(c.instanceId, entry.seq);
|
|
107
|
+
io.stdout(printedLines(c.instanceId, entry));
|
|
108
|
+
}
|
|
109
|
+
})()
|
|
110
|
+
.catch((err: unknown) => activity(io, `${c.slot ?? c.agent} [${c.instanceId}]: ${(err as Error).message}`))
|
|
111
|
+
.finally(() => {
|
|
112
|
+
following.delete(c.instanceId);
|
|
113
|
+
finished.add(c.instanceId);
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
const pollMs = opts.pollMs ?? LOGS_POLL_MS;
|
|
118
|
+
for (;;) {
|
|
119
|
+
// An open conversation is followed; one that is not open is followed once, to read its end.
|
|
120
|
+
for (const c of conversationsOf(await client.conversations(runId), agent)) {
|
|
121
|
+
if (c.open || (!finished.has(c.instanceId) && !c.cut && !c.swept)) follow(c);
|
|
45
122
|
}
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
123
|
+
const status = (await client.read(runId))?.status;
|
|
124
|
+
if (status !== "active" && following.size === 0) return 0;
|
|
125
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
49
126
|
}
|
|
50
|
-
return 0;
|
|
51
127
|
} finally {
|
|
52
128
|
target.close?.();
|
|
53
129
|
}
|
package/src/commands/run.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `jr2 run <workflow> [--input <json>] [--detach]` (ADR-0009). BLOCKING + attach-by-default, mirroring
|
|
1
|
+
// `jr2 run <workflow> [--input <json>] [--detach] [--drop-held]` (ADR-0009). BLOCKING + attach-by-default, mirroring
|
|
2
2
|
// `flue run`: start the run, then attach to its SSE feed — status deltas + author emits go to stderr
|
|
3
3
|
// as they happen; the terminal RunStatus is printed as JSON on stdout and we exit. So `jr2 run ping`
|
|
4
4
|
// shows progress to a human while `jr2 run ping | jq` yields just the result.
|
|
@@ -12,13 +12,23 @@
|
|
|
12
12
|
// name riding the path as one encoded segment. A Chart the door refuses (one-shot, in `--input`)
|
|
13
13
|
// is a 422 whose report lines this prints, one per rule the Chart fails.
|
|
14
14
|
//
|
|
15
|
+
// A standing Workflow (ADR-0017): jr2 starts its run, so `jr2 run` of it only resumes a paused
|
|
16
|
+
// one, with its held items (none with `--drop-held`). It prints the new run and returns, because a
|
|
17
|
+
// standing run never ends by itself. The door refuses it with 409 while the Workflow is not paused.
|
|
18
|
+
//
|
|
15
19
|
// The exit code (ADR-0073): 0 only for a run that ended `done`. 1 for every other end — `failed`,
|
|
16
20
|
// `error`, `cancelled`, `lost`, `drifted` — and for a run that was parked before it settled. 2 is a
|
|
17
21
|
// usage error, as for every verb. The status line on stderr says which end it was, and a failed
|
|
18
22
|
// run's reason follows it.
|
|
19
23
|
|
|
20
|
-
import type {
|
|
21
|
-
|
|
24
|
+
import type {
|
|
25
|
+
ChildErrorMarker,
|
|
26
|
+
GateMarker,
|
|
27
|
+
HookRefusedMarker,
|
|
28
|
+
PlacingMarker,
|
|
29
|
+
PoolMarker,
|
|
30
|
+
} from "@jr2/orchestrator/internal";
|
|
31
|
+
import { JR2Client, JR2HttpError, type RunFeedEvent } from "../client.ts";
|
|
22
32
|
import { failureActivity } from "./status.ts";
|
|
23
33
|
import { resolveTarget, targetOptions } from "../instance.ts";
|
|
24
34
|
import { activity, result, type Io } from "../output.ts";
|
|
@@ -35,16 +45,25 @@ export async function run(args: string[], io: Io): Promise<number> {
|
|
|
35
45
|
const input = values.input ? (JSON.parse(String(values.input)) as Record<string, unknown>) : {};
|
|
36
46
|
const target = await resolveTarget(io, targetOptions(values));
|
|
37
47
|
try {
|
|
38
|
-
const client = new JR2Client(target.url, io.fetch, target.token);
|
|
48
|
+
const client = new JR2Client(target.url, io.fetch, target.token, target.onBehalfOf);
|
|
39
49
|
|
|
40
50
|
let runId: string;
|
|
51
|
+
let resumed = false;
|
|
41
52
|
try {
|
|
42
|
-
({ runId } = await client.start(workflow, input
|
|
53
|
+
({ runId, resumed = false } = (await client.start(workflow, input, {
|
|
54
|
+
dropHeld: values["drop-held"] === true,
|
|
55
|
+
})) as { runId: string; resumed?: boolean });
|
|
43
56
|
} catch (err) {
|
|
44
57
|
if (!(err instanceof JR2HttpError) || !err.report) throw err;
|
|
45
58
|
activity(io, refusalActivity(workflow, err.report));
|
|
46
59
|
return 1;
|
|
47
60
|
}
|
|
61
|
+
// A standing run never ends by itself (ADR-0017): the resume prints it and returns.
|
|
62
|
+
if (resumed) {
|
|
63
|
+
activity(io, `resumed the standing workflow ${workflow}: run ${runId}`);
|
|
64
|
+
result(io, { runId, resumed: true });
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
48
67
|
if (values.detach) {
|
|
49
68
|
result(io, { runId });
|
|
50
69
|
return 0;
|
|
@@ -52,24 +71,8 @@ export async function run(args: string[], io: Io): Promise<number> {
|
|
|
52
71
|
|
|
53
72
|
activity(io, `run ${runId} (${workflow}) — attached`);
|
|
54
73
|
for await (const ev of client.events(runId)) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
if (ev.kind === "retry") {
|
|
60
|
-
activity(io, ` retry ${ev.child} attempt ${ev.attempt} (${ev.reason})`);
|
|
61
|
-
continue;
|
|
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
|
-
}
|
|
71
|
-
activity(io, ` → ${ev.status.status} ${JSON.stringify(ev.status.value)}`);
|
|
72
|
-
if (ev.status.status !== "active") {
|
|
74
|
+
activity(io, ` ${attachedLine(ev)}`);
|
|
75
|
+
if (ev.kind === "status" && ev.status.status !== "active") {
|
|
73
76
|
const failed = failureActivity(ev.status);
|
|
74
77
|
if (failed) activity(io, failed);
|
|
75
78
|
result(io, ev.status);
|
|
@@ -79,16 +82,40 @@ export async function run(args: string[], io: Io): Promise<number> {
|
|
|
79
82
|
// `events()` re-attaches until the run settles (ADR-0022), so reaching here means the run left
|
|
80
83
|
// the live set without settling: stopped, parked for the next boot's restore (ADR-0025). Never
|
|
81
84
|
// 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
|
-
);
|
|
85
|
+
activity(io, `run ${runId} is parked: stopped before it settled — \`jr2 watch ${runId}\` follows it once restored`);
|
|
86
86
|
return 1;
|
|
87
87
|
} finally {
|
|
88
88
|
target.close?.();
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* One event of a run's feed as the line `jr2 run` prints while it is attached, and `jr2 watch`
|
|
94
|
+
* prints (ADR-0009): an Emit, a retry, a wait for capacity, a child's error, a Gate's look, a
|
|
95
|
+
* Hook's refused delivery, a Pool's mark, or where the run stands.
|
|
96
|
+
*/
|
|
97
|
+
export function attachedLine(ev: RunFeedEvent): string {
|
|
98
|
+
switch (ev.kind) {
|
|
99
|
+
case "emit":
|
|
100
|
+
return `emit ${JSON.stringify(ev.event)}`;
|
|
101
|
+
case "retry":
|
|
102
|
+
return `retry ${ev.child} attempt ${ev.attempt} (${ev.reason})`;
|
|
103
|
+
case "placing":
|
|
104
|
+
case "placed":
|
|
105
|
+
return placingActivity(ev);
|
|
106
|
+
case "child-error":
|
|
107
|
+
return childErrorActivity(ev);
|
|
108
|
+
case "looked":
|
|
109
|
+
return lookedActivity(ev);
|
|
110
|
+
case "hook-refused":
|
|
111
|
+
return hookRefusedActivity(ev);
|
|
112
|
+
case "pool":
|
|
113
|
+
return poolActivity(ev);
|
|
114
|
+
case "status":
|
|
115
|
+
return `→ ${ev.status.status} ${JSON.stringify(ev.status.value)}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
92
119
|
/**
|
|
93
120
|
* The door's refusal of a Chart (ADR-0070) as activity: the Workflow, then each report line, which
|
|
94
121
|
* names the path in the Chart and the rule it fails.
|
|
@@ -108,6 +135,29 @@ export function placingActivity(ev: PlacingMarker): string {
|
|
|
108
135
|
return `placing${who}: waits for ${ev.on === "quota" ? "quota" : "a node"} — ${ev.message}`;
|
|
109
136
|
}
|
|
110
137
|
|
|
138
|
+
/** A Gate's look as one activity line (ADR-0011): the look read its fact, and the run took its event. */
|
|
139
|
+
export function lookedActivity(ev: GateMarker): string {
|
|
140
|
+
return `looked: gate ${ev.gate} read its fact, and the run took "${ev.event}"`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A Hook's delivery that a Gate did not take, as one activity line (ADR-0011): the run goes on. */
|
|
144
|
+
export function hookRefusedActivity(ev: HookRefusedMarker): string {
|
|
145
|
+
return (
|
|
146
|
+
`hook-refused: hook ${ev.hook} delivery ${ev.delivery} at gate ${ev.gate}` +
|
|
147
|
+
`${ev.event !== undefined ? ` ("${ev.event}")` : ""} — ${ev.reason}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What a Pool of runs did with one item, as one activity line (ADR-0017). */
|
|
152
|
+
export function poolActivity(ev: PoolMarker): string {
|
|
153
|
+
const run = ev.run !== undefined ? ` run ${ev.run}` : "";
|
|
154
|
+
if (ev.mark === "started")
|
|
155
|
+
return `pool ${ev.pool}: started ${ev.key}${run}${ev.existing ? " (the key found it)" : ""}`;
|
|
156
|
+
if (ev.mark === "skipped") return `pool ${ev.pool}: skipped an item — ${ev.reason ?? ""}`;
|
|
157
|
+
if (ev.mark === "carry-refused") return `pool ${ev.pool}: carry-refused ${ev.key} — ${ev.reason ?? ""}`;
|
|
158
|
+
return `pool ${ev.pool}: settled ${ev.key}${run} ${ev.status ?? ""}${ev.reason !== undefined ? ` — ${ev.reason}` : ""}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
111
161
|
/**
|
|
112
162
|
* A child's error below an Agent's state as one activity line (ADR-0024): the pick's answer carries
|
|
113
163
|
* no reply for it, so the Agent is not told, and this line is where the operator reads it.
|