@jr2/cli 0.2.1 → 0.4.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.
- package/README.md +1 -1
- package/bin/jr2.js +3 -3
- package/manifests/operator.yaml +266 -24
- package/package.json +6 -4
- package/src/assembly.ts +432 -0
- package/src/build.ts +137 -300
- package/src/cli.ts +6 -2
- package/src/client.ts +126 -33
- package/src/commands/down.ts +2 -1
- package/src/commands/init.ts +9 -2
- package/src/commands/kit.ts +24 -12
- package/src/commands/logs.ts +9 -0
- package/src/commands/run.ts +58 -4
- package/src/commands/runs.ts +96 -4
- package/src/commands/send.ts +20 -7
- package/src/commands/status.ts +90 -14
- package/src/commands/up.ts +1174 -183
- package/src/commands/version.ts +18 -13
- package/src/deploy.ts +782 -111
- package/src/effector-image.ts +133 -0
- package/src/held-pki.ts +306 -0
- package/src/index.ts +4 -0
- package/src/kube.ts +115 -9
- package/src/nodes.ts +27 -2
- package/src/sweep.ts +27 -13
package/src/cli.ts
CHANGED
|
@@ -31,11 +31,15 @@ usage: jr2 <command> [args]
|
|
|
31
31
|
kit push <registry> mirror the published kit images into a registry (ADR-0044)
|
|
32
32
|
run <workflow> [--input <json>] start a run; stream activity, print terminal result
|
|
33
33
|
[--detach] ...or just print the runId and return
|
|
34
|
-
runs list live runs
|
|
34
|
+
runs list live runs; "placing" names the Workspaces that wait for
|
|
35
|
+
capacity (ADR-0064), and "status <runId>" says why; the
|
|
36
|
+
Workflows and their named Charts on stderr (ADR-0070)
|
|
37
|
+
[--workflows] ...or print that listing as the result (see "runs --help")
|
|
35
38
|
status [runId] print a run's current status (read-through),
|
|
36
39
|
[--json] or the instance's Repos, per node, when given none (ADR-0048/0051)
|
|
37
40
|
logs <runId> [-f] replay a run's status; -f to follow until it settles
|
|
38
|
-
send <runId> --event CANCEL abandon a live run
|
|
41
|
+
send <runId> --event CANCEL abandon a live run and destroy its Workspaces
|
|
42
|
+
[--keep] ...or keep them for inspection until the idle timeout
|
|
39
43
|
send <runId> --gate <gate> --event <name> [--input <json>]
|
|
40
44
|
deliver a workflow event to an open gate
|
|
41
45
|
|
package/src/client.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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 type { MachineDoc, RepoStatus } from "@jr2/orchestrator";
|
|
9
|
+
import type { ChildErrorMarker, MachineDoc, PlacingMarker, RepoStatus, RunChild, RunWaiting } from "@jr2/orchestrator";
|
|
10
10
|
import { parseSSE } from "./sse.ts";
|
|
11
11
|
|
|
12
12
|
/** A run's current observable state — mirrors the orchestrator's `RunStatus` (run-host.ts). */
|
|
@@ -17,19 +17,56 @@ export type RunStatus = {
|
|
|
17
17
|
status: string;
|
|
18
18
|
value: unknown;
|
|
19
19
|
context: unknown;
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/** The live child machines beneath the root, context-free — where most of a run is. */
|
|
21
|
+
children?: RunChild[];
|
|
22
|
+
/** Every Workspace waiting for capacity, and why (ADR-0064). Optional: an orchestrator older
|
|
23
|
+
* than ADR-0064 does not send it. */
|
|
24
|
+
waiting?: RunWaiting[];
|
|
25
|
+
/** Why the host set this status, for statuses the Machine did not choose — `drifted` names the
|
|
26
|
+
* Assembly hash and why this Orchestrator cannot read it: not on the volume, published for
|
|
27
|
+
* another Kit version, or does not load (ADR-0030, ADR-0069). */
|
|
22
28
|
reason?: string;
|
|
29
|
+
/** The hash of the Assembly the run started under, and holds while it is live (ADR-0069).
|
|
30
|
+
* Absent for a run no Assembly started. */
|
|
31
|
+
assembly?: string;
|
|
32
|
+
/** The run binds at least one Chart (ADR-0070): its Machine is the Chart wrapper's with each bound
|
|
33
|
+
* Open body's Chart compiled in, which `GET /workflows/:name/runs/:runId/machine` serves.
|
|
34
|
+
* Absent when the run binds none. */
|
|
35
|
+
bound?: true;
|
|
36
|
+
/** The hashes of the Charts the run binds now, distinct and sorted (ADR-0070). */
|
|
37
|
+
chartHashes?: string[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Where a Workflow came from (ADR-0070): a Machine in the Assembly, a Chart file in the Assembly, or
|
|
41
|
+
* a runtime Chart put by `PUT /workflows/:wrapper/charts/:name`. */
|
|
42
|
+
export type WorkflowOrigin = "machine" | "file" | "runtime";
|
|
43
|
+
|
|
44
|
+
/** One entry of `GET /workflows` (ADR-0070) — mirrors the orchestrator's `WorkflowListing`
|
|
45
|
+
* (run-host.ts). A Chart wrapper's entry carries its Open body slot, whether its door takes a Chart
|
|
46
|
+
* one-shot, and its named Charts, inside its entry and not beside it. `unrunnable` is why a
|
|
47
|
+
* Workflow cannot start now (a runtime Chart a publish no longer admits). */
|
|
48
|
+
export type WorkflowListing = {
|
|
49
|
+
name: string;
|
|
50
|
+
origin: WorkflowOrigin;
|
|
51
|
+
wrapper?: {
|
|
52
|
+
body: string;
|
|
53
|
+
oneShot: boolean;
|
|
54
|
+
charts: Array<{ name: string; origin: "file" | "runtime"; unrunnable?: string }>;
|
|
55
|
+
};
|
|
56
|
+
unrunnable?: string;
|
|
23
57
|
};
|
|
24
58
|
|
|
25
59
|
/** One item on a run's observation feed — mirrors the orchestrator's `RunFeedEvent`. */
|
|
26
60
|
export type RunFeedEvent =
|
|
27
61
|
| { kind: "status"; status: RunStatus }
|
|
28
62
|
| { kind: "emit"; event: { type: string } & Record<string, unknown> }
|
|
29
|
-
| { kind: "retry"; child: string; attempt: number; reason: string }
|
|
63
|
+
| { kind: "retry"; child: string; attempt: number; reason: string }
|
|
64
|
+
| PlacingMarker
|
|
65
|
+
| ChildErrorMarker;
|
|
30
66
|
|
|
31
|
-
/** A run-control event posted to a live run (ADR-0013): CANCEL is the vocabulary that is left.
|
|
32
|
-
|
|
67
|
+
/** A run-control event posted to a live run (ADR-0013): CANCEL is the vocabulary that is left.
|
|
68
|
+
* `keep` leaves a cancelled run's Workspaces instead of destroying them (ADR-0025). */
|
|
69
|
+
export type RunEvent = { type: string; keep?: boolean };
|
|
33
70
|
|
|
34
71
|
/** The subset of `fetch` the client uses. `globalThis.fetch` and hono's `app.request` both satisfy it. */
|
|
35
72
|
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
@@ -57,16 +94,27 @@ export class JR2HttpError extends Error {
|
|
|
57
94
|
readonly status: number;
|
|
58
95
|
readonly path: string;
|
|
59
96
|
readonly instance?: InstanceIdentity;
|
|
97
|
+
/** The door's report lines, when the answer carried them: a refused Chart is
|
|
98
|
+
* `422 { error: "refused", report }` (ADR-0070), one line per rule it fails. */
|
|
99
|
+
readonly report?: string[];
|
|
60
100
|
|
|
61
|
-
constructor(message: string, status: number, path: string, instance?: InstanceIdentity) {
|
|
101
|
+
constructor(message: string, status: number, path: string, instance?: InstanceIdentity, report?: string[]) {
|
|
62
102
|
super(message);
|
|
63
103
|
this.name = "JR2HttpError";
|
|
64
104
|
this.status = status;
|
|
65
105
|
this.path = path;
|
|
66
106
|
this.instance = instance;
|
|
107
|
+
if (report) this.report = report;
|
|
67
108
|
}
|
|
68
109
|
}
|
|
69
110
|
|
|
111
|
+
/** The path of a Workflow's routes. The name is ONE path segment, encoded: a named Chart's
|
|
112
|
+
* `<wrapper>/<name>` rides as `<wrapper>%2F<name>` (ADR-0070), which the Orchestrator reads as the
|
|
113
|
+
* same Workflow as `/workflows/<wrapper>/charts/<name>`. */
|
|
114
|
+
export function workflowPath(workflow: string): string {
|
|
115
|
+
return `/workflows/${encodeURIComponent(workflow)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
70
118
|
export class JR2Client {
|
|
71
119
|
readonly baseUrl: string;
|
|
72
120
|
private readonly fetchImpl: FetchLike;
|
|
@@ -85,20 +133,40 @@ export class JR2Client {
|
|
|
85
133
|
return this.token ? { ...extra, authorization: `Bearer ${this.token}` } : extra;
|
|
86
134
|
}
|
|
87
135
|
|
|
88
|
-
/** `GET /workflows` —
|
|
89
|
-
|
|
90
|
-
|
|
136
|
+
/** `GET /workflows` — every registered Workflow with its origin, each Chart wrapper's Charts inside
|
|
137
|
+
* its entry (ADR-0070). An orchestrator older than ADR-0070 answers bare names; each reads as a
|
|
138
|
+
* Machine, so the listing has one shape for every caller. */
|
|
139
|
+
async workflows(): Promise<WorkflowListing[]> {
|
|
140
|
+
const body = await this.json(await this.fetchImpl(`${this.baseUrl}/workflows`), "/workflows");
|
|
141
|
+
// An answer that is no listing is not "none registered": say what came back (`jr2 runs` prints
|
|
142
|
+
// `workflows: unknown — ...` for it, never "none registered").
|
|
143
|
+
if (!Array.isArray(body)) throw new Error("GET /workflows: the answer is not a listing");
|
|
144
|
+
return body.map((w: unknown) =>
|
|
145
|
+
typeof w === "string" ? { name: w, origin: "machine" as const } : (w as WorkflowListing),
|
|
146
|
+
);
|
|
91
147
|
}
|
|
92
148
|
|
|
93
149
|
/** `GET /workflows/:name/machine` — the workflow's Machine as the Console's DTO. */
|
|
94
150
|
async machine(workflow: string): Promise<MachineDoc> {
|
|
95
|
-
const res = await this.fetchImpl(`${this.baseUrl}
|
|
151
|
+
const res = await this.fetchImpl(`${this.baseUrl}${workflowPath(workflow)}/machine`);
|
|
96
152
|
return (await this.json(res, "/workflows/:name/machine")) as MachineDoc;
|
|
97
153
|
}
|
|
98
154
|
|
|
99
|
-
/** `
|
|
155
|
+
/** `GET /workflows/:name/runs/:runId/machine` — the run's bound Machine (ADR-0070): the Chart
|
|
156
|
+
* wrapper's Machine with each bound Open body's Chart compiled in. An unknown run → undefined. */
|
|
157
|
+
async runMachine(workflow: string, runId: string): Promise<MachineDoc | undefined> {
|
|
158
|
+
const res = await this.fetchImpl(
|
|
159
|
+
`${this.baseUrl}${workflowPath(workflow)}/runs/${encodeURIComponent(runId)}/machine`,
|
|
160
|
+
);
|
|
161
|
+
if (res.status === 404) return undefined;
|
|
162
|
+
return (await this.json(res, "/workflows/:name/runs/:runId/machine")) as MachineDoc;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `POST /workflows/:name/runs` — start a run; unknown workflow → 404 → throws. A named Chart's
|
|
166
|
+
* name `<wrapper>/<name>` rides the path as one encoded segment (ADR-0070); a refused Chart in
|
|
167
|
+
* the input → 422 → throws with the door's `report`. */
|
|
100
168
|
async start(workflow: string, input: Record<string, unknown> = {}): Promise<{ runId: string; instanceId: string }> {
|
|
101
|
-
const res = await this.fetchImpl(`${this.baseUrl}
|
|
169
|
+
const res = await this.fetchImpl(`${this.baseUrl}${workflowPath(workflow)}/runs`, {
|
|
102
170
|
method: "POST",
|
|
103
171
|
headers: this.headers(JSON_HEADERS),
|
|
104
172
|
body: JSON.stringify(input),
|
|
@@ -142,7 +210,8 @@ export class JR2Client {
|
|
|
142
210
|
|
|
143
211
|
/** `POST /runs/:runId/gates/:gate/events` — deliver a workflow-defined event to an open gate
|
|
144
212
|
* (ADR-0011). The body is `{ type, ...input }`, validated against the event's schema host-side;
|
|
145
|
-
* an unknown gate (404)
|
|
213
|
+
* an unknown gate (404), a rejected payload (400) or an answer the run would not take now (409,
|
|
214
|
+
* ADR-0013) surfaces as a throw. */
|
|
146
215
|
async sendToGate(runId: string, gate: string, event: { type: string } & Record<string, unknown>): Promise<void> {
|
|
147
216
|
const res = await this.fetchImpl(
|
|
148
217
|
`${this.baseUrl}/runs/${encodeURIComponent(runId)}/gates/${encodeURIComponent(gate)}/events`,
|
|
@@ -165,26 +234,48 @@ export class JR2Client {
|
|
|
165
234
|
* `GET /runs/:runId/events` — the SSE feed as an async-iterable of `RunFeedEvent`. The current status
|
|
166
235
|
* is replayed first (attach), then deltas + author `emit`s until the terminal status (or the consumer
|
|
167
236
|
* breaks, which cancels the stream). A settled run streams its final status once and ends.
|
|
237
|
+
*
|
|
238
|
+
* A feed that ends BEFORE a terminal status is re-attached, never read as the run's end: the
|
|
239
|
+
* server closes a reader that fell behind (a paused pager on `jr2 run | less`), and the feed is
|
|
240
|
+
* level-triggered, so the re-attach opens with where the run stands now (ADR-0022). What falls in
|
|
241
|
+
* the gap is activity — Emits, retries — never state. The one end that IS the run's own without
|
|
242
|
+
* a terminal status is `gone`: the run was stopped and is parked (ADR-0025); the generator ends.
|
|
168
243
|
*/
|
|
169
244
|
async *events(runId: string): AsyncGenerator<RunFeedEvent> {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
245
|
+
for (;;) {
|
|
246
|
+
const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/events`, {
|
|
247
|
+
headers: this.headers({ accept: "text/event-stream" }),
|
|
248
|
+
});
|
|
249
|
+
if (res.status === 404) {
|
|
250
|
+
throw new JR2HttpError(`no run "${runId}"`, 404, "/runs/:runId/events", await this.identify());
|
|
251
|
+
}
|
|
252
|
+
if (!res.body) return;
|
|
253
|
+
let settled = false;
|
|
254
|
+
for await (const frame of parseSSE(res.body)) {
|
|
255
|
+
if (frame.event === "emit") {
|
|
256
|
+
yield { kind: "emit", event: JSON.parse(frame.data) as { type: string } & Record<string, unknown> };
|
|
257
|
+
} else if (frame.event === "retry") {
|
|
258
|
+
yield { kind: "retry", ...(JSON.parse(frame.data) as { child: string; attempt: number; reason: string }) };
|
|
259
|
+
} else if (frame.event === "status") {
|
|
260
|
+
const status = JSON.parse(frame.data) as RunStatus;
|
|
261
|
+
settled = status.status !== "active";
|
|
262
|
+
yield { kind: "status", status };
|
|
263
|
+
} else if (frame.event === "placing" || frame.event === "placed") {
|
|
264
|
+
// A wait for capacity starting and ending (ADR-0064) — activity, never a status.
|
|
265
|
+
yield { ...(JSON.parse(frame.data) as object), kind: frame.event } as PlacingMarker;
|
|
266
|
+
} else if (frame.event === "child-error") {
|
|
267
|
+
// A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024).
|
|
268
|
+
yield { ...(JSON.parse(frame.data) as object), kind: "child-error" } as ChildErrorMarker;
|
|
269
|
+
} else if (frame.event === "gone") {
|
|
270
|
+
// The run left the live set without settling — stopped, parked for the next boot's
|
|
271
|
+
// restore (ADR-0025). This end is the run's own (ADR-0022): nothing to re-attach to.
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// Any other frame (a Turn marker — ADR-0023 — or a kind this CLI predates) is skipped, not
|
|
275
|
+
// misread as a status: `jr2 logs -f` decides "settled" off `status.status`, and a marker
|
|
276
|
+
// parsed as a status would end the follow mid-run.
|
|
184
277
|
}
|
|
185
|
-
|
|
186
|
-
// misread as a status: `jr2 logs -f` decides "settled" off `status.status`, and a marker
|
|
187
|
-
// parsed as a status would end the follow mid-run.
|
|
278
|
+
if (settled) return;
|
|
188
279
|
}
|
|
189
280
|
}
|
|
190
281
|
|
|
@@ -209,10 +300,12 @@ export class JR2Client {
|
|
|
209
300
|
const text = await res.text();
|
|
210
301
|
const body = text ? JSON.parse(text) : undefined;
|
|
211
302
|
if (!res.ok) {
|
|
212
|
-
const
|
|
303
|
+
const { error, report } = (body ?? {}) as { error?: string; report?: unknown };
|
|
304
|
+
const message = error ?? `HTTP ${res.status}`;
|
|
305
|
+
const lines = Array.isArray(report) ? report.map(String) : undefined;
|
|
213
306
|
// Probe HERE, not at the catch site: by the time the error reaches `cli.ts` the command's
|
|
214
307
|
// `finally` has closed the port-forward, and there is nothing left to ask.
|
|
215
|
-
throw new JR2HttpError(message, res.status, path, await this.identify());
|
|
308
|
+
throw new JR2HttpError(message, res.status, path, await this.identify(), lines);
|
|
216
309
|
}
|
|
217
310
|
return body;
|
|
218
311
|
}
|
package/src/commands/down.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `jr2 down [--all] [-n <ns>] [--context <ctx>]` (ADR-0019): remove the instance from the cluster —
|
|
2
2
|
// its namespace and everything `jr2 up` converged into it. ALWAYS confirms (deleting a namespace
|
|
3
|
-
// takes the runs, the store PVC,
|
|
3
|
+
// takes the runs, the store PVC, the Instance Harness's conversations PVC (ADR-0031), and every
|
|
4
|
+
// 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
|
//
|
package/src/commands/init.ts
CHANGED
|
@@ -117,9 +117,16 @@ export default defineConfig({
|
|
|
117
117
|
// attach refuses it, so nothing can spend this cluster's credential against an arbitrary host. \`*\` is
|
|
118
118
|
// today's two implicit defaults made visible (JR2_GIT_TOKEN from .env for https, the jr2-git-ssh Secret
|
|
119
119
|
// 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.
|
|
120
|
+
// a run. The longest match wins; the url's scheme picks token vs sshKey. It also PUSHES: a \`push()\` whose
|
|
121
|
+
// Repo Slot url it matches — a per-run url included — spends the same token or key (ADR-0051, ADR-0067).
|
|
121
122
|
credentials: [{ match: "*", token: "JR2_GIT_TOKEN", sshKey: "jr2-git-ssh" }],
|
|
122
123
|
},
|
|
124
|
+
// Every secret of the Instance, one list (ADR-0068). Where each goes is read, never declared — a pod gets
|
|
125
|
+
// what something in it names. A model key goes to the pods whose Agents' provider reads it, and a
|
|
126
|
+
// \`workspace()\` that lists a name under \`secrets\` gets that one: its Custodian holds it and the Agent sees
|
|
127
|
+
// only a Stand-in (ADR-0059). One an Effect names with \`secret("name")\` is a file in that Effect's Effector
|
|
128
|
+
// alone. \`hosts\` says where a held value may go. Read values from .env, never commit them.
|
|
129
|
+
// secrets: [{ name: "ANTHROPIC_API_KEY", value: process.env.ANTHROPIC_API_KEY }],
|
|
123
130
|
// Where Sandboxes land (ADR-0052): by default wherever an ordinary pod lands — not cordoned, no taint — and
|
|
124
131
|
// the Repo cache agent follows the same set. No node label is needed. To admit a tainted pool or narrow to
|
|
125
132
|
// a labeled one, name it in raw pod-spec shapes; \`jr2 up\` reports the Sandbox nodes it sees.
|
|
@@ -182,7 +189,7 @@ node_modules/
|
|
|
182
189
|
`;
|
|
183
190
|
|
|
184
191
|
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 (
|
|
192
|
+
// respond to the request" with a plain actor (ADR-0001: a worktree is one piece a coding Machine bolts on) —
|
|
186
193
|
// this is that case, and the one workflow that runs end-to-end on a fresh instance before any Sandbox /
|
|
187
194
|
// Harness infrastructure exists. Filename \`ping.ts\` → workflow "ping".
|
|
188
195
|
//
|
package/src/commands/kit.ts
CHANGED
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Kit images live at a canonical public home (`ghcr.io/snapwich`, baked into `publishedKitRefs()`).
|
|
4
4
|
// A cluster that cannot reach it — air-gapped, mirror-only, or simply policy-bound to one registry —
|
|
5
|
-
// needs
|
|
6
|
-
// command is how the bytes get there.
|
|
5
|
+
// needs those refs at its own address, which is what `kitRegistry` names in the config. This
|
|
6
|
+
// command is how the bytes get there. It moves one more image the same way: the Custodian's, the
|
|
7
|
+
// pinned upstream Envoy every Harness pod runs (ADR-0059), which jr2 deploys and never builds — a
|
|
8
|
+
// mirror-only cluster needs it just as much, and `kitRegistry` re-homes it the same way.
|
|
7
9
|
//
|
|
8
10
|
// It is deliberately INSTANCE-LESS: no `jr2.config.ts`, no kube context, no namespace. Kit images are
|
|
9
11
|
// shared by every instance on a cluster (and possibly by every instance in an org), so moving them
|
|
10
12
|
// is its own deliberate act rather than a side effect of converging one instance — which is exactly
|
|
11
13
|
// why ADR-0044 keeps this out of `jr2 up`.
|
|
12
14
|
//
|
|
13
|
-
// It is a MIRROR and nothing else. There is no build arm here: the npm packages carry no Harness
|
|
14
|
-
//
|
|
15
|
+
// It is a MIRROR and nothing else. There is no build arm here: the npm packages carry no Harness
|
|
16
|
+
// source, and an installed CLI that could build Kit images would be the patched-Harness
|
|
15
17
|
// eject hatch ADR-0027/ADR-0038 welded shut. The kit developer's arm is `just kit-push`, in the
|
|
16
18
|
// checkout, where the sources actually are.
|
|
17
19
|
//
|
|
@@ -23,7 +25,7 @@
|
|
|
23
25
|
|
|
24
26
|
import { execFile } from "node:child_process";
|
|
25
27
|
import { parseArgs, promisify } from "node:util";
|
|
26
|
-
import { KIT_VERSION } from "@jr2/orchestrator";
|
|
28
|
+
import { custodianRef, KIT_VERSION } from "@jr2/orchestrator";
|
|
27
29
|
import { KIT_IMAGES, publishedKitRefs, type KitImageName } from "../build.ts";
|
|
28
30
|
import { activity, result, type Io } from "../output.ts";
|
|
29
31
|
|
|
@@ -31,8 +33,9 @@ const exec = promisify(execFile);
|
|
|
31
33
|
|
|
32
34
|
const USAGE = `usage: jr2 kit push <registry>
|
|
33
35
|
|
|
34
|
-
mirror the v${KIT_VERSION} kit images (harness,
|
|
35
|
-
home into <registry>, for a cluster whose
|
|
36
|
+
mirror the v${KIT_VERSION} kit images (harness, operator, orchestrator) from their canonical
|
|
37
|
+
home, and the Custodian's pinned Envoy from its own, into <registry>, for a cluster whose
|
|
38
|
+
\`kitRegistry\` names it (ADR-0044, ADR-0059, ADR-0069)`;
|
|
36
39
|
|
|
37
40
|
/** Run one `docker` invocation, rejecting on a non-zero exit. The seam this command is tested
|
|
38
41
|
* through: the claims are which argv each ref produces and what a failure means, and both are
|
|
@@ -45,7 +48,7 @@ const dockerCli: RunDocker = async (args) => {
|
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
/** What one image's mirror did — the per-image line, and the machine-readable result's rows. */
|
|
48
|
-
type Mirrored = { image: KitImageName; source: string; target: string; copied: boolean };
|
|
51
|
+
type Mirrored = { image: KitImageName | "custodian"; source: string; target: string; copied: boolean };
|
|
49
52
|
|
|
50
53
|
export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli): Promise<number> {
|
|
51
54
|
const { positionals } = parseArgs({ args, allowPositionals: true, strict: false, options: {} });
|
|
@@ -77,6 +80,9 @@ export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli)
|
|
|
77
80
|
for (const image of Object.keys(KIT_IMAGES) as KitImageName[]) {
|
|
78
81
|
rows.push(await mirror(io, docker, image, sources[image], targets[image]));
|
|
79
82
|
}
|
|
83
|
+
// The Custodian's: pinned by digest, and a registry-to-registry copy keeps the digest — so the
|
|
84
|
+
// ref a converge with this `kitRegistry` deploys (`custodianRef`) resolves in the mirror.
|
|
85
|
+
rows.push(await mirror(io, docker, "custodian", custodianRef(), custodianRef(target)));
|
|
80
86
|
|
|
81
87
|
const copied = rows.filter((r) => r.copied).length;
|
|
82
88
|
activity(io, `mirrored ${copied} image(s), ${rows.length - copied} already present`);
|
|
@@ -97,7 +103,7 @@ export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli)
|
|
|
97
103
|
async function mirror(
|
|
98
104
|
io: Io,
|
|
99
105
|
docker: RunDocker,
|
|
100
|
-
image: KitImageName,
|
|
106
|
+
image: KitImageName | "custodian",
|
|
101
107
|
source: string,
|
|
102
108
|
target: string,
|
|
103
109
|
): Promise<Mirrored> {
|
|
@@ -106,7 +112,8 @@ async function mirror(
|
|
|
106
112
|
return { image, source, target, copied: false };
|
|
107
113
|
}
|
|
108
114
|
await requireSource(docker, image, source, target);
|
|
109
|
-
|
|
115
|
+
// A digest-pinned target is pushed by its tag; the copy lands at that same digest.
|
|
116
|
+
await docker(["buildx", "imagetools", "create", "-t", target.replace(/@sha256:[0-9a-f]+$/, ""), source]);
|
|
110
117
|
activity(io, `${image}: copied ${source} → ${target}`);
|
|
111
118
|
return { image, source, target, copied: true };
|
|
112
119
|
}
|
|
@@ -130,12 +137,17 @@ async function resolves(docker: RunDocker, ref: string): Promise<boolean> {
|
|
|
130
137
|
* it points at is the other arm of ADR-0044: `just kit-push`, in the checkout, is what puts images
|
|
131
138
|
* at a published name in the first place; this command only moves what is already there.
|
|
132
139
|
*/
|
|
133
|
-
async function requireSource(
|
|
140
|
+
async function requireSource(
|
|
141
|
+
docker: RunDocker,
|
|
142
|
+
image: KitImageName | "custodian",
|
|
143
|
+
source: string,
|
|
144
|
+
target: string,
|
|
145
|
+
): Promise<void> {
|
|
134
146
|
if (await resolves(docker, source)) return;
|
|
135
147
|
throw new Error(
|
|
136
148
|
`${image}: ${source} is not there, so nothing can be mirrored to ${target}\n` +
|
|
137
149
|
` a kit image reaches ${target} only by being copied from its published home;\n` +
|
|
138
150
|
` if v${KIT_VERSION} was never published (a dev version never is), seed a registry from a kit\n` +
|
|
139
|
-
` checkout with \`just kit-push <registry>\`, which builds the
|
|
151
|
+
` checkout with \`just kit-push <registry>\`, which builds the Kit images from source`,
|
|
140
152
|
);
|
|
141
153
|
}
|
package/src/commands/logs.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { JR2Client } from "../client.ts";
|
|
|
8
8
|
import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
|
|
9
9
|
import { activity, result, type Io } from "../output.ts";
|
|
10
10
|
import { resolveRunId } from "../run-id.ts";
|
|
11
|
+
import { childErrorActivity, placingActivity } from "./run.ts";
|
|
11
12
|
|
|
12
13
|
export async function logs(args: string[], io: Io): Promise<number> {
|
|
13
14
|
const { values, positionals } = parseArgs({
|
|
@@ -39,6 +40,14 @@ export async function logs(args: string[], io: Io): Promise<number> {
|
|
|
39
40
|
activity(io, `retry ${ev.child} attempt ${ev.attempt} (${ev.reason})`);
|
|
40
41
|
continue;
|
|
41
42
|
}
|
|
43
|
+
if (ev.kind === "placing" || ev.kind === "placed") {
|
|
44
|
+
activity(io, placingActivity(ev));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ev.kind === "child-error") {
|
|
48
|
+
activity(io, childErrorActivity(ev));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
42
51
|
result(io, ev.status);
|
|
43
52
|
if (!values.follow) break;
|
|
44
53
|
if (ev.status.status !== "active") break;
|
package/src/commands/run.ts
CHANGED
|
@@ -7,9 +7,14 @@
|
|
|
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.
|
|
10
14
|
|
|
11
15
|
import { parseArgs } from "node:util";
|
|
12
|
-
import {
|
|
16
|
+
import type { ChildErrorMarker, PlacingMarker } from "@jr2/orchestrator";
|
|
17
|
+
import { JR2Client, JR2HttpError } from "../client.ts";
|
|
13
18
|
import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
|
|
14
19
|
import { activity, result, type Io } from "../output.ts";
|
|
15
20
|
|
|
@@ -27,7 +32,7 @@ export async function run(args: string[], io: Io): Promise<number> {
|
|
|
27
32
|
|
|
28
33
|
const workflow = positionals[0];
|
|
29
34
|
if (!workflow) {
|
|
30
|
-
activity(io, "usage: jr2 run <workflow> [--input <json>] [--detach]");
|
|
35
|
+
activity(io, "usage: jr2 run <workflow | wrapper/chart> [--input <json>] [--detach]");
|
|
31
36
|
return 2;
|
|
32
37
|
}
|
|
33
38
|
const input = values.input ? (JSON.parse(String(values.input)) as Record<string, unknown>) : {};
|
|
@@ -35,7 +40,14 @@ export async function run(args: string[], io: Io): Promise<number> {
|
|
|
35
40
|
try {
|
|
36
41
|
const client = new JR2Client(target.url, io.fetch, target.token);
|
|
37
42
|
|
|
38
|
-
|
|
43
|
+
let runId: string;
|
|
44
|
+
try {
|
|
45
|
+
({ runId } = await client.start(workflow, input));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (!(err instanceof JR2HttpError) || !err.report) throw err;
|
|
48
|
+
activity(io, refusalActivity(workflow, err.report));
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
39
51
|
if (values.detach) {
|
|
40
52
|
result(io, { runId });
|
|
41
53
|
return 0;
|
|
@@ -51,14 +63,56 @@ export async function run(args: string[], io: Io): Promise<number> {
|
|
|
51
63
|
activity(io, ` retry ${ev.child} attempt ${ev.attempt} (${ev.reason})`);
|
|
52
64
|
continue;
|
|
53
65
|
}
|
|
66
|
+
if (ev.kind === "placing" || ev.kind === "placed") {
|
|
67
|
+
activity(io, ` ${placingActivity(ev)}`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (ev.kind === "child-error") {
|
|
71
|
+
activity(io, ` ${childErrorActivity(ev)}`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
54
74
|
activity(io, ` → ${ev.status.status} ${JSON.stringify(ev.status.value)}`);
|
|
55
75
|
if (ev.status.status !== "active") {
|
|
56
76
|
result(io, ev.status);
|
|
57
77
|
return ev.status.status === "error" ? 1 : 0;
|
|
58
78
|
}
|
|
59
79
|
}
|
|
60
|
-
|
|
80
|
+
// `events()` re-attaches until the run settles (ADR-0022), so reaching here means the run left
|
|
81
|
+
// the live set without settling: stopped, parked for the next boot's restore (ADR-0025). Never
|
|
82
|
+
// a success, and nothing on stdout to be mistaken for a result.
|
|
83
|
+
activity(
|
|
84
|
+
io,
|
|
85
|
+
`run ${runId} is parked: stopped before it settled — \`jr2 logs -f ${runId}\` follows it once restored`,
|
|
86
|
+
);
|
|
87
|
+
return 1;
|
|
61
88
|
} finally {
|
|
62
89
|
target.close?.();
|
|
63
90
|
}
|
|
64
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The door's refusal of a Chart (ADR-0070) as activity: the Workflow, then each report line, which
|
|
95
|
+
* names the path in the Chart and the rule it fails.
|
|
96
|
+
*/
|
|
97
|
+
export function refusalActivity(workflow: string, report: readonly string[]): string {
|
|
98
|
+
return [`error: the door refused the Chart for "${workflow}"`, ...report.map((line) => ` ${line}`)].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A wait for capacity as one activity line (ADR-0064): the Workspace, what it waits for, and the
|
|
103
|
+
* scheduler's or the quota's own words — then, once it has a node, how long it waited. The run's
|
|
104
|
+
* root Workspace has no child path, so its line names none.
|
|
105
|
+
*/
|
|
106
|
+
export function placingActivity(ev: PlacingMarker): string {
|
|
107
|
+
const who = ev.child ? ` ${ev.child}` : "";
|
|
108
|
+
if (ev.kind === "placed") return `placed${who} after ${Math.round(ev.after / 1000)}s`;
|
|
109
|
+
return `placing${who}: waits for ${ev.on === "quota" ? "quota" : "a node"} — ${ev.message}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A child's error below an Agent's state as one activity line (ADR-0024): the pick's answer carries
|
|
114
|
+
* no reply for it, so the Agent is not told, and this line is where the operator reads it.
|
|
115
|
+
*/
|
|
116
|
+
export function childErrorActivity(ev: ChildErrorMarker): string {
|
|
117
|
+
return `child-error: \`${ev.child}\` failed below ${ev.agent}'s pick \`${ev.pick}\` — ${ev.error}`;
|
|
118
|
+
}
|
package/src/commands/runs.ts
CHANGED
|
@@ -1,19 +1,111 @@
|
|
|
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
|
+
//
|
|
8
|
+
// The verb also lists the Workflows a run can start (ADR-0070), from `GET /workflows`, as activity on
|
|
9
|
+
// stderr so the result on stdout stays the live runs: one line per Workflow with its origin
|
|
10
|
+
// (`machine`, `chart file`, `chart`), a Chart wrapper's Charts indented under it, and `unrunnable`
|
|
11
|
+
// with its reason. `--workflows` prints that listing as the result instead. No verb puts or deletes
|
|
12
|
+
// a runtime Chart in this step: `PUT`/`DELETE /workflows/<wrapper>/charts/<name>` is for an MCP or curl.
|
|
3
13
|
|
|
4
14
|
import { parseArgs } from "node:util";
|
|
5
|
-
import {
|
|
15
|
+
import type { RunChild } from "@jr2/orchestrator";
|
|
16
|
+
import { JR2Client, type RunStatus, type WorkflowListing, type WorkflowOrigin } from "../client.ts";
|
|
6
17
|
import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
|
|
7
|
-
import { result, type Io } from "../output.ts";
|
|
18
|
+
import { activity, result, type Io } from "../output.ts";
|
|
19
|
+
|
|
20
|
+
export const RUNS_USAGE = `usage: jr2 runs [--workflows]
|
|
21
|
+
|
|
22
|
+
the live runs as JSON on stdout; the Workflows a run can start on stderr, each with its origin
|
|
23
|
+
(machine, chart file, chart) and a Chart wrapper's Charts indented under it (ADR-0070)
|
|
24
|
+
--workflows print the Workflow listing as the JSON result instead
|
|
25
|
+
|
|
26
|
+
a runtime Chart has no verb here: PUT or DELETE /workflows/<wrapper>/charts/<name> with the
|
|
27
|
+
Instance token (an MCP or curl), then \`jr2 run <wrapper>/<name>\` starts it`;
|
|
8
28
|
|
|
9
29
|
export async function runs(args: string[], io: Io): Promise<number> {
|
|
10
|
-
const { values } = parseArgs({
|
|
30
|
+
const { values } = parseArgs({
|
|
31
|
+
args,
|
|
32
|
+
allowPositionals: true,
|
|
33
|
+
strict: false,
|
|
34
|
+
options: { ...TARGET_ARGS, workflows: { type: "boolean" }, help: { type: "boolean", short: "h" } },
|
|
35
|
+
});
|
|
36
|
+
if (values.help) {
|
|
37
|
+
activity(io, RUNS_USAGE);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
11
40
|
const target = await resolveTarget(io, targetOptions(values));
|
|
12
41
|
try {
|
|
13
42
|
const client = new JR2Client(target.url, io.fetch, target.token);
|
|
14
|
-
|
|
43
|
+
if (values.workflows) {
|
|
44
|
+
result(io, await client.workflows());
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
const live = (await client.list()).map(markPlacing);
|
|
48
|
+
// A report beside the result: a listing that cannot be read says why, and the runs still print.
|
|
49
|
+
try {
|
|
50
|
+
activity(io, renderWorkflows(await client.workflows()));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
activity(io, `workflows: unknown — ${e instanceof Error ? e.message : String(e)}`);
|
|
53
|
+
}
|
|
54
|
+
result(io, live);
|
|
15
55
|
return 0;
|
|
16
56
|
} finally {
|
|
17
57
|
target.close?.();
|
|
18
58
|
}
|
|
19
59
|
}
|
|
60
|
+
|
|
61
|
+
/** How a listing names an origin (ADR-0070): a runtime Chart is plainly a `chart`. */
|
|
62
|
+
const ORIGIN_LABEL: Record<WorkflowOrigin, string> = { machine: "machine", file: "chart file", runtime: "chart" };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The Workflow listing as lines: each Workflow with its origin, a Chart wrapper's Open body and
|
|
66
|
+
* whether its door takes a Chart one-shot, its Charts indented under it, and `unrunnable` with the
|
|
67
|
+
* reason. A Chart the listing also gives at the top level (or whose wrapper it does not list) prints
|
|
68
|
+
* once: under its wrapper when the wrapper is listed, else at the top level.
|
|
69
|
+
*/
|
|
70
|
+
export function renderWorkflows(listing: readonly WorkflowListing[]): string {
|
|
71
|
+
const underWrapper = new Set<string>();
|
|
72
|
+
for (const w of listing) for (const c of w.wrapper?.charts ?? []) underWrapper.add(chartFullName(w.name, c.name));
|
|
73
|
+
const line = (indent: string, name: string, label: string, unrunnable?: string): string =>
|
|
74
|
+
`${indent}${name} (${label})${unrunnable ? ` unrunnable: ${unrunnable}` : ""}`;
|
|
75
|
+
const out = ["workflows:"];
|
|
76
|
+
for (const w of listing) {
|
|
77
|
+
if (w.origin !== "machine" && underWrapper.has(w.name)) continue;
|
|
78
|
+
const wrapper = w.wrapper;
|
|
79
|
+
const label = wrapper
|
|
80
|
+
? `${ORIGIN_LABEL[w.origin]}, Chart wrapper: body "${wrapper.body}"${wrapper.oneShot ? ", one-shot" : ""}`
|
|
81
|
+
: ORIGIN_LABEL[w.origin];
|
|
82
|
+
out.push(line(" ", w.name, label, w.unrunnable));
|
|
83
|
+
for (const c of wrapper?.charts ?? []) {
|
|
84
|
+
out.push(line(" ", chartFullName(w.name, c.name), ORIGIN_LABEL[c.origin], c.unrunnable));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (out.length === 1) out.push(" none registered — `jr2 up` publishes the Assembly");
|
|
88
|
+
return out.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A Chart's Workflow name, `<wrapper>/<name>` (ADR-0070), whether the listing gives it short or whole. */
|
|
92
|
+
function chartFullName(wrapper: string, name: string): string {
|
|
93
|
+
return name.includes("/") ? name : `${wrapper}/${name}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The run, with `placing` after its id when any Workspace in it is in `placing`. */
|
|
97
|
+
export function markPlacing(run: RunStatus): RunStatus & { placing?: string[] } {
|
|
98
|
+
const placing = [...(run.value === "placing" ? [""] : []), ...placingIn(run.children ?? [], [])];
|
|
99
|
+
if (!placing.length) return run;
|
|
100
|
+
const { runId, ...rest } = run;
|
|
101
|
+
return { runId, placing, ...rest };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The paths of every child in `placing`, at any depth — read off the state values alone, which
|
|
105
|
+
* are public (a Workspace's value is the flat state key). */
|
|
106
|
+
function placingIn(children: RunChild[], path: string[]): string[] {
|
|
107
|
+
return children.flatMap((child) => {
|
|
108
|
+
const here = [...path, child.id];
|
|
109
|
+
return [...(child.value === "placing" ? [here.join("/")] : []), ...placingIn(child.children, here)];
|
|
110
|
+
});
|
|
111
|
+
}
|