@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.
- 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 +227 -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 +138 -7
- package/src/commands/steer.ts +47 -0
- package/src/commands/up.ts +440 -83
- 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 +409 -42
- package/src/instance-check.ts +57 -7
- package/src/instance.ts +16 -5
- package/src/kube.ts +37 -3
- package/src/landlock.ts +129 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jr2/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The jr2 CLI: init, up, run — the interface to a jr2 Instance.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -39,14 +39,14 @@
|
|
|
39
39
|
"ts-blank-space": "^0.9.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@jr2/orchestrator": "0.
|
|
42
|
+
"@jr2/orchestrator": "0.7.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@anthropic-ai/sdk": "0.91.1",
|
|
46
46
|
"@types/node": "^26.0.1",
|
|
47
47
|
"xstate": "^5.18.0",
|
|
48
48
|
"zod": "^4.4.3",
|
|
49
|
-
"@jr2/orchestrator": "0.
|
|
49
|
+
"@jr2/orchestrator": "0.7.0"
|
|
50
50
|
},
|
|
51
51
|
"files": [
|
|
52
52
|
"bin",
|
package/src/build.ts
CHANGED
|
@@ -375,10 +375,10 @@ export const KIT_IMAGES: Record<KitImageName, KitImage> = {
|
|
|
375
375
|
dockerfile: "deploy/orchestrator/Dockerfile",
|
|
376
376
|
context: ".",
|
|
377
377
|
// `@jr2/orchestrator` and every kit package it depends on, which the Dockerfile packs into the
|
|
378
|
-
// image (ADR-0069). `packages/agent-protocol` is a runtime dependency, so
|
|
379
|
-
// the image and must move its tag. The third-party closure (xstate, zod,
|
|
380
|
-
// the registry, each package forced to the version
|
|
381
|
-
// the tag addresses it through that file. closure.json equals pnpm-lock.yaml's closure, and
|
|
378
|
+
// image (ADR-0069). `packages/agent-protocol` is a runtime dependency, so
|
|
379
|
+
// an edit there changes the image and must move its tag. The third-party closure (xstate, zod,
|
|
380
|
+
// hono, …) comes from the registry, each package forced to the version
|
|
381
|
+
// `deploy/orchestrator/closure.json` pins, so the tag addresses it through that file. closure.json equals pnpm-lock.yaml's closure, and
|
|
382
382
|
// this list equals the Orchestrator's `workspace:` dependencies, both asserted in
|
|
383
383
|
// test/orchestrator-image.test.ts.
|
|
384
384
|
sources: ["packages/orchestrator", "packages/agent-protocol", "deploy/orchestrator"],
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The named Caller tokens on the cluster (ADR-0078): what `jr2 up` and `jr2 caller rotate` share.
|
|
2
|
+
//
|
|
3
|
+
// Each name that `callers.tokens` declares has a Secret of its own, `jr2-caller-<name>`, which holds
|
|
4
|
+
// its value (`jr2c_` and 32 random bytes in base64url) and which no pod mounts. The Orchestrator
|
|
5
|
+
// gets only the digests, in `jr2-caller-digests`, which it mounts and reads again when it changes.
|
|
6
|
+
// So the value stays where kube RBAC guards it, as the Instance token does, and a new token or a
|
|
7
|
+
// rotation rolls no pod.
|
|
8
|
+
//
|
|
9
|
+
// `jr2 up` makes a value once, for a name that has none, and keeps it after that. It deletes the
|
|
10
|
+
// Secret of a name that the config no longer declares, so that value stops working. `jr2 caller
|
|
11
|
+
// rotate` makes a new value for one name and writes the digests again from every token Secret.
|
|
12
|
+
|
|
13
|
+
import { CALLER_TOKEN_PREFIX, mintCallerToken } from "@jr2/orchestrator/internal";
|
|
14
|
+
import { CALLER_TOKEN_KEY, callerTokenObjects, LABEL_CALLER_TOKEN, LABEL_INSTANCE } from "./deploy.ts";
|
|
15
|
+
import type { KubeAdmin, KubeObject } from "./kube.ts";
|
|
16
|
+
import { mountedDigests, type AppliedObject } from "./mount-wait.ts";
|
|
17
|
+
|
|
18
|
+
type Ctx = { context?: string };
|
|
19
|
+
type SecretObject = KubeObject & { data?: Record<string, string> };
|
|
20
|
+
|
|
21
|
+
/** The values of the token Secrets in the namespace, by the name on their label. A Secret with no
|
|
22
|
+
* value, or a value that is not a Caller token's, is left out. */
|
|
23
|
+
export async function storedCallerTokens(kube: KubeAdmin, namespace: string, ctx: Ctx): Promise<Map<string, string>> {
|
|
24
|
+
const found = new Map<string, string>();
|
|
25
|
+
for (const secret of await kube.listJson<SecretObject>({
|
|
26
|
+
kind: "secret",
|
|
27
|
+
selector: LABEL_CALLER_TOKEN,
|
|
28
|
+
namespace,
|
|
29
|
+
...ctx,
|
|
30
|
+
})) {
|
|
31
|
+
const name = secret.metadata.labels?.[LABEL_CALLER_TOKEN];
|
|
32
|
+
const b64 = secret.data?.[CALLER_TOKEN_KEY];
|
|
33
|
+
if (!name || !b64) continue;
|
|
34
|
+
const value = Buffer.from(b64, "base64").toString("utf8");
|
|
35
|
+
if (value.startsWith(CALLER_TOKEN_PREFIX)) found.set(name, value);
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What `jr2 up` does with the named Caller tokens: the value of each declared name, the names it
|
|
41
|
+
* made a value for now, and the names whose Secret it deletes. */
|
|
42
|
+
export type CallerTokenPlan = {
|
|
43
|
+
values: Record<string, string>;
|
|
44
|
+
made: string[];
|
|
45
|
+
undeclared: string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Plan the named Caller tokens for one converge (ADR-0078): keep the stored value of each declared
|
|
50
|
+
* name, make one for a declared name that has none, and name each stored name that the config no
|
|
51
|
+
* longer declares.
|
|
52
|
+
*/
|
|
53
|
+
export async function planCallerTokens(
|
|
54
|
+
kube: KubeAdmin,
|
|
55
|
+
declared: readonly string[],
|
|
56
|
+
namespace: string,
|
|
57
|
+
ctx: Ctx,
|
|
58
|
+
): Promise<CallerTokenPlan> {
|
|
59
|
+
const stored = await storedCallerTokens(kube, namespace, ctx);
|
|
60
|
+
const values: Record<string, string> = {};
|
|
61
|
+
const made: string[] = [];
|
|
62
|
+
for (const name of [...declared].sort()) {
|
|
63
|
+
const kept = stored.get(name);
|
|
64
|
+
if (kept !== undefined) values[name] = kept;
|
|
65
|
+
else {
|
|
66
|
+
values[name] = mintCallerToken();
|
|
67
|
+
made.push(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const undeclared = [...stored.keys()].filter((n) => !declared.includes(n)).sort();
|
|
71
|
+
return { values, made, undeclared };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Make a new value for one named Caller token, and write the digests again (ADR-0078). The value
|
|
76
|
+
* goes first: when the digests write fails, `jr2 caller rotate` runs again and the token still has
|
|
77
|
+
* a value that `jr2 caller token` prints. Refuses a name that has no token Secret: `jr2 up` makes
|
|
78
|
+
* it, from the config. Returns the digest of the digests Secret it applied, by name
|
|
79
|
+
* (`mountedDigests`): `jr2 caller rotate` waits until the Orchestrator has loaded it.
|
|
80
|
+
*/
|
|
81
|
+
export async function rotateCallerToken(
|
|
82
|
+
kube: KubeAdmin,
|
|
83
|
+
opts: { instance: string; namespace: string; name: string; ctx: Ctx },
|
|
84
|
+
): Promise<Record<string, string>> {
|
|
85
|
+
const stored = await storedCallerTokens(kube, opts.namespace, opts.ctx);
|
|
86
|
+
if (!stored.has(opts.name)) {
|
|
87
|
+
const known = [...stored.keys()].sort();
|
|
88
|
+
throw new Error(
|
|
89
|
+
`no Caller token "${opts.name}" in namespace "${opts.namespace}" — declare it in callers.tokens of ` +
|
|
90
|
+
`jr2.config.ts and run \`jr2 up\`${known.length ? ` (tokens here: ${known.join(", ")})` : ""}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
stored.set(opts.name, mintCallerToken());
|
|
94
|
+
const labels = { [LABEL_INSTANCE]: opts.instance, "app.kubernetes.io/managed-by": "jr2" };
|
|
95
|
+
const meta = (name: string, extra: Record<string, string> = {}) => ({
|
|
96
|
+
name,
|
|
97
|
+
namespace: opts.namespace,
|
|
98
|
+
labels: { ...labels, ...extra },
|
|
99
|
+
});
|
|
100
|
+
const [valueSecret, digests] = callerTokenObjects(Object.fromEntries(stored), meta).filter(
|
|
101
|
+
(o) =>
|
|
102
|
+
(o.metadata as { labels: Record<string, string> }).labels[LABEL_CALLER_TOKEN] === opts.name ||
|
|
103
|
+
(o.metadata as { labels: Record<string, string> }).labels[LABEL_CALLER_TOKEN] === undefined,
|
|
104
|
+
);
|
|
105
|
+
await kube.apply({ manifest: JSON.stringify(valueSecret), ...opts.ctx });
|
|
106
|
+
await kube.apply({ manifest: JSON.stringify(digests), ...opts.ctx });
|
|
107
|
+
return mountedDigests([digests as AppliedObject]);
|
|
108
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -11,15 +11,24 @@ import { loadDotenv } from "./env.ts";
|
|
|
11
11
|
import { init } from "./commands/init.ts";
|
|
12
12
|
import { run } from "./commands/run.ts";
|
|
13
13
|
import { runs } from "./commands/runs.ts";
|
|
14
|
+
import { workflows } from "./commands/workflows.ts";
|
|
15
|
+
import { gates } from "./commands/gates.ts";
|
|
14
16
|
import { status } from "./commands/status.ts";
|
|
15
17
|
import { logs } from "./commands/logs.ts";
|
|
18
|
+
import { watch } from "./commands/watch.ts";
|
|
19
|
+
import { workspace } from "./commands/workspace.ts";
|
|
20
|
+
import { completion } from "./commands/completion.ts";
|
|
16
21
|
import { send } from "./commands/send.ts";
|
|
22
|
+
import { steer } from "./commands/steer.ts";
|
|
17
23
|
import { up } from "./commands/up.ts";
|
|
18
24
|
import { check } from "./commands/check.ts";
|
|
19
25
|
import { down } from "./commands/down.ts";
|
|
20
26
|
import { gc } from "./commands/gc.ts";
|
|
21
27
|
import { kit } from "./commands/kit.ts";
|
|
22
28
|
import { version } from "./commands/version.ts";
|
|
29
|
+
import { whoami } from "./commands/whoami.ts";
|
|
30
|
+
import { hooks } from "./commands/hooks.ts";
|
|
31
|
+
import { caller } from "./commands/caller.ts";
|
|
23
32
|
import { asksForHelp, commandsFor, GLOBAL, renderCommand, renderHelp } from "./command-table.ts";
|
|
24
33
|
|
|
25
34
|
/**
|
|
@@ -48,8 +57,12 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
|
|
|
48
57
|
// `jr2.config.ts` (whose deployment-varying values are read from there — ADR-0019).
|
|
49
58
|
loadDotenv(io);
|
|
50
59
|
const entries = cmd === undefined ? [] : commandsFor(cmd);
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
// `completion __complete` carries the line being completed, which can hold `-h` itself.
|
|
61
|
+
const completing = cmd === "completion" && rest[0] === "__complete";
|
|
62
|
+
if (entries.length && !completing && asksForHelp(entries, rest)) {
|
|
63
|
+
// `jr2 caller rotate --help` prints that sub-verb's entry; `jr2 caller --help` prints each.
|
|
64
|
+
const named = entries.filter((c) => c.sub !== undefined && c.sub === rest[0]);
|
|
65
|
+
activity(io, (named.length ? named : entries).map(renderCommand).join("\n\n"));
|
|
53
66
|
return 0;
|
|
54
67
|
}
|
|
55
68
|
if (cmd !== undefined && (GLOBAL.help as readonly string[]).includes(cmd)) {
|
|
@@ -63,12 +76,20 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
|
|
|
63
76
|
return await run(rest, io);
|
|
64
77
|
case "runs":
|
|
65
78
|
return await runs(rest, io);
|
|
79
|
+
case "workflows":
|
|
80
|
+
return await workflows(rest, io);
|
|
81
|
+
case "gates":
|
|
82
|
+
return await gates(rest, io);
|
|
66
83
|
case "status":
|
|
67
84
|
return await status(rest, io);
|
|
85
|
+
case "watch":
|
|
86
|
+
return await watch(rest, io);
|
|
68
87
|
case "logs":
|
|
69
88
|
return await logs(rest, io);
|
|
70
89
|
case "send":
|
|
71
90
|
return await send(rest, io);
|
|
91
|
+
case "steer":
|
|
92
|
+
return await steer(rest, io);
|
|
72
93
|
case "check":
|
|
73
94
|
return await check(rest, io);
|
|
74
95
|
case "up":
|
|
@@ -79,6 +100,16 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
|
|
|
79
100
|
return await gc(rest, io);
|
|
80
101
|
case "kit":
|
|
81
102
|
return await kit(rest, io);
|
|
103
|
+
case "whoami":
|
|
104
|
+
return await whoami(rest, io);
|
|
105
|
+
case "hooks":
|
|
106
|
+
return await hooks(rest, io);
|
|
107
|
+
case "caller":
|
|
108
|
+
return await caller(rest, io);
|
|
109
|
+
case "workspace":
|
|
110
|
+
return await workspace(rest, io);
|
|
111
|
+
case "completion":
|
|
112
|
+
return await completion(rest, io);
|
|
82
113
|
case "version":
|
|
83
114
|
case "--version":
|
|
84
115
|
return await version(rest, io);
|
package/src/client.ts
CHANGED
|
@@ -6,23 +6,42 @@
|
|
|
6
6
|
// `fetchImpl` is injectable so tests drive it with a hono `app.request` (no socket) the same way the
|
|
7
7
|
// orchestrator's own http tests do; in production it defaults to the global `fetch`.
|
|
8
8
|
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
import {
|
|
10
|
+
ON_BEHALF_OF_HEADER,
|
|
11
|
+
type CallerRecord,
|
|
12
|
+
type ChildErrorMarker,
|
|
13
|
+
type GateMarker,
|
|
14
|
+
type HookLogLine,
|
|
15
|
+
type HookRefusedMarker,
|
|
16
|
+
type PoolMarker,
|
|
17
|
+
type DispatchedBy,
|
|
18
|
+
type GateInboxEntry,
|
|
19
|
+
type MachineDoc,
|
|
20
|
+
type PlacingMarker,
|
|
21
|
+
type RepoStatus,
|
|
22
|
+
type RunChild,
|
|
23
|
+
type RunPage,
|
|
24
|
+
type RunSummary,
|
|
25
|
+
type RunStarter,
|
|
26
|
+
type RunWaiting,
|
|
27
|
+
type Scopes,
|
|
28
|
+
type ShownStatus,
|
|
17
29
|
} from "@jr2/orchestrator/internal";
|
|
18
30
|
import { parseSSE } from "./sse.ts";
|
|
19
31
|
|
|
32
|
+
/** The query of `GET /runs` (ADR-0009): `status` is `live` (the default), `ended`, `all`, or a comma
|
|
33
|
+
* list; `since` a duration back from now or an ISO-8601 time; `cursor` a page's `next`. */
|
|
34
|
+
export type RunsQuery = { status?: string; workflow?: string; since?: string; cursor?: string; limit?: number };
|
|
35
|
+
|
|
36
|
+
export type { RunPage, RunSummary };
|
|
37
|
+
|
|
20
38
|
/** A run's current observable state — mirrors the orchestrator's `RunStatus` (run-host.ts). */
|
|
21
39
|
export type RunStatus = {
|
|
22
40
|
runId: string;
|
|
23
41
|
workflow: string;
|
|
24
42
|
instanceId: string;
|
|
25
|
-
|
|
43
|
+
/** `active` while the run is live, else how it ended (ADR-0025): never xstate's `stopped`. */
|
|
44
|
+
status: ShownStatus;
|
|
26
45
|
value: unknown;
|
|
27
46
|
context: unknown;
|
|
28
47
|
/** The live child machines beneath the root, context-free — where most of a run is. */
|
|
@@ -53,8 +72,24 @@ export type RunStatus = {
|
|
|
53
72
|
/** The runs this run's `dispatch` Effects started, oldest first: the slot, the run, and when its
|
|
54
73
|
* ledger row was written (ADR-0071). Absent when it started none. */
|
|
55
74
|
dispatched?: Array<{ slot: string; run: string; at: string }>;
|
|
75
|
+
/** The Worktrees this run's cuts made, oldest first: the Repo Slot, the path, and the branch,
|
|
76
|
+
* `null` when detached (ADR-0075). Absent when it cut none. */
|
|
77
|
+
worktrees?: Array<{ slot: string; path: string; branch: string | null }>;
|
|
78
|
+
/** Who started the run (ADR-0078): a Caller, a run through a local `dispatch`, or jr2. Behind the
|
|
79
|
+
* token; absent for a run that a host started with no starter. */
|
|
80
|
+
startedBy?: RunStarter;
|
|
81
|
+
/** The Caller that cancelled the run (ADR-0078). */
|
|
82
|
+
cancelledBy?: CallerRecord;
|
|
83
|
+
/** The Caller whose `jr2 run` resumed this standing run (ADR-0017, ADR-0078). */
|
|
84
|
+
resumedBy?: CallerRecord;
|
|
85
|
+
/** The run that took over from this standing run at a hand-over (ADR-0017). */
|
|
86
|
+
handedOverTo?: string;
|
|
56
87
|
};
|
|
57
88
|
|
|
89
|
+
/** A request's Caller, as `GET /whoami` gives it (ADR-0078): how jr2 knows it, its name, the name
|
|
90
|
+
* jr2 shows, what it may do, and the party it says it acts for. */
|
|
91
|
+
export type WhoAmI = CallerRecord & { may: Scopes };
|
|
92
|
+
|
|
58
93
|
/** Where a Workflow came from (ADR-0070): a Machine in the Assembly, a Chart file in the Assembly, or
|
|
59
94
|
* a runtime Chart put by `PUT /workflows/:wrapper/charts/:name`. */
|
|
60
95
|
export type WorkflowOrigin = "machine" | "file" | "runtime";
|
|
@@ -80,12 +115,37 @@ export type RunFeedEvent =
|
|
|
80
115
|
| { kind: "emit"; event: { type: string } & Record<string, unknown> }
|
|
81
116
|
| { kind: "retry"; child: string; attempt: number; reason: string }
|
|
82
117
|
| PlacingMarker
|
|
83
|
-
| ChildErrorMarker
|
|
118
|
+
| ChildErrorMarker
|
|
119
|
+
| GateMarker
|
|
120
|
+
| HookRefusedMarker
|
|
121
|
+
| PoolMarker;
|
|
122
|
+
|
|
123
|
+
/** A Hook's log, as `GET /hooks/:name` gives it (ADR-0011): whether the current config declares the
|
|
124
|
+
* Hook, and its lines, newest first. */
|
|
125
|
+
export type HookLog = { hook: string; declared: boolean; lines: HookLogLine[] };
|
|
84
126
|
|
|
85
127
|
/** A run-control event posted to a live run (ADR-0013): CANCEL is the vocabulary that is left.
|
|
86
128
|
* `keep` leaves a cancelled run's Workspaces instead of destroying them (ADR-0025). */
|
|
87
129
|
export type RunEvent = { type: string; keep?: boolean };
|
|
88
130
|
|
|
131
|
+
/** One kept conversation, as `GET /runs/:runId/conversations` lists it (ADR-0023). `slot` is the
|
|
132
|
+
* Agent slot from the run's admission ledger. */
|
|
133
|
+
export type KeptConversationView = {
|
|
134
|
+
agent: string;
|
|
135
|
+
slot?: string;
|
|
136
|
+
instanceId: string;
|
|
137
|
+
open: boolean;
|
|
138
|
+
bytes: number;
|
|
139
|
+
cut: boolean;
|
|
140
|
+
swept?: string;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** One entry of a kept conversation (ADR-0023): one body of the print, its label as `kind`. */
|
|
144
|
+
export type ConversationEntryView = { id: string; seq: number; at: string; agent: string; kind: string; text: string };
|
|
145
|
+
|
|
146
|
+
/** One Agent slot of a live run, as `GET /runs/:runId/agents` lists it (ADR-0002). */
|
|
147
|
+
export type RunAgentView = { agent: string; open: boolean; steerable: boolean };
|
|
148
|
+
|
|
89
149
|
/** The subset of `fetch` the client uses. `globalThis.fetch` and hono's `app.request` both satisfy it. */
|
|
90
150
|
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
91
151
|
|
|
@@ -136,19 +196,44 @@ export function workflowPath(workflow: string): string {
|
|
|
136
196
|
export class JR2Client {
|
|
137
197
|
readonly baseUrl: string;
|
|
138
198
|
private readonly fetchImpl: FetchLike;
|
|
139
|
-
/** The
|
|
140
|
-
* call below is a 401. Absent only for an
|
|
199
|
+
/** The Caller's credential (ADR-0078): the Instance token, or any Caller's. The run and gate
|
|
200
|
+
* surfaces are authenticated; without it every call below is a 401. Absent only for an
|
|
201
|
+
* unauthenticated surface (a test's in-process app). */
|
|
141
202
|
private readonly token?: string;
|
|
203
|
+
/** The party the Caller says it acts for, sent as `jr2-on-behalf-of` with the bearer (ADR-0078). */
|
|
204
|
+
private readonly onBehalfOf?: string;
|
|
142
205
|
|
|
143
|
-
constructor(baseUrl: string, fetchImpl: FetchLike = globalThis.fetch, token?: string) {
|
|
206
|
+
constructor(baseUrl: string, fetchImpl: FetchLike = globalThis.fetch, token?: string, onBehalfOf?: string) {
|
|
144
207
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
145
208
|
this.fetchImpl = fetchImpl;
|
|
146
209
|
this.token = token;
|
|
210
|
+
this.onBehalfOf = onBehalfOf;
|
|
147
211
|
}
|
|
148
212
|
|
|
149
|
-
/** Request headers: the bearer, plus whatever the call adds. */
|
|
213
|
+
/** Request headers: the bearer and the party it acts for, plus whatever the call adds. */
|
|
150
214
|
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
151
|
-
|
|
215
|
+
if (!this.token) return extra;
|
|
216
|
+
return {
|
|
217
|
+
...extra,
|
|
218
|
+
authorization: `Bearer ${this.token}`,
|
|
219
|
+
...(this.onBehalfOf ? { [ON_BEHALF_OF_HEADER]: this.onBehalfOf } : {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `GET /whoami` — the Caller this credential is (ADR-0078). It needs no scope, so a credential
|
|
224
|
+
* that may do nothing still learns why. */
|
|
225
|
+
async whoami(): Promise<WhoAmI> {
|
|
226
|
+
const res = await this.fetchImpl(`${this.baseUrl}/whoami`, { headers: this.headers() });
|
|
227
|
+
return (await this.json(res, "/whoami")) as WhoAmI;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** `GET /hooks/:name` — a Hook's log, newest first (ADR-0011). The Instance token alone. */
|
|
231
|
+
async hookLog(name: string, limit?: number): Promise<HookLog> {
|
|
232
|
+
const query = limit === undefined ? "" : `?limit=${limit}`;
|
|
233
|
+
const res = await this.fetchImpl(`${this.baseUrl}/hooks/${encodeURIComponent(name)}${query}`, {
|
|
234
|
+
headers: this.headers(),
|
|
235
|
+
});
|
|
236
|
+
return (await this.json(res, "/hooks/:name")) as HookLog;
|
|
152
237
|
}
|
|
153
238
|
|
|
154
239
|
/** `GET /workflows` — every registered Workflow with its origin, each Chart wrapper's Charts inside
|
|
@@ -183,19 +268,57 @@ export class JR2Client {
|
|
|
183
268
|
/** `POST /workflows/:name/runs` — start a run; unknown workflow → 404 → throws. A named Chart's
|
|
184
269
|
* name `<wrapper>/<name>` rides the path as one encoded segment (ADR-0070); a refused Chart in
|
|
185
270
|
* the input → 422 → throws with the door's `report`. */
|
|
186
|
-
async start(
|
|
187
|
-
|
|
271
|
+
async start(
|
|
272
|
+
workflow: string,
|
|
273
|
+
input: Record<string, unknown> = {},
|
|
274
|
+
opts: { dropHeld?: boolean } = {},
|
|
275
|
+
): Promise<{ runId: string; instanceId: string; resumed?: true }> {
|
|
276
|
+
// `drop-held` resumes a paused standing Workflow with no held item (ADR-0017).
|
|
277
|
+
const query = opts.dropHeld ? "?drop-held=true" : "";
|
|
278
|
+
const res = await this.fetchImpl(`${this.baseUrl}${workflowPath(workflow)}/runs${query}`, {
|
|
188
279
|
method: "POST",
|
|
189
280
|
headers: this.headers(JSON_HEADERS),
|
|
190
281
|
body: JSON.stringify(input),
|
|
191
282
|
});
|
|
192
|
-
return (await this.json(res, "/workflows/:name/runs")) as { runId: string; instanceId: string };
|
|
283
|
+
return (await this.json(res, "/workflows/:name/runs")) as { runId: string; instanceId: string; resumed?: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** `GET /runs` — one page of the run listing (ADR-0009): live runs when `query` names no
|
|
287
|
+
* `status`, newest start first, and the cursor of the next page when there is one. */
|
|
288
|
+
async runs(query: RunsQuery = {}): Promise<RunPage> {
|
|
289
|
+
const params = new URLSearchParams();
|
|
290
|
+
for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, String(value));
|
|
291
|
+
const qs = params.size ? `?${params}` : "";
|
|
292
|
+
const res = await this.fetchImpl(`${this.baseUrl}/runs${qs}`, { headers: this.headers() });
|
|
293
|
+
const page = (await this.json(res, "/runs")) as RunPage;
|
|
294
|
+
if (typeof page !== "object" || page === null || !Array.isArray(page.runs)) {
|
|
295
|
+
throw new Error("GET /runs: the answer is not a page of runs");
|
|
296
|
+
}
|
|
297
|
+
return page;
|
|
193
298
|
}
|
|
194
299
|
|
|
195
|
-
/** `GET /
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
300
|
+
/** `GET /gates` — the Gate inbox (ADR-0011): the open Gates of every live run that this Caller
|
|
301
|
+
* may answer, oldest first, of one Workflow when `workflow` names it. */
|
|
302
|
+
async gates(workflow?: string): Promise<GateInboxEntry[]> {
|
|
303
|
+
const qs = workflow !== undefined ? `?${new URLSearchParams({ workflow })}` : "";
|
|
304
|
+
const res = await this.fetchImpl(`${this.baseUrl}/gates${qs}`, { headers: this.headers() });
|
|
305
|
+
const body = (await this.json(res, "/gates")) as { gates?: unknown };
|
|
306
|
+
if (typeof body !== "object" || body === null || !Array.isArray(body.gates)) {
|
|
307
|
+
throw new Error("GET /gates: the answer is not a list of Gates");
|
|
308
|
+
}
|
|
309
|
+
return body.gates as GateInboxEntry[];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Every run that `query` selects: each page of `GET /runs`, in turn, until a page has no `next`. */
|
|
313
|
+
async allRuns(query: Omit<RunsQuery, "cursor"> = {}): Promise<RunSummary[]> {
|
|
314
|
+
const runs: RunSummary[] = [];
|
|
315
|
+
let cursor: string | undefined;
|
|
316
|
+
do {
|
|
317
|
+
const page = await this.runs({ ...query, ...(cursor !== undefined ? { cursor } : {}) });
|
|
318
|
+
runs.push(...page.runs);
|
|
319
|
+
cursor = page.next;
|
|
320
|
+
} while (cursor !== undefined);
|
|
321
|
+
return runs;
|
|
199
322
|
}
|
|
200
323
|
|
|
201
324
|
/** `GET /repos` — whether the instance has a data plane, and every Repo resource as the cluster
|
|
@@ -238,6 +361,25 @@ export class JR2Client {
|
|
|
238
361
|
await this.json(res, "/runs/:runId/gates/:gate/events"); // surface { error }; ignore { ok:true }
|
|
239
362
|
}
|
|
240
363
|
|
|
364
|
+
/** `GET /runs/:runId/agents` — the Agent slots a Steering message can go to (ADR-0002). */
|
|
365
|
+
async agents(runId: string): Promise<RunAgentView[]> {
|
|
366
|
+
const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/agents`, {
|
|
367
|
+
headers: this.headers(),
|
|
368
|
+
});
|
|
369
|
+
return (await this.json(res, "/runs/:runId/agents")) as RunAgentView[];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** `POST /runs/:runId/steering` — a Steering message to one Agent slot of a live run (ADR-0002).
|
|
373
|
+
* Answers its id, and whether a Turn of the slot is open now. */
|
|
374
|
+
async steer(runId: string, agent: string, text: string): Promise<{ id: string; agent: string; open: boolean }> {
|
|
375
|
+
const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/steering`, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
headers: this.headers(JSON_HEADERS),
|
|
378
|
+
body: JSON.stringify({ agent, text }),
|
|
379
|
+
});
|
|
380
|
+
return (await this.json(res, "/runs/:runId/steering")) as { id: string; agent: string; open: boolean };
|
|
381
|
+
}
|
|
382
|
+
|
|
241
383
|
/** `POST /runs/:runId/events` — feed one run-control event into a live run. */
|
|
242
384
|
async send(runId: string, event: RunEvent): Promise<void> {
|
|
243
385
|
const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/events`, {
|
|
@@ -284,19 +426,81 @@ export class JR2Client {
|
|
|
284
426
|
} else if (frame.event === "child-error") {
|
|
285
427
|
// A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024).
|
|
286
428
|
yield { ...(JSON.parse(frame.data) as object), kind: "child-error" } as ChildErrorMarker;
|
|
429
|
+
} else if (frame.event === "looked") {
|
|
430
|
+
// A Gate's look gave its event, and the run took it (ADR-0011).
|
|
431
|
+
yield { ...(JSON.parse(frame.data) as object), kind: "looked" } as GateMarker;
|
|
432
|
+
} else if (frame.event === "hook-refused") {
|
|
433
|
+
// A Hook's delivery reached an open Gate, and the Gate did not take it (ADR-0011).
|
|
434
|
+
yield { ...(JSON.parse(frame.data) as object), kind: "hook-refused" } as HookRefusedMarker;
|
|
435
|
+
} else if (frame.event === "pool") {
|
|
436
|
+
// What a Pool of runs did with an item (ADR-0017).
|
|
437
|
+
yield { ...(JSON.parse(frame.data) as object), kind: "pool" } as PoolMarker;
|
|
287
438
|
} else if (frame.event === "gone") {
|
|
288
439
|
// The run left the live set without settling — stopped, parked for the next boot's
|
|
289
440
|
// restore (ADR-0025). This end is the run's own (ADR-0022): nothing to re-attach to.
|
|
290
441
|
return;
|
|
291
442
|
}
|
|
292
443
|
// Any other frame (a Turn marker — ADR-0023 — or a kind this CLI predates) is skipped, not
|
|
293
|
-
// misread as a status: `jr2
|
|
444
|
+
// misread as a status: `jr2 watch` decides "settled" off `status.status`, and a marker
|
|
294
445
|
// parsed as a status would end the follow mid-run.
|
|
295
446
|
}
|
|
296
447
|
if (settled) return;
|
|
297
448
|
}
|
|
298
449
|
}
|
|
299
450
|
|
|
451
|
+
/** `GET /runs/:runId/conversations` — the run's kept conversations (ADR-0023), the first first. */
|
|
452
|
+
async conversations(runId: string): Promise<KeptConversationView[]> {
|
|
453
|
+
const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/conversations`, {
|
|
454
|
+
headers: this.headers(),
|
|
455
|
+
});
|
|
456
|
+
return (await this.json(res, "/runs/:runId/conversations")) as KeptConversationView[];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** `GET /runs/:runId/conversations/:instanceId` — one page of a kept conversation's entries after
|
|
460
|
+
* `after` (-1: from the first). */
|
|
461
|
+
async conversationEntries(
|
|
462
|
+
runId: string,
|
|
463
|
+
instanceId: string,
|
|
464
|
+
after: number,
|
|
465
|
+
limit = 1000,
|
|
466
|
+
): Promise<ConversationEntryView[]> {
|
|
467
|
+
const res = await this.fetchImpl(
|
|
468
|
+
`${this.baseUrl}/runs/${encodeURIComponent(runId)}/conversations/${encodeURIComponent(instanceId)}` +
|
|
469
|
+
`?after=${after}&limit=${limit}`,
|
|
470
|
+
{ headers: this.headers() },
|
|
471
|
+
);
|
|
472
|
+
return (await this.json(res, "/runs/:runId/conversations/:instanceId")) as ConversationEntryView[];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** `GET /runs/:runId/conversations/:instanceId/events` — follow one kept conversation: its entries
|
|
476
|
+
* after `after`, then each new one, until the server ends the feed (the conversation is not open,
|
|
477
|
+
* or it is cut or swept). A feed that ends with no `end` frame is opened again after the last
|
|
478
|
+
* entry read. */
|
|
479
|
+
async *followConversation(runId: string, instanceId: string, after: number): AsyncGenerator<ConversationEntryView> {
|
|
480
|
+
let last = after;
|
|
481
|
+
for (;;) {
|
|
482
|
+
const res = await this.fetchImpl(
|
|
483
|
+
`${this.baseUrl}/runs/${encodeURIComponent(runId)}/conversations/${encodeURIComponent(instanceId)}/events` +
|
|
484
|
+
`?after=${last}`,
|
|
485
|
+
{ headers: this.headers({ accept: "text/event-stream" }) },
|
|
486
|
+
);
|
|
487
|
+
if (!res.ok) {
|
|
488
|
+
await this.json(res, "/runs/:runId/conversations/:instanceId/events");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (!res.body) return;
|
|
492
|
+
for await (const frame of parseSSE(res.body)) {
|
|
493
|
+
if (frame.event === "entry") {
|
|
494
|
+
const entry = JSON.parse(frame.data) as ConversationEntryView;
|
|
495
|
+
if (entry.seq > last) last = entry.seq;
|
|
496
|
+
yield entry;
|
|
497
|
+
} else if (frame.event === "end") {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
300
504
|
/**
|
|
301
505
|
* `GET /healthz` — what this orchestrator says it is. Unauthenticated (it is the readiness probe),
|
|
302
506
|
* best-effort by contract: an unreachable or older instance answers `undefined`/`{}` rather than
|