@boardwalk-labs/runner 0.1.2
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/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +214 -0
- package/dist/contract.d.ts +154 -0
- package/dist/contract.js +196 -0
- package/dist/daemon/daemon.d.ts +45 -0
- package/dist/daemon/daemon.js +129 -0
- package/dist/daemon/identity.d.ts +15 -0
- package/dist/daemon/identity.js +44 -0
- package/dist/daemon/index.d.ts +3 -0
- package/dist/daemon/index.js +4 -0
- package/dist/daemon/pool_client.d.ts +31 -0
- package/dist/daemon/pool_client.js +101 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/runtime/agent/budget.d.ts +123 -0
- package/dist/runtime/agent/budget.js +174 -0
- package/dist/runtime/agent/events.d.ts +46 -0
- package/dist/runtime/agent/events.js +8 -0
- package/dist/runtime/agent/model_rates.d.ts +7 -0
- package/dist/runtime/agent/model_rates.js +21 -0
- package/dist/runtime/agent/secret_redactor.d.ts +42 -0
- package/dist/runtime/agent/secret_redactor.js +98 -0
- package/dist/runtime/broker_artifact_store.d.ts +17 -0
- package/dist/runtime/broker_artifact_store.js +46 -0
- package/dist/runtime/broker_child_dispatcher.d.ts +31 -0
- package/dist/runtime/broker_child_dispatcher.js +88 -0
- package/dist/runtime/broker_event_publisher.d.ts +25 -0
- package/dist/runtime/broker_event_publisher.js +69 -0
- package/dist/runtime/broker_tool_host.d.ts +49 -0
- package/dist/runtime/broker_tool_host.js +240 -0
- package/dist/runtime/cancel_watcher.d.ts +31 -0
- package/dist/runtime/cancel_watcher.js +77 -0
- package/dist/runtime/credit_watcher.d.ts +30 -0
- package/dist/runtime/credit_watcher.js +78 -0
- package/dist/runtime/direct_inference.d.ts +29 -0
- package/dist/runtime/direct_inference.js +78 -0
- package/dist/runtime/index.d.ts +85 -0
- package/dist/runtime/index.js +525 -0
- package/dist/runtime/inference_transport.d.ts +5 -0
- package/dist/runtime/inference_transport.js +9 -0
- package/dist/runtime/leaf_executor.d.ts +104 -0
- package/dist/runtime/leaf_executor.js +333 -0
- package/dist/runtime/lease_renewer.d.ts +31 -0
- package/dist/runtime/lease_renewer.js +80 -0
- package/dist/runtime/main.d.ts +1 -0
- package/dist/runtime/main.js +7 -0
- package/dist/runtime/phase_tracker.d.ts +26 -0
- package/dist/runtime/phase_tracker.js +45 -0
- package/dist/runtime/program_log_capture.d.ts +20 -0
- package/dist/runtime/program_log_capture.js +79 -0
- package/dist/runtime/program_runner.d.ts +82 -0
- package/dist/runtime/program_runner.js +116 -0
- package/dist/runtime/program_worker.d.ts +177 -0
- package/dist/runtime/program_worker.js +272 -0
- package/dist/runtime/recording_secret_resolver.d.ts +9 -0
- package/dist/runtime/recording_secret_resolver.js +22 -0
- package/dist/runtime/run_abort.d.ts +16 -0
- package/dist/runtime/run_abort.js +38 -0
- package/dist/runtime/run_event_emitter.d.ts +24 -0
- package/dist/runtime/run_event_emitter.js +63 -0
- package/dist/runtime/runner_control_client.d.ts +184 -0
- package/dist/runtime/runner_control_client.js +501 -0
- package/dist/runtime/runtime_flusher.d.ts +41 -0
- package/dist/runtime/runtime_flusher.js +90 -0
- package/dist/runtime/sandbox_config.d.ts +21 -0
- package/dist/runtime/sandbox_config.js +34 -0
- package/dist/runtime/support/index.d.ts +53 -0
- package/dist/runtime/support/index.js +114 -0
- package/dist/runtime/suspension.d.ts +141 -0
- package/dist/runtime/suspension.js +120 -0
- package/dist/runtime/testing_artifact_build.d.ts +27 -0
- package/dist/runtime/testing_artifact_build.js +96 -0
- package/dist/runtime/tools/artifacts.d.ts +146 -0
- package/dist/runtime/tools/artifacts.js +125 -0
- package/dist/runtime/tools/types.d.ts +114 -0
- package/dist/runtime/tools/types.js +61 -0
- package/dist/runtime/tools/web_search.d.ts +66 -0
- package/dist/runtime/tools/web_search.js +165 -0
- package/dist/runtime/wire/artifact_storage.d.ts +37 -0
- package/dist/runtime/wire/artifact_storage.js +93 -0
- package/dist/runtime/wire/artifact_verify.d.ts +1 -0
- package/dist/runtime/wire/artifact_verify.js +8 -0
- package/dist/runtime/wire/human_input.d.ts +62 -0
- package/dist/runtime/wire/human_input.js +156 -0
- package/dist/runtime/wire/inference_proxy.d.ts +70 -0
- package/dist/runtime/wire/inference_proxy.js +157 -0
- package/dist/runtime/wire/manifest.d.ts +16 -0
- package/dist/runtime/wire/manifest.js +18 -0
- package/dist/runtime/wire/run.d.ts +54 -0
- package/dist/runtime/wire/run.js +2 -0
- package/dist/runtime/workflow_host.d.ts +194 -0
- package/dist/runtime/workflow_host.js +382 -0
- package/dist/runtime/workspace_store.d.ts +73 -0
- package/dist/runtime/workspace_store.js +118 -0
- package/package.json +70 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// RunnerControlClient — the worker's HTTP client for the Runner Control API (docs/RUNNER_BROKER.md).
|
|
2
|
+
//
|
|
3
|
+
// Under the broker model the worker reaches run lifecycle through the api-server, not the DB directly:
|
|
4
|
+
// it presents its per-run token (BOARDWALK_RUN_TOKEN) as a bearer credential and calls the broker for
|
|
5
|
+
// claim / finalize / version. The client is bound to ONE run (the token is too), so every call targets
|
|
6
|
+
// `…/runner/v1/runs/<runId>/…`.
|
|
7
|
+
//
|
|
8
|
+
// Thin + injectable (fetch is overridable) so it's unit-tested without a live server. Status mapping
|
|
9
|
+
// mirrors the broker handlers: claim 409 ⇒ "claim lost" (null), version 404 ⇒ missing (null); any
|
|
10
|
+
// other non-success status throws so the worker fails loud (→ restart, lease reclaimed).
|
|
11
|
+
import { createLogger } from "./support/index.js";
|
|
12
|
+
import { journalLookupSchema, } from "./suspension.js";
|
|
13
|
+
import { INFERENCE_NDJSON_CONTENT_TYPE, parseInferenceFrame, serializeInferenceRequest, } from "./wire/inference_proxy.js";
|
|
14
|
+
const log = createLogger("RunnerControlClient");
|
|
15
|
+
export class RunnerControlClient {
|
|
16
|
+
cfg;
|
|
17
|
+
base;
|
|
18
|
+
fetchImpl;
|
|
19
|
+
constructor(cfg) {
|
|
20
|
+
this.cfg = cfg;
|
|
21
|
+
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
|
22
|
+
this.fetchImpl = cfg.fetchImpl ?? fetch;
|
|
23
|
+
}
|
|
24
|
+
/** Claim the run's lease. Returns the run on success, or null when it isn't claimable (409 —
|
|
25
|
+
* another worker has it, or it isn't pending), which the worker treats as "claim lost". */
|
|
26
|
+
async claim(workerId, leaseSeconds) {
|
|
27
|
+
const res = await this.fetchImpl(this.url("claim"), {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: this.headers(true),
|
|
30
|
+
body: JSON.stringify({ workerId, leaseSeconds }),
|
|
31
|
+
});
|
|
32
|
+
if (res.status === 409)
|
|
33
|
+
return null;
|
|
34
|
+
if (res.status !== 201)
|
|
35
|
+
throw await brokerError(res, "claim");
|
|
36
|
+
const body = (await res.json());
|
|
37
|
+
return {
|
|
38
|
+
run: body.run,
|
|
39
|
+
lastEventCursor: body.lastEventCursor ?? 0,
|
|
40
|
+
// The replay frontier for silent replay (docs/SUSPENSION.md): the highest journaled seq, so a
|
|
41
|
+
// resumed run knows which seams already ran (suppress their re-emitted observability).
|
|
42
|
+
lastJournalSeq: body.lastJournalSeq ?? 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Heartbeat: extend our lease so a long run isn't reclaimed mid-flight. Returns the new
|
|
46
|
+
* `leaseUntil`, or null when the lease was lost (409 — another worker reclaimed the run), which
|
|
47
|
+
* the worker treats as "stop". */
|
|
48
|
+
async renewLease(workerId, leaseSeconds) {
|
|
49
|
+
const res = await this.fetchImpl(this.url("renew"), {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: this.headers(true),
|
|
52
|
+
body: JSON.stringify({ workerId, leaseSeconds }),
|
|
53
|
+
});
|
|
54
|
+
if (res.status === 409)
|
|
55
|
+
return null;
|
|
56
|
+
if (res.status !== 200)
|
|
57
|
+
throw await brokerError(res, "renew");
|
|
58
|
+
const body = (await res.json());
|
|
59
|
+
return body.leaseUntil;
|
|
60
|
+
}
|
|
61
|
+
/** Mark the run terminal. `workerId` lets the broker reject a finalize from a DISPLACED worker (one
|
|
62
|
+
* whose lease expired and whose run was reclaimed + re-dispatched to a new owner), so a
|
|
63
|
+
* hung/partitioned worker that later recovers can't clobber the live run or revive a terminal one. */
|
|
64
|
+
async finalize(status, output, workerId) {
|
|
65
|
+
const res = await this.fetchImpl(this.url("finalize"), {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: this.headers(true),
|
|
68
|
+
body: JSON.stringify({ status, output, workerId }),
|
|
69
|
+
});
|
|
70
|
+
if (res.status !== 204)
|
|
71
|
+
throw await brokerError(res, "finalize");
|
|
72
|
+
}
|
|
73
|
+
/** Look up a durable-seam journal entry by its seq (docs/SUSPENSION.md), or null on a replay miss
|
|
74
|
+
* (404). The broker joins a parked agent leaf's answers into the result server-side. */
|
|
75
|
+
async journalGet(seq) {
|
|
76
|
+
const res = await this.fetchImpl(this.url(`journal/${encodeURIComponent(String(seq))}`), {
|
|
77
|
+
method: "GET",
|
|
78
|
+
headers: this.headers(false),
|
|
79
|
+
});
|
|
80
|
+
if (res.status === 404)
|
|
81
|
+
return null;
|
|
82
|
+
if (res.status !== 200)
|
|
83
|
+
throw await brokerError(res, "journal-get");
|
|
84
|
+
return journalLookupSchema.parse(await res.json());
|
|
85
|
+
}
|
|
86
|
+
/** Record a RESOLVED seam result (idempotent on the run + seq server-side; a resolved entry is
|
|
87
|
+
* immutable). The broker writes the memoized value the next replay returns. */
|
|
88
|
+
async journalPut(entry) {
|
|
89
|
+
const res = await this.fetchImpl(this.url("journal"), {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: this.headers(true),
|
|
92
|
+
body: JSON.stringify(entry),
|
|
93
|
+
});
|
|
94
|
+
if (res.status !== 204)
|
|
95
|
+
throw await brokerError(res, "journal-put");
|
|
96
|
+
}
|
|
97
|
+
/** Persist a durable SUSPENSION: the broker records the wake condition (a pending/suspended journal
|
|
98
|
+
* entry + a human-input request row for HITL, or the wake time for a long sleep), flips the run to
|
|
99
|
+
* its suspended status, and releases the lease — transactionally. No finalize; a wake re-dispatches. */
|
|
100
|
+
async suspend(signal, workerId) {
|
|
101
|
+
const res = await this.fetchImpl(this.url("suspend"), {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: this.headers(true),
|
|
104
|
+
body: JSON.stringify({ ...signal, workerId }),
|
|
105
|
+
});
|
|
106
|
+
if (res.status !== 204)
|
|
107
|
+
throw await brokerError(res, "suspend");
|
|
108
|
+
}
|
|
109
|
+
/** The {@link JournalSeam} the worker host reads/writes — a thin adapter over the broker methods. */
|
|
110
|
+
journalSeam() {
|
|
111
|
+
return {
|
|
112
|
+
get: (seq) => this.journalGet(seq),
|
|
113
|
+
put: (entry) => this.journalPut(entry),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Fetch the run's pinned manifest + program source, or null when the version is missing (404). */
|
|
117
|
+
async getVersion() {
|
|
118
|
+
const res = await this.fetchImpl(this.url("version"), {
|
|
119
|
+
method: "GET",
|
|
120
|
+
headers: this.headers(false),
|
|
121
|
+
});
|
|
122
|
+
if (res.status === 404)
|
|
123
|
+
return null;
|
|
124
|
+
if (res.status !== 200)
|
|
125
|
+
throw await brokerError(res, "version");
|
|
126
|
+
return (await res.json());
|
|
127
|
+
}
|
|
128
|
+
/** Book a runtime-seconds DELTA (the worker's RuntimeFlusher → broker). `identifier` makes a
|
|
129
|
+
* retried/duplicate flush idempotent; distinct per-flush ids sum into the run's runtime total. */
|
|
130
|
+
async reportUsage(runtimeSeconds, identifier) {
|
|
131
|
+
const res = await this.fetchImpl(this.url("usage"), {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: this.headers(true),
|
|
134
|
+
body: JSON.stringify({ runtimeSeconds, identifier }),
|
|
135
|
+
});
|
|
136
|
+
if (res.status !== 204)
|
|
137
|
+
throw await brokerError(res, "usage");
|
|
138
|
+
}
|
|
139
|
+
/** Report a token-usage delta for incremental in-run metering (the usage flusher → broker). The
|
|
140
|
+
* broker gates on the run's per-connection `billed_by_boardwalk` server-side + meters to Stripe;
|
|
141
|
+
* `identifier` makes a retried/duplicate flush idempotent. Satisfies {@link TokenUsageReporter}. */
|
|
142
|
+
async meterTokens(input) {
|
|
143
|
+
const res = await this.fetchImpl(this.url("usage/tokens"), {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: this.headers(true),
|
|
146
|
+
body: JSON.stringify(input),
|
|
147
|
+
});
|
|
148
|
+
if (res.status !== 204)
|
|
149
|
+
throw await brokerError(res, "usage/tokens");
|
|
150
|
+
}
|
|
151
|
+
/** Check whether the run's org is still funded (the CreditWatcher → broker). The broker reads the
|
|
152
|
+
* live Stripe balance server-side; `false` means out of credit (the watcher then aborts the run). */
|
|
153
|
+
async checkCredit() {
|
|
154
|
+
const res = await this.fetchImpl(this.url("credit"), {
|
|
155
|
+
method: "GET",
|
|
156
|
+
headers: this.headers(false),
|
|
157
|
+
});
|
|
158
|
+
if (res.status !== 200)
|
|
159
|
+
throw await brokerError(res, "credit");
|
|
160
|
+
return (await res.json()).funded;
|
|
161
|
+
}
|
|
162
|
+
/** Check whether the run has been asked to cancel (the CancelWatcher → broker). `true` once the user
|
|
163
|
+
* cancelled the run (the broker flipped it to `cancelling`/`cancelled`); the watcher then aborts the
|
|
164
|
+
* run. Brokered because the runner holds no DB/Redis — this replaces the unreachable Redis channel. */
|
|
165
|
+
async checkCancelled() {
|
|
166
|
+
const res = await this.fetchImpl(this.url("cancel"), {
|
|
167
|
+
method: "GET",
|
|
168
|
+
headers: this.headers(false),
|
|
169
|
+
});
|
|
170
|
+
if (res.status !== 200)
|
|
171
|
+
throw await brokerError(res, "cancel");
|
|
172
|
+
return (await res.json()).cancelRequested;
|
|
173
|
+
}
|
|
174
|
+
/** Mint a presigned GET URL to restore this workflow's last `/workspace` snapshot (workspace
|
|
175
|
+
* persistence, §5). `null` when the run isn't eligible (not opted-in, or self-hosted). */
|
|
176
|
+
async workspaceHydrateUrl() {
|
|
177
|
+
const res = await this.fetchImpl(this.url("workspace/hydrate-url"), {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: this.headers(false),
|
|
180
|
+
});
|
|
181
|
+
if (res.status !== 200)
|
|
182
|
+
throw await brokerError(res, "workspace/hydrate-url");
|
|
183
|
+
return (await res.json()).url;
|
|
184
|
+
}
|
|
185
|
+
/** Mint a presigned PUT URL to snapshot this workflow's `/workspace` (the worker uploads the tarball
|
|
186
|
+
* straight to S3). `null` when the run isn't eligible. `sizeBytes` is the archive's on-disk size
|
|
187
|
+
* (the worker tars BEFORE requesting the URL) — the broker records it for the org storage counter +
|
|
188
|
+
* daily meter; the snapshot overwrites one per-workflow key, so it's the workflow's full footprint. */
|
|
189
|
+
async workspacePersistUrl(sizeBytes) {
|
|
190
|
+
const res = await this.fetchImpl(this.url("workspace/persist-url"), {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: this.headers(true),
|
|
193
|
+
body: JSON.stringify({ sizeBytes }),
|
|
194
|
+
});
|
|
195
|
+
if (res.status !== 200)
|
|
196
|
+
throw await brokerError(res, "workspace/persist-url");
|
|
197
|
+
const body = (await res.json());
|
|
198
|
+
return body.url === null ? null : { url: body.url, contentType: body.contentType ?? "" };
|
|
199
|
+
}
|
|
200
|
+
/** Download bytes from a presigned S3 URL (workspace hydrate). `null` on 404 (no snapshot yet —
|
|
201
|
+
* e.g. the workflow's first run); throws on any other non-2xx. Goes straight to S3, not the broker. */
|
|
202
|
+
async downloadBytes(url) {
|
|
203
|
+
const res = await this.fetchImpl(url, { method: "GET" });
|
|
204
|
+
if (res.status === 404)
|
|
205
|
+
return null;
|
|
206
|
+
if (!res.ok)
|
|
207
|
+
throw await brokerError(res, "workspace-download");
|
|
208
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
209
|
+
}
|
|
210
|
+
/** Request an OIDC run id-token for `audience` (§OIDC). The broker mints an asymmetric,
|
|
211
|
+
* third-party-verifiable token (gated server-side on `permissions.id_token: "write"`) — used to
|
|
212
|
+
* federate into the org's OWN cloud (AWS/GCP). DIFFERENT from this client's run token. */
|
|
213
|
+
async requestOidcToken(audience) {
|
|
214
|
+
const res = await this.fetchImpl(this.url("oidc/token"), {
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: this.headers(true),
|
|
217
|
+
body: JSON.stringify({ audience }),
|
|
218
|
+
});
|
|
219
|
+
if (res.status !== 200)
|
|
220
|
+
throw await brokerError(res, "oidc/token");
|
|
221
|
+
return (await res.json());
|
|
222
|
+
}
|
|
223
|
+
/** Publish a batch of live agent-event frames (the SSE live-tail source) — the broker publishes
|
|
224
|
+
* them to the run's Redis channel server-side, so the runner holds no Redis credential. */
|
|
225
|
+
async publishTelemetry(frames) {
|
|
226
|
+
const res = await this.fetchImpl(this.url("telemetry"), {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: this.headers(true),
|
|
229
|
+
body: JSON.stringify({ frames }),
|
|
230
|
+
});
|
|
231
|
+
if (res.status !== 204)
|
|
232
|
+
throw await brokerError(res, "telemetry");
|
|
233
|
+
}
|
|
234
|
+
/** Store a run artifact through the broker (which holds the S3 credential + neutralizes the served
|
|
235
|
+
* content type server-side). Returns the catalog id + a signed download URL. */
|
|
236
|
+
async writeArtifact(input) {
|
|
237
|
+
const res = await this.fetchImpl(this.url("artifacts"), {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: this.headers(true),
|
|
240
|
+
body: JSON.stringify(input),
|
|
241
|
+
});
|
|
242
|
+
if (res.status !== 201)
|
|
243
|
+
throw await brokerError(res, "artifacts");
|
|
244
|
+
return (await res.json());
|
|
245
|
+
}
|
|
246
|
+
/** Phase 1 of the LARGE-artifact path (docs/RUNNER_BROKER.md §7 step 4): presign an S3 PUT. The
|
|
247
|
+
* broker derives the S3 key + neutralizes/pins the served content type; it returns the upload URL +
|
|
248
|
+
* required headers + the `s3Key` to echo back at commit. No catalog row exists yet. */
|
|
249
|
+
async presignArtifact(input) {
|
|
250
|
+
const res = await this.fetchImpl(this.url("artifacts/presign"), {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers: this.headers(true),
|
|
253
|
+
body: JSON.stringify(input),
|
|
254
|
+
});
|
|
255
|
+
if (res.status !== 201)
|
|
256
|
+
throw await brokerError(res, "artifacts/presign");
|
|
257
|
+
return (await res.json());
|
|
258
|
+
}
|
|
259
|
+
/** Upload bytes to a presigned S3 URL (the large-artifact path). The `headers` come from the
|
|
260
|
+
* presign response and MUST be sent verbatim — the content type is pinned into the signature, so
|
|
261
|
+
* S3 rejects a mismatch. This call goes straight to S3, not the broker. */
|
|
262
|
+
async uploadBytes(url, headers, body) {
|
|
263
|
+
const res = await this.fetchImpl(url, { method: "PUT", headers, body });
|
|
264
|
+
if (!res.ok)
|
|
265
|
+
throw await brokerError(res, "artifacts-upload");
|
|
266
|
+
}
|
|
267
|
+
/** Phase 2 of the LARGE-artifact path: register the catalog row AFTER the bytes have landed in S3
|
|
268
|
+
* (called only on a successful {@link uploadBytes}, so a failed upload leaves no dangling row). The
|
|
269
|
+
* broker re-validates the run prefix + re-neutralizes the content type, then returns the catalog id
|
|
270
|
+
* + a signed download URL. */
|
|
271
|
+
async commitArtifact(input) {
|
|
272
|
+
const res = await this.fetchImpl(this.url("artifacts/commit"), {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: this.headers(true),
|
|
275
|
+
body: JSON.stringify(input),
|
|
276
|
+
});
|
|
277
|
+
if (res.status !== 201)
|
|
278
|
+
throw await brokerError(res, "artifacts/commit");
|
|
279
|
+
return (await res.json());
|
|
280
|
+
}
|
|
281
|
+
/** List the artifacts this run has produced. */
|
|
282
|
+
async listArtifacts() {
|
|
283
|
+
const res = await this.fetchImpl(this.url("artifacts"), {
|
|
284
|
+
method: "GET",
|
|
285
|
+
headers: this.headers(false),
|
|
286
|
+
});
|
|
287
|
+
if (res.status !== 200)
|
|
288
|
+
throw await brokerError(res, "artifacts-list");
|
|
289
|
+
return (await res.json()).artifacts;
|
|
290
|
+
}
|
|
291
|
+
/** Mint a fresh signed download URL for one of this run's artifacts. */
|
|
292
|
+
async signArtifactUrl(artifactId, ttlSeconds) {
|
|
293
|
+
const res = await this.fetchImpl(this.url(`artifacts/${encodeURIComponent(artifactId)}/signed-url`), {
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: this.headers(true),
|
|
296
|
+
body: JSON.stringify({ ttlSeconds }),
|
|
297
|
+
});
|
|
298
|
+
if (res.status !== 200)
|
|
299
|
+
throw await brokerError(res, "artifacts-sign");
|
|
300
|
+
return (await res.json());
|
|
301
|
+
}
|
|
302
|
+
/** Resolve an org secret the run's manifest allows (the program's `secrets.get`). The broker
|
|
303
|
+
* enforces the allowlist + returns the value; a forbidden/missing secret surfaces as a throw. */
|
|
304
|
+
async resolveSecret(name) {
|
|
305
|
+
const res = await this.fetchImpl(this.url("secrets/resolve"), {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: this.headers(true),
|
|
308
|
+
body: JSON.stringify({ name }),
|
|
309
|
+
});
|
|
310
|
+
if (res.status !== 200)
|
|
311
|
+
throw await brokerError(res, "secrets/resolve");
|
|
312
|
+
const body = (await res.json());
|
|
313
|
+
return body.value;
|
|
314
|
+
}
|
|
315
|
+
/** Broker a short-lived OAuth bearer for a hosted MCP server (the engine's `mcpToken` hook, called
|
|
316
|
+
* reactively after a 401). The broker vends from the org's connection vault and re-checks egress.
|
|
317
|
+
* A 403 (no active connection / non-allowlisted host) degrades to `{ accessToken: null, hint }` so
|
|
318
|
+
* the engine surfaces a clean failure instead of a thrown 500 mid-run; the token is never logged. */
|
|
319
|
+
async mcpToken(serverUrl, invalidateToken) {
|
|
320
|
+
const res = await this.fetchImpl(this.url("mcp/token"), {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: this.headers(true),
|
|
323
|
+
body: JSON.stringify({
|
|
324
|
+
serverUrl,
|
|
325
|
+
...(invalidateToken !== undefined ? { invalidateToken } : {}),
|
|
326
|
+
}),
|
|
327
|
+
});
|
|
328
|
+
if (res.status === 403) {
|
|
329
|
+
return { accessToken: null, hint: await brokerForbiddenHint(res) };
|
|
330
|
+
}
|
|
331
|
+
if (res.status !== 200)
|
|
332
|
+
throw await brokerError(res, "mcp/token");
|
|
333
|
+
const body = (await res.json());
|
|
334
|
+
return { accessToken: body.accessToken };
|
|
335
|
+
}
|
|
336
|
+
/** Proxy a web_search through the broker (which holds the Tavily key) — the runner sends the
|
|
337
|
+
* query, the broker calls Tavily and returns the results. */
|
|
338
|
+
async webSearch(input) {
|
|
339
|
+
const res = await this.fetchImpl(this.url("tools/web_search"), {
|
|
340
|
+
method: "POST",
|
|
341
|
+
headers: this.headers(true),
|
|
342
|
+
body: JSON.stringify(input),
|
|
343
|
+
});
|
|
344
|
+
if (res.status !== 200)
|
|
345
|
+
throw await brokerError(res, "tools/web_search");
|
|
346
|
+
return (await res.json());
|
|
347
|
+
}
|
|
348
|
+
/** Create (or idempotently re-attach to) a child run for `workflows.call`. */
|
|
349
|
+
async startChild(slug, input) {
|
|
350
|
+
const res = await this.fetchImpl(this.url("children"), {
|
|
351
|
+
method: "POST",
|
|
352
|
+
headers: this.headers(true),
|
|
353
|
+
body: JSON.stringify({ slug, input }),
|
|
354
|
+
});
|
|
355
|
+
// The broker returns 201 for a fresh child, 200 for an idempotent re-attach.
|
|
356
|
+
if (res.status !== 200 && res.status !== 201)
|
|
357
|
+
throw await brokerError(res, "children");
|
|
358
|
+
return (await res.json());
|
|
359
|
+
}
|
|
360
|
+
/** Provision a durable schedule for `workflows.schedule`; returns the new schedule's id. */
|
|
361
|
+
async scheduleWorkflow(slug, input, spec) {
|
|
362
|
+
const res = await this.fetchImpl(this.url("schedules"), {
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: this.headers(true),
|
|
365
|
+
body: JSON.stringify({ slug, input, ...spec }),
|
|
366
|
+
});
|
|
367
|
+
if (res.status !== 201)
|
|
368
|
+
throw await brokerError(res, "schedules");
|
|
369
|
+
return (await res.json()).scheduleId;
|
|
370
|
+
}
|
|
371
|
+
/** Poll a child run's status/output, or null when it isn't this run's child (404). */
|
|
372
|
+
async getChild(childRunId) {
|
|
373
|
+
const res = await this.fetchImpl(this.url(`children/${encodeURIComponent(childRunId)}`), {
|
|
374
|
+
method: "GET",
|
|
375
|
+
headers: this.headers(false),
|
|
376
|
+
});
|
|
377
|
+
if (res.status === 404)
|
|
378
|
+
return null;
|
|
379
|
+
if (res.status !== 200)
|
|
380
|
+
throw await brokerError(res, "children-get");
|
|
381
|
+
return (await res.json());
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Proxy one model turn through the broker (docs/RUNNER_BROKER.md §4 — Inference). POSTs the
|
|
385
|
+
* neutral conversation; the broker resolves the REAL model server-side (the runner holds no model
|
|
386
|
+
* creds), invokes the matching engine adapter, and relays the model's stream back as NDJSON
|
|
387
|
+
* `InferenceFrame`s (delta / result / error). Yields each frame; the engine-backed leaf surfaces
|
|
388
|
+
* deltas via `providerIo.onDelta`, takes the terminal `result` as the turn, and throws on `error`.
|
|
389
|
+
*
|
|
390
|
+
* Backs {@link InferenceProxyTransport} (inference_transport.ts) — the model swap is invisible to
|
|
391
|
+
* the engine loop, which keeps the runner provider-agnostic (the broker owns model invocation). A
|
|
392
|
+
* non-200 (a failure BEFORE the stream began) throws the broker's already-classified message.
|
|
393
|
+
*/
|
|
394
|
+
async *streamInference(req) {
|
|
395
|
+
const res = await this.fetchImpl(this.url("inference"), {
|
|
396
|
+
method: "POST",
|
|
397
|
+
headers: { ...this.headers(true), accept: INFERENCE_NDJSON_CONTENT_TYPE },
|
|
398
|
+
body: serializeInferenceRequest(req),
|
|
399
|
+
});
|
|
400
|
+
if (res.status !== 200 || res.body === null) {
|
|
401
|
+
throw await inferenceHttpError(res);
|
|
402
|
+
}
|
|
403
|
+
for await (const line of readNdjsonLines(res.body)) {
|
|
404
|
+
yield parseInferenceFrame(line);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
url(suffix) {
|
|
408
|
+
return `${this.base}/runner/v1/runs/${encodeURIComponent(this.cfg.runId)}/${suffix}`;
|
|
409
|
+
}
|
|
410
|
+
headers(json) {
|
|
411
|
+
const h = {
|
|
412
|
+
authorization: `Bearer ${this.cfg.runToken}`,
|
|
413
|
+
accept: "application/json",
|
|
414
|
+
};
|
|
415
|
+
if (json)
|
|
416
|
+
h["content-type"] = "application/json";
|
|
417
|
+
return h;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Yield complete newline-delimited lines from a streaming response body (NDJSON inference frames),
|
|
421
|
+
* buffering partial chunks and flushing any trailing line. Blank lines are skipped. */
|
|
422
|
+
async function* readNdjsonLines(body) {
|
|
423
|
+
const reader = body.getReader();
|
|
424
|
+
const decoder = new TextDecoder();
|
|
425
|
+
let buf = "";
|
|
426
|
+
try {
|
|
427
|
+
for (;;) {
|
|
428
|
+
const { done, value } = await reader.read();
|
|
429
|
+
if (done)
|
|
430
|
+
break;
|
|
431
|
+
buf += decoder.decode(value, { stream: true });
|
|
432
|
+
for (;;) {
|
|
433
|
+
const idx = buf.indexOf("\n");
|
|
434
|
+
if (idx < 0)
|
|
435
|
+
break;
|
|
436
|
+
const line = buf.slice(0, idx).trim();
|
|
437
|
+
buf = buf.slice(idx + 1);
|
|
438
|
+
if (line.length > 0)
|
|
439
|
+
yield line;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const tail = (buf + decoder.decode()).trim();
|
|
443
|
+
if (tail.length > 0)
|
|
444
|
+
yield tail;
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
reader.releaseLock();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/** Build a redacted-enough error from a non-success broker response (status + a clipped body). Used
|
|
451
|
+
* for the internal control-plane ops (claim/finalize/usage/…), whose failures crash+restart the run
|
|
452
|
+
* rather than surface to the customer — so the op+status framing is the useful debugging detail. */
|
|
453
|
+
async function brokerError(res, op) {
|
|
454
|
+
let detail = "";
|
|
455
|
+
try {
|
|
456
|
+
detail = (await res.text()).slice(0, 500);
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
/* body unreadable — status alone is enough */
|
|
460
|
+
}
|
|
461
|
+
log.warn("broker_call_failed", { op, status: res.status });
|
|
462
|
+
return new Error(`Runner Control ${op} failed: ${String(res.status)} ${detail}`);
|
|
463
|
+
}
|
|
464
|
+
/** Best-effort human hint from a broker 403 body (the error envelope's message, else the raw text)
|
|
465
|
+
* — surfaced to the model when an MCP server can't be reached. Never includes a token (403s carry
|
|
466
|
+
* none). */
|
|
467
|
+
async function brokerForbiddenHint(res) {
|
|
468
|
+
let text = "";
|
|
469
|
+
try {
|
|
470
|
+
text = (await res.text()).slice(0, 500);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return "MCP connection is not available for this server.";
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
const parsed = JSON.parse(text);
|
|
477
|
+
if (typeof parsed.error?.message === "string")
|
|
478
|
+
return parsed.error.message;
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
/* not JSON — fall through to the raw text */
|
|
482
|
+
}
|
|
483
|
+
return text === "" ? "MCP connection is not available for this server." : text;
|
|
484
|
+
}
|
|
485
|
+
/** Clean Error from a non-200 INFERENCE response. Unlike {@link brokerError}, this becomes the run's
|
|
486
|
+
* customer-facing error, so it surfaces the broker's already-classified `{error:{message}}` directly
|
|
487
|
+
* (HTTP status / op stay in the log, not the user text). Falls back to a generic line if unparsable. */
|
|
488
|
+
async function inferenceHttpError(res) {
|
|
489
|
+
let message = "Inference failed.";
|
|
490
|
+
try {
|
|
491
|
+
const parsed = JSON.parse(await res.text());
|
|
492
|
+
if (typeof parsed.error?.message === "string" && parsed.error.message.length > 0) {
|
|
493
|
+
message = parsed.error.message;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
/* non-JSON body — keep the generic message */
|
|
498
|
+
}
|
|
499
|
+
log.warn("broker_inference_failed", { status: res.status });
|
|
500
|
+
return new Error(message);
|
|
501
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Flush cadence — runtime is billed within ~this window of accruing, so a long/perpetual run can't
|
|
2
|
+
* burn unbilled compute and the credit watcher sees the spend promptly. */
|
|
3
|
+
export declare const DEFAULT_RUNTIME_FLUSH_INTERVAL_MS = 60000;
|
|
4
|
+
export interface RuntimeFlusherDeps {
|
|
5
|
+
/** The run being metered (for the id base + correlation). */
|
|
6
|
+
runId: string;
|
|
7
|
+
/** This worker session's id (fresh per claim) — separates a restarted run's sessions in the id. */
|
|
8
|
+
sessionId: string;
|
|
9
|
+
/** Wall-clock at which this session's runtime starts accruing (the claim time). */
|
|
10
|
+
startedAtMs: number;
|
|
11
|
+
/** vCPUs provisioned for the task. Runtime is billed per vCPU-SECOND, so each delta is wall-clock
|
|
12
|
+
* seconds × this. Defaults to 1 (the 1-vCPU `small` size → vCPU-seconds == wall-clock). */
|
|
13
|
+
vcpus?: number;
|
|
14
|
+
/** Monotonic-ish clock; injected for tests. */
|
|
15
|
+
now: () => number;
|
|
16
|
+
/** Book a runtime vCPU-seconds delta under `identifier` (brokered: `RunnerControlClient.reportUsage`). */
|
|
17
|
+
report: (deltaSeconds: number, identifier: string) => Promise<void>;
|
|
18
|
+
intervalMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export declare class RuntimeFlusher {
|
|
21
|
+
private readonly deps;
|
|
22
|
+
private timer;
|
|
23
|
+
private firing;
|
|
24
|
+
private stopped;
|
|
25
|
+
/** Total vCPU-seconds already booked (across successful flushes). The next delta is `total - this`. */
|
|
26
|
+
private flushedSeconds;
|
|
27
|
+
/** Per-flush sequence — advances only on a successful flush, so a retry reuses the same id. */
|
|
28
|
+
private seq;
|
|
29
|
+
constructor(deps: RuntimeFlusherDeps);
|
|
30
|
+
/** Begin periodic delta flushing. */
|
|
31
|
+
start(): void;
|
|
32
|
+
/** Stop the periodic timer and drain any in-flight flush. Does NOT book the final tail — call
|
|
33
|
+
* {@link flushFinal} for that on a clean terminal. Idempotent. */
|
|
34
|
+
stop(): Promise<void>;
|
|
35
|
+
/** Book the remaining runtime since the last successful flush (the terminal tail). Runs even after
|
|
36
|
+
* {@link stop}; it's the replacement for the old single terminal runtime charge. */
|
|
37
|
+
flushFinal(): Promise<void>;
|
|
38
|
+
/** Compute the unbilled delta and book it under a fixed per-flush id. `final` lets the terminal tail
|
|
39
|
+
* flush after `stop()`; the timer path skips once stopped. */
|
|
40
|
+
private flush;
|
|
41
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// RuntimeFlusher — meters a run's held-task runtime as periodic DELTAS, instead of one charge at
|
|
2
|
+
// terminal (MASTER_SPEC §15; docs/RUNNER_BROKER.md). The heartbeat counterpart to the credit / cancel
|
|
3
|
+
// / lease watchers.
|
|
4
|
+
//
|
|
5
|
+
// Why: runtime used to be booked once, at terminal (`now - sessionStart`). That left two holes — a run
|
|
6
|
+
// that never terminates (a perpetual loop) NEVER billed its runtime (and the credit watcher, reading
|
|
7
|
+
// Stripe, never saw the burn so it couldn't stop it); and a session that crashed before finalizing had
|
|
8
|
+
// its whole runtime lost. Metering on a timer closes both: runtime is billed as it accrues, the credit
|
|
9
|
+
// watcher sees it, and a crashed session keeps the deltas it already flushed (the fresh session bills
|
|
10
|
+
// its own — distinct ids sum, so the restart isn't double-charged or under-charged for the overlap-free
|
|
11
|
+
// portion).
|
|
12
|
+
//
|
|
13
|
+
// Idempotency: each flush is a delta with a fixed per-flush id (`<runId>:<sessionId>:rt:<seq>`); the
|
|
14
|
+
// broker/usage store dedupes on it, so a retried flush bills once. On a report failure we DON'T advance
|
|
15
|
+
// `flushedSeconds`/`seq`, so the next tick retries the SAME id (with a delta grown by the elapsed since)
|
|
16
|
+
// — the dedup keeps whichever landed first, bounding any loss to ~one interval and NEVER double-billing.
|
|
17
|
+
// `flushFinal()` books the tail (since the last successful flush) on a clean terminal; it is deliberately
|
|
18
|
+
// NOT called on a `lease_lost` handoff (the new owner books its own runtime — see program_worker).
|
|
19
|
+
import { createLogger } from "./support/index.js";
|
|
20
|
+
const log = createLogger("RuntimeFlusher");
|
|
21
|
+
/** Flush cadence — runtime is billed within ~this window of accruing, so a long/perpetual run can't
|
|
22
|
+
* burn unbilled compute and the credit watcher sees the spend promptly. */
|
|
23
|
+
export const DEFAULT_RUNTIME_FLUSH_INTERVAL_MS = 60_000;
|
|
24
|
+
export class RuntimeFlusher {
|
|
25
|
+
deps;
|
|
26
|
+
timer = null;
|
|
27
|
+
firing = Promise.resolve();
|
|
28
|
+
stopped = false;
|
|
29
|
+
/** Total vCPU-seconds already booked (across successful flushes). The next delta is `total - this`. */
|
|
30
|
+
flushedSeconds = 0;
|
|
31
|
+
/** Per-flush sequence — advances only on a successful flush, so a retry reuses the same id. */
|
|
32
|
+
seq = 0;
|
|
33
|
+
constructor(deps) {
|
|
34
|
+
this.deps = deps;
|
|
35
|
+
}
|
|
36
|
+
/** Begin periodic delta flushing. */
|
|
37
|
+
start() {
|
|
38
|
+
if (this.timer !== null)
|
|
39
|
+
return;
|
|
40
|
+
const interval = this.deps.intervalMs ?? DEFAULT_RUNTIME_FLUSH_INTERVAL_MS;
|
|
41
|
+
this.timer = setInterval(() => {
|
|
42
|
+
this.firing = this.firing.then(() => this.flush(false));
|
|
43
|
+
}, interval);
|
|
44
|
+
// Don't keep the worker process alive solely for the flush timer.
|
|
45
|
+
this.timer.unref();
|
|
46
|
+
}
|
|
47
|
+
/** Stop the periodic timer and drain any in-flight flush. Does NOT book the final tail — call
|
|
48
|
+
* {@link flushFinal} for that on a clean terminal. Idempotent. */
|
|
49
|
+
async stop() {
|
|
50
|
+
this.stopped = true;
|
|
51
|
+
if (this.timer !== null) {
|
|
52
|
+
clearInterval(this.timer);
|
|
53
|
+
this.timer = null;
|
|
54
|
+
}
|
|
55
|
+
await this.firing.catch(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
/** Book the remaining runtime since the last successful flush (the terminal tail). Runs even after
|
|
58
|
+
* {@link stop}; it's the replacement for the old single terminal runtime charge. */
|
|
59
|
+
async flushFinal() {
|
|
60
|
+
await this.flush(true);
|
|
61
|
+
}
|
|
62
|
+
/** Compute the unbilled delta and book it under a fixed per-flush id. `final` lets the terminal tail
|
|
63
|
+
* flush after `stop()`; the timer path skips once stopped. */
|
|
64
|
+
async flush(final) {
|
|
65
|
+
if (this.stopped && !final)
|
|
66
|
+
return;
|
|
67
|
+
// vCPU-seconds = wall-clock seconds × vCPUs (billed per vCPU-second). Rounding the cumulative
|
|
68
|
+
// product (not per-delta) keeps the booked total aligned with wall-clock×vcpus over many flushes.
|
|
69
|
+
const vcpus = this.deps.vcpus ?? 1;
|
|
70
|
+
const total = Math.max(0, Math.round(((this.deps.now() - this.deps.startedAtMs) / 1000) * vcpus));
|
|
71
|
+
const delta = total - this.flushedSeconds;
|
|
72
|
+
if (delta < 1)
|
|
73
|
+
return;
|
|
74
|
+
const identifier = `${this.deps.runId}:${this.deps.sessionId}:rt:${String(this.seq)}`;
|
|
75
|
+
try {
|
|
76
|
+
await this.deps.report(delta, identifier);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
// A transient report failure must NOT kill a live run — leave `flushedSeconds`/`seq` unadvanced
|
|
80
|
+
// so the next flush retries the SAME id (dedup => no double-bill; at most ~one interval lost).
|
|
81
|
+
log.warn("runtime_flush_failed", {
|
|
82
|
+
runId: this.deps.runId,
|
|
83
|
+
error: err instanceof Error ? err.message : String(err),
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.flushedSeconds = total;
|
|
88
|
+
this.seq += 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type WorkflowManifest } from "./wire/manifest.js";
|
|
2
|
+
export interface SandboxToolDeps {
|
|
3
|
+
/** Absolute sandbox root — every filesystem/shell/git op is confined here. */
|
|
4
|
+
root: string;
|
|
5
|
+
/** Non-secret env vars (resolved `manifest.env` literals) exposed to shell/git subprocesses. */
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
/** Env var name → granted secret name (`manifest.env`'s secret refs), resolved per-run. */
|
|
8
|
+
secretEnv?: Record<string, string>;
|
|
9
|
+
committerName?: string;
|
|
10
|
+
committerEmail?: string;
|
|
11
|
+
/** Manifest `tools[name=shell].config.extra_commands` allowlist additions. */
|
|
12
|
+
extraShellCommands?: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
/** The manifest-derived subset of {@link SandboxToolDeps} (the worker adds `root`). */
|
|
15
|
+
export type SandboxConfig = Omit<SandboxToolDeps, "root" | "committerName" | "committerEmail">;
|
|
16
|
+
/**
|
|
17
|
+
* Split `manifest.env` into non-secret literals vs. `${{ secrets.X }}` references. Returns only the
|
|
18
|
+
* keys that have content, so the caller can spread it into the sandbox deps without injecting
|
|
19
|
+
* empty objects.
|
|
20
|
+
*/
|
|
21
|
+
export declare function sandboxConfigFromManifest(manifest: WorkflowManifest): SandboxConfig;
|