@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.
- 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 -5
- package/src/actor.ts +507 -54
- package/src/agent.ts +2 -2
- 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 +233 -22
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +313 -0
- package/src/custodian.ts +951 -0
- 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 +247 -25
- package/src/held-secrets.ts +643 -0
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +514 -119
- package/src/images.ts +58 -31
- package/src/index.ts +49 -4
- package/src/instance.ts +445 -59
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/model-keys.ts +92 -0
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +89 -7
- 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 +61 -86
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1841 -105
- 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 +285 -89
- 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 +44 -12
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1144
package/src/harness-client.ts
CHANGED
|
@@ -10,20 +10,23 @@
|
|
|
10
10
|
// socket-free through an injected `fetch`.
|
|
11
11
|
//
|
|
12
12
|
// Channel split (ADR-0002, refined by ADR-0016): the Harness wire carries **lifecycle only** —
|
|
13
|
-
// domain events go up the
|
|
13
|
+
// domain events go up the Menu, through the pod's Custodian. The client's `send`/`wait` pair is exactly
|
|
14
14
|
// that lifecycle surface: `send` answers with a serializable Admission
|
|
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
|
|
|
@@ -477,7 +699,7 @@ async function errorDetail(res: Response): Promise<string> {
|
|
|
477
699
|
|
|
478
700
|
/**
|
|
479
701
|
* The marker the `@kind` tier greps for, and therefore a CONTRACT — duplicated verbatim in
|
|
480
|
-
* `@jr2/
|
|
702
|
+
* `@jr2/harness`'s Menu (the two packages share no runtime dependency) and matched in
|
|
481
703
|
* `features/steps/kind.steps.ts`. Renaming it on one side does not break a build; it silently turns
|
|
482
704
|
* the tier's routability budget into a check that passes because it matches nothing.
|
|
483
705
|
*/
|