@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.
- package/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +10 -1
- package/console/components/fleet.ts +39 -6
- package/console/components/machine-pane.ts +89 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +110 -39
- package/console/store.ts +221 -17
- package/console/style.css +90 -0
- package/package.json +4 -4
- package/src/actor.ts +502 -49
- package/src/ambient.ts +2 -0
- package/src/assembly-checks.ts +165 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/chart.ts +2050 -0
- package/src/charts.ts +720 -0
- package/src/config.ts +152 -46
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +6 -5
- package/src/custodian.ts +71 -19
- package/src/customize.ts +216 -25
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +905 -0
- package/src/effector-wire.ts +525 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +245 -23
- package/src/held-secrets.ts +82 -26
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +496 -104
- package/src/images.ts +47 -21
- package/src/index.ts +43 -2
- package/src/instance.ts +444 -58
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +66 -8
- package/src/notices.ts +88 -0
- package/src/open-body.ts +1444 -0
- package/src/open-effect.ts +144 -0
- package/src/open.ts +4 -4
- package/src/parts.ts +530 -33
- package/src/party.ts +468 -0
- package/src/pick.ts +309 -0
- package/src/pool.ts +1 -1
- package/src/registration.ts +301 -17
- package/src/repo-fetch.ts +60 -85
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1832 -98
- package/src/sandbox-effect.ts +891 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1719 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +494 -0
- package/src/server.ts +281 -84
- package/src/setup.ts +589 -70
- package/src/size.ts +342 -0
- package/src/snapshot-store.ts +64 -13
- package/src/stub-harness.ts +5 -0
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +178 -0
- package/src/tokens.ts +35 -4
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1108
package/src/harness-client.ts
CHANGED
|
@@ -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
|
|
19
|
-
// `workspace.lost`'s
|
|
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:
|
|
22
|
-
//
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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?:
|
|
332
|
-
await client.wait(admission,
|
|
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
|
|
package/src/held-secrets.ts
CHANGED
|
@@ -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),
|
|
12
|
-
// engine-neutral
|
|
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
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
|
|
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`
|
|
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
|
|
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:
|
|
127
|
-
* `jr2-held-secrets
|
|
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
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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(
|
|
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:
|
|
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:
|
|
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[] = (
|
|
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 &&
|
|
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 (
|
|
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 `
|
|
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
|
|
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`
|
|
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 => {
|