@jr2/orchestrator 0.3.0 → 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 (73) 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 -4
  13. package/src/actor.ts +502 -49
  14. package/src/ambient.ts +2 -0
  15. package/src/assembly-checks.ts +165 -0
  16. package/src/assembly-tar.ts +494 -0
  17. package/src/assembly.ts +721 -0
  18. package/src/chart.ts +2050 -0
  19. package/src/charts.ts +720 -0
  20. package/src/config.ts +152 -46
  21. package/src/content-hash.ts +105 -0
  22. package/src/custodian.lua +6 -5
  23. package/src/custodian.ts +71 -19
  24. package/src/customize.ts +216 -25
  25. package/src/effect-checks.ts +557 -0
  26. package/src/effect.ts +905 -0
  27. package/src/effector-wire.ts +525 -0
  28. package/src/fingerprint.ts +4 -3
  29. package/src/gate.ts +8 -0
  30. package/src/git-clone.ts +65 -0
  31. package/src/git-push.ts +336 -0
  32. package/src/harness-client.ts +245 -23
  33. package/src/held-secrets.ts +82 -26
  34. package/src/holds.ts +156 -0
  35. package/src/http-effect.ts +981 -0
  36. package/src/http.ts +496 -104
  37. package/src/images.ts +47 -21
  38. package/src/index.ts +43 -2
  39. package/src/instance.ts +444 -58
  40. package/src/kube-client.ts +333 -0
  41. package/src/machine-doc.ts +88 -6
  42. package/src/mount-digest.ts +53 -0
  43. package/src/names.ts +66 -8
  44. package/src/notices.ts +88 -0
  45. package/src/open-body.ts +1444 -0
  46. package/src/open-effect.ts +144 -0
  47. package/src/open.ts +4 -4
  48. package/src/parts.ts +530 -33
  49. package/src/party.ts +468 -0
  50. package/src/pick.ts +309 -0
  51. package/src/pool.ts +1 -1
  52. package/src/registration.ts +301 -17
  53. package/src/repo-fetch.ts +60 -85
  54. package/src/repos.ts +63 -74
  55. package/src/rest.ts +133 -0
  56. package/src/run-host.ts +1832 -98
  57. package/src/sandbox-effect.ts +891 -0
  58. package/src/sandbox-effector.ts +111 -0
  59. package/src/sandbox-kube.ts +1719 -0
  60. package/src/sandbox-watch.ts +260 -0
  61. package/src/secret-placement.ts +494 -0
  62. package/src/server.ts +281 -84
  63. package/src/setup.ts +589 -70
  64. package/src/size.ts +342 -0
  65. package/src/snapshot-store.ts +64 -13
  66. package/src/stub-harness.ts +5 -0
  67. package/src/sync-client.ts +54 -0
  68. package/src/timers.ts +178 -0
  69. package/src/tokens.ts +35 -4
  70. package/src/vocabulary.ts +66 -1
  71. package/src/wire.ts +156 -2
  72. package/src/workspace.ts +529 -194
  73. package/src/sandbox-kubectl.ts +0 -1108
@@ -15,15 +15,18 @@
15
15
  // (`{ streamUrl, offset, submissionId }` — jr2's durable re-attach handle, stored in the host
16
16
  // ledger), and `wait(admission)` follows the durable stream from the admission offset to the
17
17
  // Submission's Settlement — a long-poll loop that advances by the `stream-next-offset` header and
18
- // reconnects from the SAME offset on network failure (capped backoff, indefinitely: pod death is
19
- // `workspace.lost`'s job to report, not this loop's to guess). Re-attach after a restart is `wait`
18
+ // reconnects from the SAME offset on network failure (capped backoff, indefinitely: a lost pod is
19
+ // `workspace.lost`'s to report and a restarted Harness the Agent actor's, both off the watch —
20
+ // ADR-0021 — never this loop's to guess). Re-attach after a restart is `wait`
20
21
  // with the SAME persisted admission; replay cost is bounded by one Submission's chunks. A 404 is a
21
- // LOST conversation (ADR-0027: a conversation lives as long as its Harness process) — a
22
- // `SettlementFault`, never an endless poll.
22
+ // LOST conversation (ADR-0027: the Harness no longer holds it — a persisted conversation survives a
23
+ // Harness restart, ADR-0031, and one it cannot rebuild or has freed does not) — a
24
+ // `SettlementFault`, never an endless poll. A Submission a restart cut settles `failed` on the
25
+ // rebuilt stream (ADR-0031), which is read like any other Settlement.
23
26
  //
24
27
  // Both verbs re-send on a network failure, for one reason stated twice: an unanswered request is
25
28
  // not an answer. They differ in bound, and the difference is where the Submission is — `wait`'s is
26
- // already admitted, so the lease may report a dead pod and this loop need never give up, while
29
+ // already admitted, so the watch may report a dead pod or Harness and this loop need never give up, while
27
30
  // `send`'s does not exist yet, so its window closes and faults. `send` also re-sends ONLY what
28
31
  // provably never left this host (`postAdmission` — ADR-0042: it is the first thing ever to dial a
29
32
  // Sandbox's Service, and a CR at `phase: Ready` is not yet routable).
@@ -34,13 +37,42 @@
34
37
  // (the actor is stopped by then; see actor.ts).
35
38
 
36
39
  import { agentActorWith } from "./actor.ts";
37
- import type { AgentAdmission, AgentAdmitOptions, AgentLogic, AgentRunInput, AgentRunPort } from "./actor.ts";
40
+ import type {
41
+ AgentAdmission,
42
+ AgentAdmitOptions,
43
+ AgentLogic,
44
+ AgentRunInput,
45
+ AgentRunPort,
46
+ AgentSettleOptions,
47
+ } from "./actor.ts";
38
48
  import type { AgentDeclaration, AgentDefinition, ThinkingLevel } from "./agent.ts";
49
+ import {
50
+ commandWith,
51
+ ExecNotSent,
52
+ ExecRefused,
53
+ type Arg,
54
+ type CommandInput,
55
+ type CommandOptions,
56
+ type CommandOutput,
57
+ type OpenCommandInput,
58
+ type EnvOpenParts,
59
+ type EffectLogic,
60
+ type ExecPort,
61
+ } from "./effect.ts";
62
+ import { open } from "./open.ts";
63
+ import type { OpenEffect, OpenOf } from "./open-effect.ts";
39
64
  import {
40
65
  LIVE_LONG_POLL,
41
66
  STREAM_NEXT_OFFSET_HEADER,
67
+ VIEW_HISTORY,
42
68
  VIEW_UPDATES,
43
69
  type EchoEvent,
70
+ type ExecRead,
71
+ type ExecRequest,
72
+ type LiveConversation,
73
+ type LiveSetRequest,
74
+ type LiveSetResponse,
75
+ type Notice,
44
76
  type Settlement,
45
77
  type StreamEvent,
46
78
  type SubmissionSettledEvent,
@@ -67,12 +99,19 @@ export type HarnessClient = {
67
99
  definition: AgentDefinition;
68
100
  model?: string;
69
101
  thinkingLevel?: ThinkingLevel;
102
+ /** The pending notices in this admission's scope (ADR-0062). Omitted when there are none. */
103
+ notices?: Notice[];
70
104
  signal?: AbortSignal;
71
105
  },
72
106
  ): Promise<AgentAdmission>;
73
107
  /** Follow the stream to this Submission's Settlement: resolve on `completed`, reject with
74
- * `SettlementFault` on `failed`/`aborted`/404. */
75
- wait(admission: AgentAdmission, opts?: { signal?: AbortSignal }): Promise<void>;
108
+ * `SettlementFault` on `failed`/`aborted`/404. A guard kill read on the way is handed to
109
+ * `onMemoryLimit` (ADR-0061). */
110
+ wait(admission: AgentAdmission, opts?: AgentSettleOptions): Promise<void>;
111
+ /** `GET /agents/:name/:id?view=history`: does the Harness still hold this conversation? Only a
112
+ * 404 says no (ADR-0027: a GET on an unknown conversation is 404); anything else — another
113
+ * status, a transport failure — says yes, because unknown is never loss (ADR-0062). */
114
+ holds(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<boolean>;
76
115
  /** `POST /agents/:name/:id/abort`. The `{ aborted }` answer is dropped (ADR-0024). */
77
116
  abort(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<void>;
78
117
  };
@@ -114,10 +153,14 @@ export type HarnessClientOptions = {
114
153
  export class SettlementFault extends Error {
115
154
  /** The Settlement as the stream carried it; absent when the conversation itself was lost. */
116
155
  readonly settlement?: Settlement;
117
- constructor(message: string, settlement?: Settlement) {
156
+ /** The conversation itself is gone (404): its Harness no longer holds it (ADR-0027). One of the
157
+ * faults the Agent actor asks the Sandbox about, to name a memory kill (ADR-0061). */
158
+ readonly lost: boolean;
159
+ constructor(message: string, settlement?: Settlement, opts: { lost?: boolean } = {}) {
118
160
  super(message);
119
161
  this.name = "SettlementFault";
120
162
  this.settlement = settlement;
163
+ this.lost = opts.lost === true;
121
164
  }
122
165
  }
123
166
 
@@ -138,11 +181,11 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
138
181
  * POST the admission, re-sending it while the request DEMONSTRABLY never left this host.
139
182
  *
140
183
  * This is `wait`'s reconnect rule, applied one step earlier and for the same reason: a request
141
- * that could not connect is not a Harness that refused the prompt. It matters here because the
142
- * admission is the FIRST thing jr2 ever sends over the Sandbox's Service — provisioning waits on
143
- * the CR's `phase: Ready`, which the operator computes from the POD, and the attach reaches the
144
- * pod through the API server, so nothing before this has proven the Service dialable. Ready is
145
- * not routable: the EndpointSlice behind the ClusterIP is programmed after the pod passes its
184
+ * that could not connect is not a Harness that refused the prompt. It matters here because
185
+ * provisioning waits on the CR's `phase: Ready`, which the operator computes from the POD, and
186
+ * a Workspace-less Turn (the Instance Harness) has no attach before it — so this can be the first
187
+ * request ever sent over a Service. (A Sandbox's attach, `POST /attach` since ADR-0063, dials it
188
+ * first and retries the same way.) Ready is not routable: the EndpointSlice behind the ClusterIP is programmed after the pod passes its
146
189
  * probe, and until it is, kube-proxy REJECTs — which arrives here as a refused connection.
147
190
  * Un-retried, that single blip lost the whole turn (the actor calls an admission failure a
148
191
  * terminal `agent.fault`), which is exactly the flake that kept the `@kind` tier serial.
@@ -153,9 +196,15 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
153
196
  * question "did this reach the wire at all", never "does this look transient".
154
197
  *
155
198
  * And the window is BOUNDED where `wait`'s is not. `wait` may reconnect forever because its
156
- * Submission is already admitted, so the lease owns the reporting (`workspace.lost` — ADR-0021).
199
+ * Submission is already admitted, so the watch owns the reporting (`workspace.lost`, or the
200
+ * Harness restart that ends the Turn — ADR-0021).
157
201
  * Nothing is admitted yet here, so there is no turn for a lease to be about: an address that
158
202
  * never answers is a fault this call has to name itself.
203
+ *
204
+ * A draining Harness's 503 is re-sent on the same ladder (ADR-0031): the Harness answers it
205
+ * before it reads the body, so it queued nothing, and it closes the connection with it, so the
206
+ * next attempt dials the Service again — which reaches the replacement once it serves. It is the
207
+ * one answer re-sent: every other status is the Harness's word about this admission.
159
208
  */
160
209
  const postAdmission = async (url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> => {
161
210
  const startedAt = Date.now();
@@ -165,12 +214,9 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
165
214
  let lastCode = "?";
166
215
  for (;;) {
167
216
  attempts += 1;
217
+ let res: Response;
168
218
  try {
169
- const res = await fetchImpl(url, init);
170
- // Only when it actually cost something. A window that is never approached should be silent,
171
- // so that a line appearing at all is already the signal (see `routabilityLine`).
172
- if (attempts > 1) log(routabilityLine("admission", url, attempts, Date.now() - startedAt, lastCode));
173
- return res;
219
+ res = await fetchImpl(url, init);
174
220
  } catch (err) {
175
221
  lastCode = transportCode(err);
176
222
  // A local abandon propagates untranslated, exactly as in `wait` — the stopped actor
@@ -186,7 +232,19 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
186
232
  }
187
233
  await sleep(jittered(backoffMs), signal);
188
234
  backoffMs = Math.min(backoffMs * 2, backoffMaxMs);
235
+ continue;
236
+ }
237
+ if (res.status === DRAINING && Date.now() < deadline) {
238
+ lastCode = `HTTP${DRAINING}`;
239
+ await res.body?.cancel();
240
+ await sleep(jittered(backoffMs), signal);
241
+ backoffMs = Math.min(backoffMs * 2, backoffMaxMs);
242
+ continue;
189
243
  }
244
+ // Only when it actually cost something. A window that is never approached should be silent,
245
+ // so that a line appearing at all is already the signal (see `routabilityLine`).
246
+ if (attempts > 1) log(routabilityLine("admission", url, attempts, Date.now() - startedAt, lastCode));
247
+ return res;
190
248
  }
191
249
  };
192
250
 
@@ -215,6 +273,9 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
215
273
  // Omitted when unset, so an admission with no dials is byte-identical to before.
216
274
  ...(sendOptions.model ? { model: sendOptions.model } : {}),
217
275
  ...(sendOptions.thinkingLevel ? { thinkingLevel: sendOptions.thinkingLevel } : {}),
276
+ // The Briefing's news (ADR-0062): typed data, the Harness writes the words. Omitted
277
+ // when there is none, so an admission without notices is byte-identical to before.
278
+ ...(sendOptions.notices?.length ? { notices: sendOptions.notices } : {}),
218
279
  }),
219
280
  signal: sendOptions.signal,
220
281
  },
@@ -247,7 +308,9 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
247
308
  if (res.status === 404) {
248
309
  throw new SettlementFault(
249
310
  `conversation lost: the harness answered 404 for submission "${admission.submissionId}" — ` +
250
- `a conversation lives as long as its Harness process (ADR-0027)`,
311
+ `the Harness no longer holds this conversation (ADR-0027, ADR-0031)`,
312
+ undefined,
313
+ { lost: true },
251
314
  );
252
315
  }
253
316
  // Refused, not unreachable: the bearer is derived, so a Harness that rejects it now
@@ -276,6 +339,11 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
276
339
  backoffMs = backoffInitialMs;
277
340
 
278
341
  for (const event of events) {
342
+ if (event.type === "memory-limit") {
343
+ const { peak, limit, conversationId, position } = event;
344
+ opts?.onMemoryLimit?.({ peak, limit, source: `${conversationId}@${position.batch}.${position.index}` });
345
+ continue;
346
+ }
279
347
  if (event.type !== "submission-settled" || event.submissionId !== admission.submissionId) continue;
280
348
  if (event.outcome === "completed") return;
281
349
  throw new SettlementFault(faultMessage(event), toSettlement(event));
@@ -284,6 +352,20 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
284
352
  }
285
353
  },
286
354
 
355
+ async holds(agentName, instanceId, opts) {
356
+ const url = new URL(conversationUrl(agentName, instanceId));
357
+ url.searchParams.set("view", VIEW_HISTORY);
358
+ try {
359
+ const res = await fetchImpl(url.toString(), { headers: authorization, signal: opts?.signal });
360
+ // Drain the body so the socket is released; the snapshot itself is not the question.
361
+ await res.body?.cancel().catch(() => undefined);
362
+ return res.status !== 404;
363
+ } catch (err) {
364
+ if (opts?.signal?.aborted) throw err;
365
+ return true;
366
+ }
367
+ },
368
+
287
369
  async abort(agentName, instanceId, opts) {
288
370
  const res = await fetchImpl(`${conversationUrl(agentName, instanceId)}/abort`, {
289
371
  method: "POST",
@@ -324,12 +406,17 @@ export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
324
406
  definition: opts.definition,
325
407
  model: input.model,
326
408
  thinkingLevel: input.thinkingLevel,
409
+ ...(opts.notices?.length ? { notices: opts.notices } : {}),
327
410
  signal: opts.signal,
328
411
  });
329
412
  },
330
413
 
331
- async settle(admission: AgentAdmission, opts?: { signal?: AbortSignal }): Promise<void> {
332
- await client.wait(admission, { signal: opts?.signal });
414
+ async settle(admission: AgentAdmission, opts?: AgentSettleOptions): Promise<void> {
415
+ await client.wait(admission, opts);
416
+ },
417
+
418
+ async holds(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<boolean> {
419
+ return await client.holds(agentName, instanceId, opts);
333
420
  },
334
421
 
335
422
  async abort(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<void> {
@@ -386,6 +473,40 @@ export function createEchoPush(options: {
386
473
  };
387
474
  }
388
475
 
476
+ /**
477
+ * Build the live-set statement for the Instance Harness (ADR-0031): `PUT /agents
478
+ * { live: [{ agent, instanceId }] }` → `{ freed }`, and the Harness frees memory and files of every
479
+ * conversation not named. The whole set every time — level-triggered, so a missed statement costs
480
+ * nothing and the next one says it all again. Un-retried, like the echo: the caller is a timer.
481
+ */
482
+ export function createLiveSetPush(options: {
483
+ baseUrl: string;
484
+ /** The Instance Harness's bearer (ADR-0058). Absent only against the stub Harness. */
485
+ token?: string;
486
+ /** Injectable for socket-free tests. Default: global `fetch`. */
487
+ fetch?: typeof fetch;
488
+ }): (live: LiveConversation[]) => Promise<LiveSetResponse> {
489
+ const fetchImpl = options.fetch ?? fetch;
490
+ return async (live) => {
491
+ const url = new URL("/agents", options.baseUrl).toString();
492
+ const res = await fetchImpl(url, {
493
+ method: "PUT",
494
+ headers: {
495
+ "content-type": "application/json",
496
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
497
+ },
498
+ body: JSON.stringify({ live } satisfies LiveSetRequest),
499
+ }).catch((err: unknown) => {
500
+ throw new Error(`live-set statement to ${url} failed: ${transportDetail(err)}`, { cause: err });
501
+ });
502
+ if (!res.ok) {
503
+ throw new Error(`live-set statement failed (${res.status}): ${await errorDetail(res)}`);
504
+ }
505
+ const { freed } = (await res.json().catch(() => ({}))) as Partial<Record<keyof LiveSetResponse, unknown>>;
506
+ return { freed: typeof freed === "number" ? freed : 0 };
507
+ };
508
+ }
509
+
389
510
  /**
390
511
  * THE authoring surface for an Agent (ADR-0049): one definition in, one actor slot out —
391
512
  * `jr2Setup({ actors: { coder: agent({ model, instructions }) } })`, invoked as `src: "coder"`.
@@ -409,6 +530,107 @@ export function agent(declaration: AgentDeclaration): AgentLogic {
409
530
  );
410
531
  }
411
532
 
533
+ /**
534
+ * The Harness's exec (ADR-0067), as a `command` Effect drives it: `POST /exec/:id` to start,
535
+ * `GET /exec/:id` long-polled by offset, `POST /exec/:id/kill` when the Effect's state exits (sent
536
+ * again until the Harness keeps it), `DELETE /exec/:id` once the output is ledgered. A start that
537
+ * never reached the wire rejects `ExecNotSent`, the one start failure sent again: a Harness runs one
538
+ * id once only within its own process, so a start that reached one that then restarted would run
539
+ * twice. A read's transport failure or 5xx rejects, and the Effect asks again; a 404 on a read is
540
+ * an exec the Harness does not know (it restarted); any other 4xx is a jr2 defect, never retried.
541
+ */
542
+ export function createExecPort(options: {
543
+ baseUrl: string;
544
+ /** The Workspace's Harness bearer (ADR-0058). Absent only against a Harness that checks nothing. */
545
+ token?: string;
546
+ /** Injectable for socket-free tests. Default: global `fetch`. */
547
+ fetch?: typeof fetch;
548
+ }): ExecPort {
549
+ const fetchImpl = options.fetch ?? fetch;
550
+ const headers = {
551
+ "content-type": "application/json",
552
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
553
+ };
554
+ const url = (id: string, suffix = "") => new URL(`/exec/${encodeURIComponent(id)}${suffix}`, options.baseUrl);
555
+ const call = async (target: URL, init: RequestInit): Promise<Response> =>
556
+ fetchImpl(target.toString(), { ...init, headers }).catch((err: unknown) => {
557
+ throw new Error(`harness exec ${init.method ?? "GET"} ${target.pathname} failed: ${transportDetail(err)}`, {
558
+ cause: err,
559
+ });
560
+ });
561
+ const refusedOrRetry = async (res: Response, what: string): Promise<never> => {
562
+ const detail = await errorDetail(res);
563
+ if (res.status >= 400 && res.status < 500)
564
+ throw new ExecRefused(`harness refused the ${what} (${res.status}): ${detail}`);
565
+ throw new Error(`harness ${what} failed (${res.status}): ${detail}`);
566
+ };
567
+ return {
568
+ start: async (id, req: ExecRequest, signal) => {
569
+ const res = await call(url(id), { method: "POST", body: JSON.stringify(req), signal }).catch((err: unknown) => {
570
+ // Only a request that never reached the wire is safe to send again (see `startOnce`).
571
+ if (!signal.aborted && neverDelivered(err)) throw new ExecNotSent((err as Error).message, { cause: err });
572
+ throw err;
573
+ });
574
+ if (!res.ok) return refusedOrRetry(res, "exec");
575
+ await res.json().catch(() => undefined);
576
+ },
577
+ read: async (id, offset, signal) => {
578
+ const target = url(id);
579
+ if (offset !== undefined) target.searchParams.set("offset", String(offset));
580
+ target.searchParams.set("live", LIVE_LONG_POLL);
581
+ const res = await call(target, { method: "GET", signal });
582
+ if (res.status === 404) {
583
+ await res.body?.cancel();
584
+ return undefined;
585
+ }
586
+ if (!res.ok) return refusedOrRetry(res, "exec read");
587
+ return (await res.json()) as ExecRead;
588
+ },
589
+ kill: async (id) => {
590
+ // Not kept until the Harness answers 2xx: the Effect sends it again until then.
591
+ const res = await call(url(id, "/kill"), { method: "POST" });
592
+ if (!res.ok) return refusedOrRetry(res, "exec kill");
593
+ await res.body?.cancel();
594
+ },
595
+ forget: async (id) => {
596
+ const res = await call(url(id), { method: "DELETE" });
597
+ await res.body?.cancel();
598
+ },
599
+ };
600
+ }
601
+
602
+ /**
603
+ * THE authoring surface for a `command` Effect (ADR-0067): `actors: { tests: command(["pnpm",
604
+ * "test"]) }`, invoked as `src: "tests"`. `argv` is never shell text — an `arg("x")` entry is ONE
605
+ * argument, filled from the invoke's input — and `sh -c` is the author's explicit choice. It runs
606
+ * in the Harness container of the enclosing Workspace's Sandbox, in a Repo Slot's Worktree
607
+ * (`slot`, needed only where the Workspace carries several); outside a Workspace it is refused.
608
+ * Every outcome is output: `exited | timeout | killed | unknown`.
609
+ */
610
+ export function command<const Opt extends CommandOptions = CommandOptions>(
611
+ argv: typeof open,
612
+ options?: Opt,
613
+ ): EffectLogic<OpenCommandInput<Opt["pin"]>, CommandOutput> & OpenEffect<"argv" | EnvOpenParts<Opt>>;
614
+ export function command<const A extends readonly (string | Arg)[], const Opt extends CommandOptions = CommandOptions>(
615
+ argv: A,
616
+ options?: Opt,
617
+ ): EffectLogic<CommandInput<A, Opt["pin"]>, CommandOutput> & OpenOf<EnvOpenParts<Opt>>;
618
+ export function command(
619
+ argv: readonly (string | Arg)[] | typeof open,
620
+ options: CommandOptions = {},
621
+ // The implementation of the overloads above: each one states the input type.
622
+ ): EffectLogic<any, CommandOutput> {
623
+ // An Open argv (ADR-0054) is the composer's to bind with `customize()`; see open-effect.ts.
624
+ return commandWith(
625
+ (endpoint, bearer) => createExecPort({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
626
+ argv as readonly (string | Arg)[],
627
+ options,
628
+ );
629
+ }
630
+
631
+ /** What a draining Harness answers an admission (ADR-0031): refused before anything is queued. */
632
+ const DRAINING = 503;
633
+
412
634
  /** A stream read worth retrying (server hiccup) — internal to the reconnect loop, never thrown out. */
413
635
  class ReconnectableError extends Error {}
414
636
 
@@ -1,4 +1,6 @@
1
- // Held secrets (ADR-0059): credentials the Harness uses and never holds.
1
+ // Held secrets (ADR-0059): credentials the Harness uses and never holds — the entries of the
2
+ // `secrets` list that a Harness pod claims: a `workspace()` lists it under `secrets`, or a provider
3
+ // an Agent there names reads it (secret-placement.ts places each one, ADR-0068).
2
4
  //
3
5
  // The Agent executes code in the Harness container (ADR-0005), so a key in that container's env is
4
6
  // a key it can take out of the pod. A held secret is not there: the Harness's env carries a
@@ -8,8 +10,9 @@
8
10
  // This module is the pure half, shared by every seat that needs it:
9
11
  //
10
12
  // standIn(name) the value the Harness sees — deterministic and public
11
- // heldSecretsOf(...) `jr2 up`'s normalization: every refusal (R1–R13), every warning, and the
12
- // engine-neutral manifest (`held.json`) every composition reads
13
+ // heldSecretsOf(...) `jr2 up`'s normalization of the claimed entries: every refusal (R1–R13),
14
+ // every warning, and the engine-neutral bindings each pod's manifest
15
+ // (`held-<pod>.json`) takes its own from
13
16
  //
14
17
  // It runs HOST-SIDE, in `jr2 up`, because only there does the config carry values: in-cluster,
15
18
  // `process.env` has no `.env`, so a `value` reads `undefined` and a `hosts` entry built from `.env`
@@ -21,11 +24,50 @@ import { readFile } from "node:fs/promises";
21
24
  import { isIP } from "node:net";
22
25
  import type { JR2Config, HarnessEnvVar } from "./config.ts";
23
26
  import { MODEL_KEYS, MODEL_KEY_PROVIDERS, SIGNING_CREDENTIALS } from "./model-keys.ts";
24
- import { HELD_CA_SECRET, HELD_SECRETS_SECRET, HELD_TLS_SECRET, INSTANCE_SECRET } from "./names.ts";
27
+ import { HELD_CA_SECRET, HELD_SECRETS_SECRET, HELD_TLS_SECRET, INSTANCE_EFFECTOR, INSTANCE_SECRET } from "./names.ts";
28
+ import type { PlacementRoute } from "./parts.ts";
29
+
30
+ /** The kit's Secrets no Harness container may read (R13): the Instance token and signing key, each
31
+ * pod's literal held values, the leaves, the CA, and every Effector's (ADR-0068) — the Instance
32
+ * Effector's Effect secrets and key, each Workspace's Sandbox Effector Secret (`jr2-effector-<pod>`),
33
+ * each Sandbox's Effector key (`<sandbox>-effector`), and each `git.credentials` entry's clone and
34
+ * push Secrets (`jr2-git-<hash>`, `jr2-git-push-<hash>`, ADR-0051). */
35
+ const KIT_SECRETS: readonly string[] = [INSTANCE_SECRET, HELD_TLS_SECRET, HELD_CA_SECRET, INSTANCE_EFFECTOR];
36
+ const KIT_SECRET_PREFIXES: readonly string[] = [`${HELD_SECRETS_SECRET}-`, "jr2-effector-", "jr2-git-"];
37
+ const isKitSecret = (name: string): boolean =>
38
+ KIT_SECRETS.includes(name) ||
39
+ name === HELD_SECRETS_SECRET ||
40
+ KIT_SECRET_PREFIXES.some((p) => name.startsWith(p)) ||
41
+ // A Sandbox's own Effector key: every Sandbox's name starts `ws-` (`workspaceName`).
42
+ (name.startsWith("ws-") && name.endsWith("-effector"));
25
43
 
26
- /** The kit's Secrets no Harness container may read (R13): the Instance token and signing key, the
27
- * literal held values, the leaves, and the CA. */
28
- const KIT_SECRETS: readonly string[] = [INSTANCE_SECRET, HELD_SECRETS_SECRET, HELD_TLS_SECRET, HELD_CA_SECRET];
44
+ /**
45
+ * The pod key of one Workspace (ADR-0059): its registered workflow and the placement route to its
46
+ * `workspace()` (`PlacementRoute`) — what `jr2 up` reads off the walk and the run host off the live
47
+ * actor (`actorRoute`), so both name one pod alike. Every Workspace has a route of its own, an
48
+ * inline-invoked one included, so two Workspaces never share a key. A digest, so it is a name a
49
+ * Secret and a ConfigMap key can carry; `ws-` keeps it apart from the Instance Harness's.
50
+ */
51
+ export function workspaceHeldPod(at: { workflow: string; route: PlacementRoute }): string {
52
+ const key = JSON.stringify([at.workflow, at.route]);
53
+ return `ws-${createHash("sha256").update(key).digest("hex").slice(0, 16)}`;
54
+ }
55
+
56
+ /**
57
+ * The names a provider claims for a pod (ADR-0059): the `secrets` entries that are a known model key
58
+ * the provider of one of `models` reads, and the custom provider's key when an Agent there names
59
+ * that provider. A model key is claimed by the PROVIDER — the Agent names its model, jr2 knows which
60
+ * key that provider reads — so it is not listed under a `workspace()`'s `secrets`.
61
+ */
62
+ export function providerClaims(config: JR2Config, models: readonly string[]): string[] {
63
+ const providers = new Set(models.map((m) => m.split("/")[0] ?? ""));
64
+ const claims = (config.secrets ?? [])
65
+ .filter((s) => MODEL_KEYS[s.name]?.providers.some((p) => providers.has(p)))
66
+ .map((s) => s.name);
67
+ const custom = config.harness?.provider;
68
+ if (custom?.apiKey && providers.has(custom.id)) claims.push(PROVIDER_KEY_NAME);
69
+ return claims;
70
+ }
29
71
 
30
72
  /**
31
73
  * The Stand-in for one held secret: what its env var holds in the Harness container.
@@ -79,7 +121,7 @@ export const MECHANISM_ENV: readonly string[] = [
79
121
  "GIT_SSL_CAINFO",
80
122
  ];
81
123
 
82
- /** What `jr2 up` resolved; what every composition reads (`held.json` in the `jr2-held`
124
+ /** What `jr2 up` placed in one pod; what its composition reads (`held-<pod>.json` in the `jr2-held`
83
125
  * ConfigMap). Values never appear here. */
84
126
  export type HeldManifest = { version: 1; secrets: HeldBinding[] };
85
127
 
@@ -87,7 +129,7 @@ export type HeldManifest = { version: 1; secrets: HeldBinding[] };
87
129
  export type HeldBinding = {
88
130
  /** The env var; the Harness sees `standIn(name)`. */
89
131
  name: string;
90
- /** A literal lands in the `jr2-held-secrets` Secret under `name`; a `secret` is the user's own
132
+ /** A literal lands in its pod's `jr2-held-secrets-<pod>` Secret under `name`; a `secret` is the user's own
91
133
  * Secret, mounted by reference so a rotation reaches new pods without a `jr2 up`. */
92
134
  source: { kind: "literal" } | { kind: "secret"; secret: string; key: string };
93
135
  /** `leaf` is the file stem of this host's certificate in `jr2-held-tls` (`leafStem`). */
@@ -118,13 +160,18 @@ export type HeldLookups = {
118
160
  /** The key names of a Secret a `valueFrom.secretKeyRef` names, or undefined when it does not
119
161
  * exist in the namespace (R10). */
120
162
  secretKeys?: (name: string) => readonly string[] | undefined;
163
+ /** The length in characters of one key's value in a Secret a `valueFrom.secretKeyRef` names —
164
+ * never the value — or undefined when it is not known. An Effect secret shorter than 8 characters
165
+ * is refused by it as a literal is (ADR-0068). */
166
+ valueLength?: (name: string, key: string) => number | undefined;
121
167
  /** Every model specifier a carried Agent definition names (`anthropic/claude-sonnet-4-6`) — R9's
122
168
  * evidence that a provider's turns will reach its host. */
123
169
  models?: readonly string[];
124
170
  };
125
171
 
126
- /** `heldSecretsOf`'s answer: the manifest, the literal values (host-side only — they go into the
127
- * `jr2-held-secrets` Secret and nowhere else), and what `jr2 up` should warn about. */
172
+ /** `heldSecretsOf`'s answer: every claimed binding, the literal values (host-side only — each goes
173
+ * into the `jr2-held-secrets-<pod>` Secret of a pod that claims it and nowhere else), and what
174
+ * `jr2 up` should warn about. */
128
175
  export type HeldResolution = {
129
176
  manifest: HeldManifest;
130
177
  values: Record<string, string>;
@@ -146,11 +193,17 @@ const FRAMING_HEADERS = new Set([
146
193
  ]);
147
194
 
148
195
  /**
149
- * Normalize `harness.heldSecrets` (plus the implicit `JR2_PROVIDER_API_KEY`) into the manifest,
150
- * or throw the FIRST refusal. Every message names the config path and never a value, and says
151
- * what to write instead — the `checkGit` style.
196
+ * Normalize the Held secrets — the entries of the `secrets` list some pod claims, and the implicit
197
+ * `JR2_PROVIDER_API_KEY` when one claims it (`claimed`, from secret-placement.ts) — into bindings, or
198
+ * throw the FIRST refusal. Every message names the config path and never a value, and says what to
199
+ * write instead — the `checkGit` style. An entry no pod claims is not this function's: `hosts`,
200
+ * `headers` and `paths` are the binding, never the placement, so R5 fires only for a claimed one.
152
201
  */
153
- export function heldSecretsOf(config: JR2Config, lookups: HeldLookups = {}): HeldResolution {
202
+ export function heldSecretsOf(
203
+ config: JR2Config,
204
+ claimed: ReadonlySet<string>,
205
+ lookups: HeldLookups = {},
206
+ ): HeldResolution {
154
207
  const harness = config.harness ?? {};
155
208
  const warnings: string[] = [];
156
209
  const env = harness.env ?? [];
@@ -185,7 +238,7 @@ export function heldSecretsOf(config: JR2Config, lookups: HeldLookups = {}): Hel
185
238
  if (!(v.name in MODEL_KEYS)) continue;
186
239
  throw new Error(
187
240
  `harness.env[${i}] sets ${v.name}, a model key the Agent could read and take out of the pod — hold it ` +
188
- `instead: heldSecrets: [${heldLine(v)}] (ADR-0059)`,
241
+ `instead: secrets: [${heldLine(v)}] (ADR-0059)`,
189
242
  );
190
243
  }
191
244
  for (const [i, ref] of (harness.envFrom ?? []).entries()) {
@@ -201,7 +254,7 @@ export function heldSecretsOf(config: JR2Config, lookups: HeldLookups = {}): Hel
201
254
  const where = ref.secretRef ? `Secret "${ref.secretRef.name}"` : `ConfigMap "${ref.configMapRef?.name}"`;
202
255
  throw new Error(
203
256
  `harness.envFrom[${i}] loads ${where}, which carries ${known} — a model key the Agent could read. Hold it ` +
204
- `instead: heldSecrets: [{ name: "${known}", valueFrom: { secretKeyRef: { name: "${ref.secretRef?.name ?? "<secret>"}", key: "${known}" } } }], ` +
257
+ `instead: secrets: [{ name: "${known}", valueFrom: { secretKeyRef: { name: "${ref.secretRef?.name ?? "<secret>"}", key: "${known}" } } }], ` +
205
258
  `and drop the key from that ${ref.secretRef ? "Secret" : "ConfigMap"} (ADR-0059)`,
206
259
  );
207
260
  }
@@ -224,7 +277,9 @@ export function heldSecretsOf(config: JR2Config, lookups: HeldLookups = {}): Hel
224
277
  headers?: readonly string[];
225
278
  paths?: readonly string[];
226
279
  };
227
- const entries: Entry[] = (harness.heldSecrets ?? []).map((s, i) => ({ at: `harness.heldSecrets[${i}]`, ...s }));
280
+ const entries: Entry[] = (config.secrets ?? []).flatMap((s, i) =>
281
+ claimed.has(s.name) ? [{ at: `secrets[${i}]`, ...s }] : [],
282
+ );
228
283
 
229
284
  // R2, for the user's own entries — before the implicit one joins them.
230
285
  for (const e of entries) {
@@ -241,7 +296,7 @@ export function heldSecretsOf(config: JR2Config, lookups: HeldLookups = {}): Hel
241
296
 
242
297
  // The custom provider's key: held since ADR-0059, bound to its own endpoint.
243
298
  const provider = harness.provider;
244
- if (provider?.apiKey) {
299
+ if (provider?.apiKey && claimed.has(PROVIDER_KEY_NAME)) {
245
300
  const target = httpsTarget(provider.baseUrl);
246
301
  if (!target) {
247
302
  throw new Error(
@@ -335,7 +390,7 @@ export function heldSecretsOf(config: JR2Config, lookups: HeldLookups = {}): Hel
335
390
  )?.name;
336
391
  for (const [i, v] of env.entries()) {
337
392
  const ref = (v.valueFrom as { secretKeyRef?: { name?: string; key?: string } } | undefined)?.secretKeyRef;
338
- if (ref?.name !== undefined && KIT_SECRETS.includes(ref.name)) {
393
+ if (ref?.name !== undefined && isKitSecret(ref.name)) {
339
394
  throw new Error(
340
395
  `harness.env[${i}] (${v.name}) reads Secret "${ref.name}", which is the kit's and never the Harness's — the ` +
341
396
  "Agent would read it. Drop the entry (ADR-0059)",
@@ -359,7 +414,7 @@ export function heldSecretsOf(config: JR2Config, lookups: HeldLookups = {}): Hel
359
414
  for (const [i, ref] of (harness.envFrom ?? []).entries()) {
360
415
  const secret = ref.secretRef?.name;
361
416
  if (secret === undefined) continue;
362
- if (KIT_SECRETS.includes(secret)) {
417
+ if (isKitSecret(secret)) {
363
418
  throw new Error(
364
419
  `harness.envFrom[${i}] loads Secret "${secret}", which is the kit's and never the Harness's — the Agent ` +
365
420
  "would read every key of it. Drop the entry (ADR-0059)",
@@ -396,7 +451,7 @@ export function heldSecretsOf(config: JR2Config, lookups: HeldLookups = {}): Hel
396
451
  }
397
452
 
398
453
  /** R4 and R10: exactly one source, and a literal that can ride a header. */
399
- function sourceOf(
454
+ export function sourceOf(
400
455
  e: { at: string; name: string; value?: string; valueFrom?: { secretKeyRef: { name: string; key: string } } },
401
456
  lookups: HeldLookups,
402
457
  ): HeldBinding["source"] {
@@ -539,7 +594,7 @@ export function normalHost(raw: string): string {
539
594
  return bare;
540
595
  }
541
596
 
542
- /** The `heldSecrets` line R1 hands back, built from the entry it refuses. */
597
+ /** The `secrets` line R1 hands back, built from the entry it refuses. */
543
598
  function heldLine(v: HarnessEnvVar): string {
544
599
  const ref = (v.valueFrom as { secretKeyRef?: { name?: string; key?: string } } | undefined)?.secretKeyRef;
545
600
  if (ref?.name && ref.key) {
@@ -549,7 +604,8 @@ function heldLine(v: HarnessEnvVar): string {
549
604
  }
550
605
 
551
606
  /**
552
- * Read the manifest `jr2 up` wrote (`held.json`, mounted from the `jr2-held` ConfigMap), per call —
607
+ * Read the manifest `jr2 up` wrote for one pod (`held-<pod>.json`, mounted from the `jr2-held`
608
+ * ConfigMap), per call —
553
609
  * the `readImageRefs` stance (ADR-0038): no cache, so a `jr2 up` that changes a held secret reaches
554
610
  * the next provision without rolling the Orchestrator. Absent or malformed is a pointed error naming
555
611
  * `jr2 up`, never "nothing held": a Sandbox composed on a guess would put a key where the converge
@@ -562,8 +618,8 @@ export async function readHeldManifest(path: string): Promise<HeldManifest> {
562
618
  } catch (err) {
563
619
  throw new Error(
564
620
  `no held-secret manifest at ${path} (${(err as NodeJS.ErrnoException).code ?? "read failed"}) — every ` +
565
- "Harness pod's Custodian is composed from the `jr2-held` ConfigMap, which `jr2 up` writes (ADR-0059). " +
566
- "Converge this instance with `jr2 up`.",
621
+ "Harness pod's Custodian is composed from its own manifest in the `jr2-held` ConfigMap, which `jr2 up` " +
622
+ "writes for each Workspace it walked (ADR-0059). Converge this instance with `jr2 up`.",
567
623
  );
568
624
  }
569
625
  const bad = (why: string): never => {