@jr2/orchestrator 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.
Files changed (75) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -5
  13. package/src/actor.ts +507 -54
  14. package/src/agent.ts +2 -2
  15. package/src/ambient.ts +2 -0
  16. package/src/assembly-checks.ts +165 -0
  17. package/src/assembly-tar.ts +494 -0
  18. package/src/assembly.ts +721 -0
  19. package/src/chart.ts +2050 -0
  20. package/src/charts.ts +720 -0
  21. package/src/config.ts +233 -22
  22. package/src/content-hash.ts +105 -0
  23. package/src/custodian.lua +313 -0
  24. package/src/custodian.ts +951 -0
  25. package/src/customize.ts +216 -25
  26. package/src/effect-checks.ts +557 -0
  27. package/src/effect.ts +905 -0
  28. package/src/effector-wire.ts +525 -0
  29. package/src/fingerprint.ts +4 -3
  30. package/src/gate.ts +8 -0
  31. package/src/git-clone.ts +65 -0
  32. package/src/git-push.ts +336 -0
  33. package/src/harness-client.ts +247 -25
  34. package/src/held-secrets.ts +643 -0
  35. package/src/holds.ts +156 -0
  36. package/src/http-effect.ts +981 -0
  37. package/src/http.ts +514 -119
  38. package/src/images.ts +58 -31
  39. package/src/index.ts +49 -4
  40. package/src/instance.ts +445 -59
  41. package/src/kube-client.ts +333 -0
  42. package/src/machine-doc.ts +88 -6
  43. package/src/model-keys.ts +92 -0
  44. package/src/mount-digest.ts +53 -0
  45. package/src/names.ts +89 -7
  46. package/src/notices.ts +88 -0
  47. package/src/open-body.ts +1444 -0
  48. package/src/open-effect.ts +144 -0
  49. package/src/open.ts +4 -4
  50. package/src/parts.ts +530 -33
  51. package/src/party.ts +468 -0
  52. package/src/pick.ts +309 -0
  53. package/src/pool.ts +1 -1
  54. package/src/registration.ts +301 -17
  55. package/src/repo-fetch.ts +61 -86
  56. package/src/repos.ts +63 -74
  57. package/src/rest.ts +133 -0
  58. package/src/run-host.ts +1841 -105
  59. package/src/sandbox-effect.ts +891 -0
  60. package/src/sandbox-effector.ts +111 -0
  61. package/src/sandbox-kube.ts +1719 -0
  62. package/src/sandbox-watch.ts +260 -0
  63. package/src/secret-placement.ts +494 -0
  64. package/src/server.ts +285 -89
  65. package/src/setup.ts +589 -70
  66. package/src/size.ts +342 -0
  67. package/src/snapshot-store.ts +64 -13
  68. package/src/stub-harness.ts +5 -0
  69. package/src/sync-client.ts +54 -0
  70. package/src/timers.ts +178 -0
  71. package/src/tokens.ts +44 -12
  72. package/src/vocabulary.ts +66 -1
  73. package/src/wire.ts +156 -2
  74. package/src/workspace.ts +529 -194
  75. package/src/sandbox-kubectl.ts +0 -1144
@@ -0,0 +1,333 @@
1
+ // The Orchestrator's Kubernetes client (ADR-0063): plain REST on Node's built-in `fetch`, with no
2
+ // dependency. The Orchestrator is always in-cluster (ADR-0019), so the whole of "configuration" is
3
+ // the ServiceAccount the kubelet mounts and the Service the API server publishes:
4
+ //
5
+ // base URL https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT
6
+ // bearer the SA token FILE, read on every request (below)
7
+ // trust the SA `ca.crt`, through NODE_EXTRA_CA_CERTS on the Deployment (deploy.ts) — so
8
+ // this module holds no TLS code at all
9
+ //
10
+ // It speaks for jr2's own kinds alone — Sandbox, Repo, Secret — and every call stays behind the
11
+ // Sandbox and Repo ports, so swapping it for a client library is a contained change if jr2 ever
12
+ // needs exec or many more kinds (ADR-0063's considered options).
13
+ //
14
+ // Its rules, each observed on a real cluster (ADR-0063):
15
+ //
16
+ // - The token is never cached. The kubelet replaced a 10-minute token only about 50 seconds
17
+ // before it expired, so a cached one fails in production and never in a test. A `401` re-reads
18
+ // the file and retries ONCE — a rotation between the read and the request.
19
+ // - Writes are capped (8–16 at once). 200 parallel writes each opened a TLS connection and cost
20
+ // about 130% CPU; a provision burst queues here instead.
21
+ // - A watch asks for bookmarks and `timeoutSeconds=240`: built-in `fetch` aborts a body silent
22
+ // for 300s (`UND_ERR_BODY_TIMEOUT`) and that cannot be changed without undici's dispatcher, so
23
+ // the server closing first — and bookmarks about every 60s — keep a healthy watch clear of it.
24
+ // The watch LOOP that reads this stream lives in sandbox-watch.ts.
25
+
26
+ import { readFile } from "node:fs/promises";
27
+
28
+ // The API server's CA (`SERVICE_ACCOUNT_CA`, names.ts) is not read here: the Deployment points
29
+ // NODE_EXTRA_CA_CERTS at it, and built-in `fetch` trusts it from there.
30
+
31
+ /** Where the kubelet mounts the Pod's ServiceAccount. */
32
+ export const SERVICE_ACCOUNT_DIR = "/var/run/secrets/kubernetes.io/serviceaccount";
33
+ /** The bearer — rotated by the kubelet, so read per request. */
34
+ export const SERVICE_ACCOUNT_TOKEN = `${SERVICE_ACCOUNT_DIR}/token`;
35
+
36
+ /** The field manager every server-side apply names — the Orchestrator's one identity as a writer. */
37
+ export const FIELD_MANAGER = "jr2";
38
+
39
+ /** The write cap when the caller names none: inside ADR-0063's 8–16. */
40
+ const DEFAULT_MAX_WRITES = 12;
41
+
42
+ /** One kind, as its REST path spells it. The core group is `""` (`/api/v1`). */
43
+ export type KubeKind = { group: string; version: string; plural: string };
44
+
45
+ export const SANDBOXES: KubeKind = { group: "core.jr2.dev", version: "v1alpha1", plural: "sandboxes" };
46
+ export const REPOS: KubeKind = { group: "core.jr2.dev", version: "v1alpha1", plural: "repos" };
47
+ export const SECRETS: KubeKind = { group: "", version: "v1", plural: "secrets" };
48
+ export const CONFIGMAPS: KubeKind = { group: "", version: "v1", plural: "configmaps" };
49
+ export const SERVICES: KubeKind = { group: "", version: "v1", plural: "services" };
50
+
51
+ /** The object fields every kind shares — what the client and the watch read off any of them. */
52
+ export type KubeObject = {
53
+ apiVersion?: string;
54
+ kind?: string;
55
+ metadata?: {
56
+ name?: string;
57
+ namespace?: string;
58
+ uid?: string;
59
+ resourceVersion?: string;
60
+ labels?: Record<string, string>;
61
+ annotations?: Record<string, string>;
62
+ };
63
+ };
64
+
65
+ /** One event off a watch stream. `ERROR` carries a `Status` (code 410 for a resourceVersion too
66
+ * old) INSIDE an HTTP 200 — the caller re-lists on any of them. */
67
+ export type WatchEvent<T extends KubeObject = KubeObject> =
68
+ | { type: "ADDED" | "MODIFIED" | "DELETED" | "BOOKMARK"; object: T }
69
+ | { type: "ERROR"; object: { kind?: "Status"; code?: number; reason?: string; message?: string } };
70
+
71
+ /** A refusal from the API server: the HTTP status and the `Status` body's own reason and words. */
72
+ export class KubeError extends Error {
73
+ readonly status: number;
74
+ readonly reason: string | undefined;
75
+ constructor(status: number, reason: string | undefined, message: string) {
76
+ super(message);
77
+ this.name = "KubeError";
78
+ this.status = status;
79
+ this.reason = reason;
80
+ }
81
+ }
82
+
83
+ export type KubeClientOptions = {
84
+ /** The API server. Default: the in-cluster Service from `KUBERNETES_SERVICE_HOST`/`_PORT`. */
85
+ baseUrl?: string;
86
+ /** The environment the default base URL is read from. Default `process.env`. */
87
+ env?: Record<string, string | undefined>;
88
+ /** The bearer, asked for on EVERY request — never cached (see the header). Default: the SA
89
+ * token file. */
90
+ token?: () => Promise<string>;
91
+ /** Injectable transport: the tests' fake API server. Default: global `fetch`. */
92
+ fetch?: typeof fetch;
93
+ /** How many writes may be in flight at once. Default 12. */
94
+ maxWrites?: number;
95
+ };
96
+
97
+ /** The client the Sandbox, Repo and fetch ports share: one process, one write cap. */
98
+ export interface KubeClient {
99
+ /** The object, or `undefined` when there is none (404). */
100
+ get<T extends KubeObject>(kind: KubeKind, namespace: string, name: string): Promise<T | undefined>;
101
+ /** Every object of the kind in the namespace, with the list's resourceVersion — where a watch
102
+ * starts. A kind the cluster does not serve (no CRD) is a {@link KubeError} with status 404. */
103
+ list<T extends KubeObject>(
104
+ kind: KubeKind,
105
+ namespace: string,
106
+ opts?: { labelSelector?: string },
107
+ ): Promise<{ items: T[]; resourceVersion: string }>;
108
+ /** Create; an existing object is a {@link KubeError} with status 409. */
109
+ create<T extends KubeObject>(kind: KubeKind, namespace: string, body: T): Promise<T & KubeObject>;
110
+ /** Delete; true when there was one to delete, false when it was already gone. */
111
+ delete(kind: KubeKind, namespace: string, name: string): Promise<boolean>;
112
+ /** A JSON merge patch — `null` removes a key. `status` patches the status subresource. Answers
113
+ * the object as it stands after the patch. */
114
+ patch<T extends KubeObject>(
115
+ kind: KubeKind,
116
+ namespace: string,
117
+ name: string,
118
+ body: object,
119
+ opts?: { status?: boolean },
120
+ ): Promise<T>;
121
+ /** Server-side apply as field manager `jr2`, forced: create-or-update of every field the body
122
+ * names. Answers the object as it stands afterwards (its `uid` included). */
123
+ apply<T extends KubeObject>(kind: KubeKind, namespace: string, body: T): Promise<T & KubeObject>;
124
+ /** One watch request's events, from `resourceVersion`, until the server closes it. Throws on a
125
+ * refused request or a broken stream — the caller decides what each means (sandbox-watch.ts). */
126
+ watch<T extends KubeObject>(
127
+ kind: KubeKind,
128
+ namespace: string,
129
+ opts: { resourceVersion: string; labelSelector?: string; timeoutSeconds: number; signal?: AbortSignal },
130
+ ): AsyncIterable<WatchEvent<T>>;
131
+ }
132
+
133
+ /** The in-cluster base URL, from the Service env the kubelet injects into every pod. */
134
+ export function inClusterBaseUrl(env: Record<string, string | undefined> = process.env): string {
135
+ const host = env.KUBERNETES_SERVICE_HOST;
136
+ const port = env.KUBERNETES_SERVICE_PORT ?? "443";
137
+ if (!host) {
138
+ throw new Error(
139
+ "no Kubernetes API server: KUBERNETES_SERVICE_HOST is unset. The Orchestrator drives the cluster " +
140
+ "only from inside it (ADR-0019, ADR-0063) — `jr2 up` deploys it there.",
141
+ );
142
+ }
143
+ // An IPv6 address needs brackets in a URL.
144
+ return `https://${host.includes(":") ? `[${host}]` : host}:${port}`;
145
+ }
146
+
147
+ /** Build the client. Nothing is read until the first request: a boot never fails on the cluster. */
148
+ export function kubeClient(opts: KubeClientOptions = {}): KubeClient {
149
+ const fetchImpl = opts.fetch ?? fetch;
150
+ const token = opts.token ?? (async () => (await readFile(SERVICE_ACCOUNT_TOKEN, "utf8")).trim());
151
+ const writes = semaphore(opts.maxWrites ?? DEFAULT_MAX_WRITES);
152
+ let base: string | undefined = opts.baseUrl;
153
+ const baseUrl = () => (base ??= inClusterBaseUrl(opts.env));
154
+
155
+ /** One request, with the bearer read fresh and a 401 re-read and retried once. */
156
+ const send = async (
157
+ method: string,
158
+ path: string,
159
+ init: { query?: Record<string, string>; body?: string; contentType?: string; signal?: AbortSignal } = {},
160
+ ): Promise<Response> => {
161
+ const url = new URL(path, baseUrl());
162
+ for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v);
163
+ for (let attempt = 0; ; attempt++) {
164
+ const res = await fetchImpl(url, {
165
+ method,
166
+ headers: {
167
+ authorization: `Bearer ${await token()}`,
168
+ accept: "application/json",
169
+ ...(init.contentType ? { "content-type": init.contentType } : {}),
170
+ },
171
+ ...(init.body !== undefined ? { body: init.body } : {}),
172
+ ...(init.signal ? { signal: init.signal } : {}),
173
+ });
174
+ // A rotation landed between the read and the request: the file already holds the new one.
175
+ if (res.status === 401 && attempt === 0) {
176
+ await res.body?.cancel();
177
+ continue;
178
+ }
179
+ return res;
180
+ }
181
+ };
182
+
183
+ /** The answer as JSON, or a {@link KubeError} carrying the Status body's reason and words. */
184
+ const json = async <T>(res: Response, what: string): Promise<T> => {
185
+ const text = await res.text();
186
+ if (res.ok) return (text ? JSON.parse(text) : {}) as T;
187
+ throw errorOf(res.status, text, what);
188
+ };
189
+
190
+ /** A write: inside the cap until its answer is read, so the cap bounds connections, not calls. */
191
+ const write = <T>(method: string, path: string, what: string, init: Parameters<typeof send>[2]) =>
192
+ writes(async () => json<T>(await send(method, path, init), what));
193
+
194
+ return {
195
+ async get<T extends KubeObject>(kind: KubeKind, ns: string, name: string) {
196
+ const res = await send("GET", pathOf(kind, ns, name));
197
+ if (res.status === 404) {
198
+ await res.body?.cancel();
199
+ return undefined;
200
+ }
201
+ return json<T>(res, `get ${kind.plural}/${name}`);
202
+ },
203
+
204
+ async list<T extends KubeObject>(kind: KubeKind, ns: string, opts: { labelSelector?: string } = {}) {
205
+ const res = await send("GET", pathOf(kind, ns), {
206
+ query: opts.labelSelector ? { labelSelector: opts.labelSelector } : {},
207
+ });
208
+ const out = await json<{ items?: T[]; metadata?: { resourceVersion?: string } }>(res, `list ${kind.plural}`);
209
+ return { items: out.items ?? [], resourceVersion: out.metadata?.resourceVersion ?? "" };
210
+ },
211
+
212
+ create<T extends KubeObject>(kind: KubeKind, ns: string, body: T) {
213
+ return write<T & KubeObject>("POST", pathOf(kind, ns), `create ${kind.plural}/${body.metadata?.name ?? "?"}`, {
214
+ body: JSON.stringify(body),
215
+ contentType: "application/json",
216
+ });
217
+ },
218
+
219
+ delete(kind: KubeKind, ns: string, name: string) {
220
+ return writes(async () => {
221
+ const res = await send("DELETE", pathOf(kind, ns, name));
222
+ if (res.status === 404) {
223
+ await res.body?.cancel();
224
+ return false;
225
+ }
226
+ await json(res, `delete ${kind.plural}/${name}`);
227
+ return true;
228
+ });
229
+ },
230
+
231
+ patch<T extends KubeObject>(kind: KubeKind, ns: string, name: string, body: object, o: { status?: boolean } = {}) {
232
+ return write<T>(
233
+ "PATCH",
234
+ pathOf(kind, ns, name, o.status ? "status" : undefined),
235
+ `patch ${kind.plural}/${name}`,
236
+ {
237
+ body: JSON.stringify(body),
238
+ contentType: "application/merge-patch+json",
239
+ },
240
+ );
241
+ },
242
+
243
+ apply<T extends KubeObject>(kind: KubeKind, ns: string, body: T) {
244
+ const name = body.metadata?.name;
245
+ if (!name) throw new Error(`apply ${kind.plural}: the object names no metadata.name`);
246
+ // JSON is YAML, so the apply content type takes the object as it is.
247
+ return write<T & KubeObject>("PATCH", pathOf(kind, ns, name), `apply ${kind.plural}/${name}`, {
248
+ query: { fieldManager: FIELD_MANAGER, force: "true" },
249
+ body: JSON.stringify(body),
250
+ contentType: "application/apply-patch+yaml",
251
+ });
252
+ },
253
+
254
+ async *watch<T extends KubeObject>(
255
+ kind: KubeKind,
256
+ ns: string,
257
+ o: { resourceVersion: string; labelSelector?: string; timeoutSeconds: number; signal?: AbortSignal },
258
+ ): AsyncIterable<WatchEvent<T>> {
259
+ const res = await send("GET", pathOf(kind, ns), {
260
+ query: {
261
+ watch: "true",
262
+ allowWatchBookmarks: "true",
263
+ timeoutSeconds: String(o.timeoutSeconds),
264
+ resourceVersion: o.resourceVersion,
265
+ ...(o.labelSelector ? { labelSelector: o.labelSelector } : {}),
266
+ },
267
+ ...(o.signal ? { signal: o.signal } : {}),
268
+ });
269
+ if (!res.ok) throw errorOf(res.status, await res.text(), `watch ${kind.plural}`);
270
+ if (!res.body) return;
271
+ // One JSON object per line. A line can straddle two chunks, so the tail waits for the next.
272
+ let buf = "";
273
+ for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
274
+ buf += chunk;
275
+ let nl: number;
276
+ while ((nl = buf.indexOf("\n")) >= 0) {
277
+ const line = buf.slice(0, nl).trim();
278
+ buf = buf.slice(nl + 1);
279
+ if (line) yield JSON.parse(line) as WatchEvent<T>;
280
+ }
281
+ }
282
+ if (buf.trim()) yield JSON.parse(buf) as WatchEvent<T>;
283
+ },
284
+ };
285
+ }
286
+
287
+ /** A `Status` body read into a {@link KubeError}; a body that is not one keeps its raw text. */
288
+ function errorOf(status: number, text: string, what: string): KubeError {
289
+ let reason: string | undefined;
290
+ let message = text;
291
+ try {
292
+ const parsed = JSON.parse(text) as { reason?: string; message?: string };
293
+ reason = parsed.reason;
294
+ message = parsed.message ?? text;
295
+ } catch {
296
+ // not a Status: the raw text is the most there is to say
297
+ }
298
+ return new KubeError(status, reason, `${what} failed (${status}${reason ? ` ${reason}` : ""}): ${message}`);
299
+ }
300
+
301
+ /** The REST path of a kind in a namespace, optionally one object and one subresource. */
302
+ function pathOf(kind: KubeKind, ns: string, name?: string, sub?: string): string {
303
+ const root = kind.group ? `/apis/${kind.group}/${kind.version}` : `/api/${kind.version}`;
304
+ return (
305
+ `${root}/namespaces/${encodeURIComponent(ns)}/${kind.plural}` +
306
+ (name !== undefined ? `/${encodeURIComponent(name)}` : "") +
307
+ (sub !== undefined ? `/${sub}` : "")
308
+ );
309
+ }
310
+
311
+ /** At most `n` of the wrapped calls at once; the rest queue in arrival order. */
312
+ function semaphore(n: number): <T>(fn: () => Promise<T>) => Promise<T> {
313
+ let active = 0;
314
+ const queue: Array<() => void> = [];
315
+ return async (fn) => {
316
+ // A released slot passes straight to the next waiter, so a caller arriving in between can
317
+ // never slip past the cap.
318
+ if (active >= n) await new Promise<void>((resolve) => queue.push(resolve));
319
+ else active++;
320
+ try {
321
+ return await fn();
322
+ } finally {
323
+ const next = queue.shift();
324
+ if (next) next();
325
+ else active--;
326
+ }
327
+ };
328
+ }
329
+
330
+ /** True for the API server's "no such object" — the one refusal most callers read as an answer. */
331
+ export const isNotFound = (err: unknown): boolean => err instanceof KubeError && err.status === 404;
332
+ /** True for "already exists" on a create. */
333
+ export const isAlreadyExists = (err: unknown): boolean => err instanceof KubeError && err.status === 409;
@@ -18,7 +18,8 @@
18
18
  import type { AnyStateMachine, StateNode, TransitionDefinition } from "xstate";
19
19
  import { z } from "zod";
20
20
  import type { EventAudience } from "@jr2/agent-protocol";
21
- import { vocabularyOf } from "./vocabulary.ts";
21
+ import { argvText, isEffect, targetText, type EffectParts } from "./effect.ts";
22
+ import { loadWarningsOf, seatsOf, vocabularyOf, type MachineSeat } from "./vocabulary.ts";
22
23
 
23
24
  /** One transition of the Machine, id-addressed at both ends. */
24
25
  export type MachineTransitionDoc = {
@@ -32,9 +33,30 @@ export type MachineTransitionDoc = {
32
33
  label: string;
33
34
  /** Normalized guard name, if guarded. */
34
35
  guard?: string;
36
+ /** xstate's own transition `description`, as authored: what this writing does, where one name
37
+ * has writings that differ (ADR-0015's "one def, one meaning"). */
38
+ description?: string;
35
39
  kind: "event" | "always" | "after" | "done" | "error";
36
40
  };
37
41
 
42
+ /** An Effect slot's parts, as the doc carries them (ADR-0067): a command's argv (an argv slot reads
43
+ * `<name>`), its timeout, and the Repo Slot whose Worktree is its cwd, when it names one. */
44
+ export type EffectDoc =
45
+ | { kind: "command"; argv: string[]; timeoutMs: number; slot?: string; secrets?: string[]; pin?: true }
46
+ /** An http Effect (ADR-0068): its method, its fixed target, the secrets it names — never a value
47
+ * — and `file` when its body is a Worktree file (ADR-0067). */
48
+ | {
49
+ kind: "http";
50
+ method: string;
51
+ target: string;
52
+ secrets: string[];
53
+ timeoutMs: number;
54
+ idempotent: boolean;
55
+ file?: true;
56
+ }
57
+ /** A push (ADR-0067): which options the author set. */
58
+ | { kind: "push"; sign: boolean; squash: boolean; force: boolean; pin: boolean; timeoutMs: number; slot?: string };
59
+
38
60
  /** One state of the Machine; `states` nests in document order. */
39
61
  export type MachineStateDoc = {
40
62
  /** Unique state id (custom `id:` respected). */
@@ -44,7 +66,9 @@ export type MachineStateDoc = {
44
66
  type: "atomic" | "compound" | "parallel" | "final" | "history";
45
67
  /** Initial child state id (compound only). */
46
68
  initial?: string;
47
- invoke: Array<{ id: string; src: string }>;
69
+ /** Each invoke's id and slot; an Effect's carries its plain-data parts (ADR-0067) — what it runs,
70
+ * where, and for how long, read without running anything. */
71
+ invoke: Array<{ id: string; src: string; effect?: EffectDoc }>;
48
72
  tags: string[];
49
73
  description?: string;
50
74
  states: MachineStateDoc[];
@@ -98,7 +122,8 @@ export type ChildMachineDoc = {
98
122
  export type MachineEventDoc = {
99
123
  name: string;
100
124
  description?: string;
101
- audience: EventAudience;
125
+ /** Absent: each writing's state decides who may send it (ADR-0015) — see the body's `seats`. */
126
+ audience?: EventAudience;
102
127
  /** The def's input schema as JSON Schema — what a form or a `jr2 send` prompt generates from. */
103
128
  input: unknown;
104
129
  };
@@ -112,6 +137,13 @@ export type MachineBodyDoc = {
112
137
  /** The events THIS Machine declares, and only this one (ADR-0049): a nested Machine's ride its
113
138
  * own `MachineBodyDoc`. Empty for a Machine not built by `jr2Setup`. */
114
139
  events: MachineEventDoc[];
140
+ /** Who may send what, per state (ADR-0015): one entry per party on each state that invokes one —
141
+ * the audience rule's answer for this Machine, as `jr2 up` prints it. Empty for a Machine not
142
+ * built by `jr2Setup`. */
143
+ seats: MachineSeat[];
144
+ /** What the build found legal and suspect (ADR-0015) — a mode nothing enters, a pick a Gate above
145
+ * takes from the Agent below, one name written for two parties on one path. */
146
+ warnings: string[];
115
147
  };
116
148
 
117
149
  /** The serialized structure of a workflow's Machine. */
@@ -122,6 +154,10 @@ export type MachineDoc = MachineBodyDoc & {
122
154
  * to discover the diagram is lying. Absent on the nested `MachineBodyDoc`s: it is a whole-doc
123
155
  * question, and the root's walk already covers them. */
124
156
  opaqueStates?: string[];
157
+ /** A run's doc only (`runMachine`, ADR-0070): each bound Chart the doc could not draw, so its Open
158
+ * body shows the slot's own subgraph — the body's path in the run, its slot, and why (a stored
159
+ * Chart the door no longer admits is drift, D-B). Absent when every bound Chart is drawn. */
160
+ chartsNotDrawn?: Array<{ path: string; src: string; reason: string }>;
125
161
  };
126
162
 
127
163
  /** Normalize a guard to a display name: setup() name, parameterized type, or `"inline"`. */
@@ -172,6 +208,7 @@ function serializeTransition(t: TransitionDefinition<any, any>): MachineTransiti
172
208
  kind,
173
209
  };
174
210
  if (guard !== undefined) doc.guard = guard;
211
+ if (t.description !== undefined) doc.description = t.description;
175
212
  return doc;
176
213
  }
177
214
 
@@ -287,7 +324,11 @@ function serializeState(node: StateNode<any, any>, into: MachineTransitionDoc[],
287
324
  id: node.id,
288
325
  key: node.key,
289
326
  type: node.type,
290
- invoke: node.invoke.map((inv) => ({ id: inv.id, src: srcName(inv.src) })),
327
+ invoke: node.invoke.map((inv) => {
328
+ const logic = typeof inv.src === "string" ? scope.actors[inv.src] : undefined;
329
+ if (!isEffect(logic)) return { id: inv.id, src: srcName(inv.src) };
330
+ return { id: inv.id, src: srcName(inv.src), effect: effectDoc(logic.effect) };
331
+ }),
291
332
  tags: [...node.tags],
292
333
  states: children.map((child) => serializeState(child, into, scope)),
293
334
  children: childMachines(node, scope),
@@ -311,6 +352,18 @@ export function opaqueStates(doc: MachineDoc): string[] {
311
352
  return ids;
312
353
  }
313
354
 
355
+ /** Every Machine body in a doc — the root's, then each child Machine's, depth-first in state order.
356
+ * A recursive reference is not repeated: its body is already above it. */
357
+ export function machineBodies(doc: MachineBodyDoc): MachineBodyDoc[] {
358
+ const out: MachineBodyDoc[] = [doc];
359
+ const walk = (state: MachineStateDoc): void => {
360
+ for (const child of state.children) if (child.machine) out.push(...machineBodies(child.machine));
361
+ for (const nested of state.states) walk(nested);
362
+ };
363
+ walk(doc.root);
364
+ return out;
365
+ }
366
+
314
367
  /** Serialize one Machine — its own state tree and its own transitions. Each level carries its own
315
368
  * actor registry (a child machine resolves `src` names against ITS `setup`, not its parent's). */
316
369
  function serializeBody(machine: AnyStateMachine, path: Set<AnyStateMachine>): MachineBodyDoc {
@@ -321,10 +374,10 @@ function serializeBody(machine: AnyStateMachine, path: Set<AnyStateMachine>): Ma
321
374
  const events = [...(vocabularyOf(machine)?.values() ?? [])].map((def) => ({
322
375
  name: def.name,
323
376
  ...(def.description !== undefined ? { description: def.description } : {}),
324
- audience: def.audience,
377
+ ...(def.audience !== undefined ? { audience: def.audience } : {}),
325
378
  input: z.toJSONSchema(def.input),
326
379
  }));
327
- return { id: machine.id, root, transitions, events };
380
+ return { id: machine.id, root, transitions, events, seats: seatsOf(machine), warnings: loadWarningsOf(machine) };
328
381
  }
329
382
 
330
383
  /** Serialize a workflow's template Machine into the Console's DTO, child machines and all. */
@@ -332,3 +385,32 @@ export function serializeMachine(workflow: string, machine: AnyStateMachine): Ma
332
385
  const doc: MachineDoc = { workflow, ...serializeBody(machine, new Set()) };
333
386
  return { ...doc, opaqueStates: opaqueStates(doc) };
334
387
  }
388
+
389
+ /** An Effect's plain data as the doc carries it: what it runs, read without running anything. */
390
+ function effectDoc(parts: EffectParts): EffectDoc {
391
+ if (parts.kind === "http") {
392
+ const { method, secrets, timeoutMs, idempotent } = parts;
393
+ return {
394
+ kind: "http",
395
+ method,
396
+ target: targetText(parts),
397
+ secrets: [...secrets],
398
+ timeoutMs,
399
+ idempotent,
400
+ ...(parts.file ? { file: true as const } : {}),
401
+ };
402
+ }
403
+ if (parts.kind === "push") {
404
+ const { sign, squash, force, pin, timeoutMs, slot } = parts;
405
+ return { kind: "push", sign, squash, force, pin, timeoutMs, ...(slot ? { slot } : {}) };
406
+ }
407
+ const { argv, timeoutMs, slot, secrets, pin } = parts;
408
+ return {
409
+ kind: "command",
410
+ argv: argvText(argv),
411
+ timeoutMs,
412
+ ...(slot ? { slot } : {}),
413
+ ...(secrets?.length ? { secrets: [...secrets] } : {}),
414
+ ...(pin ? { pin } : {}),
415
+ };
416
+ }
@@ -0,0 +1,92 @@
1
+ // The known model keys (ADR-0059): every env var pi's catalog reads a provider's credential from,
2
+ // the catalog provider(s) that read it, and the one host those providers' calls go to by default.
3
+ //
4
+ // A COPY of pi's data at the exact pin `@jr2/harness` holds, because this package has no pi
5
+ // dependency and must not grow one: `jr2 up` reads this table host-side (R1, and the default
6
+ // `hosts` of a held secret), and the in-cluster Orchestrator never touches pi. The copy cannot
7
+ // drift silently — `@jr2/harness`'s `model-keys.test.ts` checks it against pi itself, env names per
8
+ // provider and every provider's default host, in the default gate. A pi bump that adds or moves a
9
+ // provider fails there first, the same canary role Harness conformance plays (ADR-0027).
10
+ //
11
+ // `host` is present only when every model of every provider that reads the name goes to ONE static
12
+ // host. It is absent when there is none: the base URL is a template (`{location}`, an account id),
13
+ // the providers disagree (MOONSHOT_API_KEY serves two regions), or the host is regional (Bedrock).
14
+ // A held secret with such a name must then say its `hosts` itself (§3.4 of the spec, R5).
15
+
16
+ /** One known model-key env name. */
17
+ export type ModelKey = {
18
+ /** The catalog provider ids that read this name. */
19
+ providers: readonly string[];
20
+ /** The host (port 443) those providers' calls go to by default, when there is exactly one. */
21
+ host?: string;
22
+ };
23
+
24
+ export const MODEL_KEYS: Readonly<Record<string, ModelKey>> = {
25
+ // anthropic reads three names. AUTH_TOKEN goes as `Authorization: Bearer`, the other two as
26
+ // `x-api-key` (an `sk-ant-oat` value is an OAuth token, which a Stand-in never is).
27
+ ANTHROPIC_API_KEY: { providers: ["anthropic"], host: "api.anthropic.com" },
28
+ ANTHROPIC_AUTH_TOKEN: { providers: ["anthropic"], host: "api.anthropic.com" },
29
+ ANTHROPIC_OAUTH_TOKEN: { providers: ["anthropic"], host: "api.anthropic.com" },
30
+ OPENAI_API_KEY: { providers: ["openai"], host: "api.openai.com" },
31
+ GEMINI_API_KEY: { providers: ["google"], host: "generativelanguage.googleapis.com" },
32
+ // `https://{location}-aiplatform.googleapis.com` — regional, so no default.
33
+ GOOGLE_CLOUD_API_KEY: { providers: ["google-vertex"] },
34
+ COPILOT_GITHUB_TOKEN: { providers: ["github-copilot"], host: "api.individual.githubcopilot.com" },
35
+ // The bearer form of Bedrock auth. Regional, so no default. (SigV4 credentials cannot be held at
36
+ // all — a signature is not a header a Stand-in can be swapped into — and `jr2 up` warns on them.)
37
+ AWS_BEARER_TOKEN_BEDROCK: { providers: ["amazon-bedrock"] },
38
+ ANT_LING_API_KEY: { providers: ["ant-ling"], host: "api.ant-ling.com" },
39
+ QWEN_TOKEN_PLAN_API_KEY: {
40
+ providers: ["qwen-token-plan"],
41
+ host: "token-plan.ap-southeast-1.maas.aliyuncs.com",
42
+ },
43
+ QWEN_TOKEN_PLAN_CN_API_KEY: { providers: ["qwen-token-plan-cn"], host: "token-plan.cn-beijing.maas.aliyuncs.com" },
44
+ // The catalog names no base URL: an Azure deployment is the user's own resource.
45
+ AZURE_OPENAI_API_KEY: { providers: ["azure-openai-responses"] },
46
+ NVIDIA_API_KEY: { providers: ["nvidia"], host: "integrate.api.nvidia.com" },
47
+ DEEPSEEK_API_KEY: { providers: ["deepseek"], host: "api.deepseek.com" },
48
+ GROQ_API_KEY: { providers: ["groq"], host: "api.groq.com" },
49
+ CEREBRAS_API_KEY: { providers: ["cerebras"], host: "api.cerebras.ai" },
50
+ XAI_API_KEY: { providers: ["xai"], host: "api.x.ai" },
51
+ // pi reads the name, and its catalog lists no model for the provider at this pin.
52
+ RADIUS_API_KEY: { providers: ["radius"] },
53
+ OPENROUTER_API_KEY: { providers: ["openrouter"], host: "openrouter.ai" },
54
+ AI_GATEWAY_API_KEY: { providers: ["vercel-ai-gateway"], host: "ai-gateway.vercel.sh" },
55
+ ZAI_API_KEY: { providers: ["zai"], host: "api.z.ai" },
56
+ ZAI_CODING_CN_API_KEY: { providers: ["zai-coding-cn"], host: "open.bigmodel.cn" },
57
+ MISTRAL_API_KEY: { providers: ["mistral"], host: "api.mistral.ai" },
58
+ MINIMAX_API_KEY: { providers: ["minimax"], host: "api.minimax.io" },
59
+ MINIMAX_CN_API_KEY: { providers: ["minimax-cn"], host: "api.minimaxi.com" },
60
+ // Two providers, two hosts (`.ai` and `.cn`): no single default.
61
+ MOONSHOT_API_KEY: { providers: ["moonshotai", "moonshotai-cn"] },
62
+ HF_TOKEN: { providers: ["huggingface"], host: "router.huggingface.co" },
63
+ FIREWORKS_API_KEY: { providers: ["fireworks"], host: "api.fireworks.ai" },
64
+ TOGETHER_API_KEY: { providers: ["together"], host: "api.together.ai" },
65
+ // Two providers, one host.
66
+ OPENCODE_API_KEY: { providers: ["opencode", "opencode-go"], host: "opencode.ai" },
67
+ KIMI_API_KEY: { providers: ["kimi-coding"], host: "api.kimi.com" },
68
+ // Account-templated base URLs.
69
+ CLOUDFLARE_API_KEY: { providers: ["cloudflare-workers-ai", "cloudflare-ai-gateway"] },
70
+ XIAOMI_API_KEY: { providers: ["xiaomi"], host: "api.xiaomimimo.com" },
71
+ XIAOMI_TOKEN_PLAN_CN_API_KEY: { providers: ["xiaomi-token-plan-cn"], host: "token-plan-cn.xiaomimimo.com" },
72
+ XIAOMI_TOKEN_PLAN_AMS_API_KEY: { providers: ["xiaomi-token-plan-ams"], host: "token-plan-ams.xiaomimimo.com" },
73
+ XIAOMI_TOKEN_PLAN_SGP_API_KEY: { providers: ["xiaomi-token-plan-sgp"], host: "token-plan-sgp.xiaomimimo.com" },
74
+ };
75
+
76
+ /** Every catalog provider id some known model key belongs to — the set `harness.catalog` may name. */
77
+ export const MODEL_KEY_PROVIDERS: ReadonlySet<string> = new Set(
78
+ Object.values(MODEL_KEYS).flatMap((key) => key.providers),
79
+ );
80
+
81
+ /**
82
+ * Credentials that sign a request instead of riding it in a header (ADR-0059). A Stand-in cannot be
83
+ * swapped into a signature, so these cannot be held — `jr2 up` warns when `harness.env` sets one,
84
+ * because the Agent can read it, and names a gateway as the way out.
85
+ */
86
+ export const SIGNING_CREDENTIALS: readonly string[] = [
87
+ "AWS_ACCESS_KEY_ID",
88
+ "AWS_SECRET_ACCESS_KEY",
89
+ "AWS_SESSION_TOKEN",
90
+ "AWS_PROFILE",
91
+ "GOOGLE_APPLICATION_CREDENTIALS",
92
+ ];
@@ -0,0 +1,53 @@
1
+ // What a mounted ConfigMap holds, as one digest (ADR-0038, ADR-0069). A publish rolls no pod, so
2
+ // the Orchestrator reads `jr2-images` and `jr2-held` through mounts the kubelet refreshes on its
3
+ // own period. `jr2 up` digests the data it applied, `GET /assembly` reports the digest of what the
4
+ // process reads, and the publish waits until the two agree. Then the door's checks, and the
5
+ // provisions that follow the converge, read the data this converge wrote.
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { readdir, readFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { HELD_CONFIGMAP, HELD_MOUNT, IMAGES_CONFIGMAP, IMAGES_MOUNT } from "./names.ts";
11
+
12
+ /** The ConfigMaps the Orchestrator reads through a mount, by name, and where each is mounted. */
13
+ export const MOUNTED_CONFIGMAPS: Readonly<Record<string, string>> = {
14
+ [IMAGES_CONFIGMAP]: IMAGES_MOUNT,
15
+ [HELD_CONFIGMAP]: HELD_MOUNT,
16
+ };
17
+
18
+ /** One digest of a ConfigMap's `data`: the same bytes give the same digest at both ends. */
19
+ export function configMapDigest(data: Readonly<Record<string, string>>): string {
20
+ const entries = Object.keys(data)
21
+ .sort()
22
+ .map((key) => [key, data[key]]);
23
+ return createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0, 16);
24
+ }
25
+
26
+ /**
27
+ * The digest of a mounted ConfigMap, read from its mount (ADR-0069). The kubelet writes each key
28
+ * as a file and keeps its own entries under names that start with `.`, which are not data. An
29
+ * absent mount reads as `undefined`.
30
+ */
31
+ export async function readMountDigest(dir: string): Promise<string | undefined> {
32
+ let names: string[];
33
+ try {
34
+ names = await readdir(dir);
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ const data: Record<string, string> = {};
39
+ for (const name of names.filter((n) => !n.startsWith("."))) data[name] = await readFile(join(dir, name), "utf8");
40
+ return configMapDigest(data);
41
+ }
42
+
43
+ /** Every mounted ConfigMap's digest, by name; a mount that is absent is left out. */
44
+ export async function readMountDigests(
45
+ mounts: Readonly<Record<string, string>> = MOUNTED_CONFIGMAPS,
46
+ ): Promise<Record<string, string>> {
47
+ const out: Record<string, string> = {};
48
+ for (const [name, dir] of Object.entries(mounts)) {
49
+ const digest = await readMountDigest(dir);
50
+ if (digest !== undefined) out[name] = digest;
51
+ }
52
+ return out;
53
+ }