@boardwalk-labs/runner 0.1.11 → 0.1.13
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/README.md +18 -0
- package/binding.gyp +12 -0
- package/dist/runtime/agent/budget.d.ts +5 -5
- package/dist/runtime/agent/budget.js +8 -8
- package/dist/runtime/agent/model_rates.js +4 -5
- package/dist/runtime/agent/secret_redactor.js +1 -1
- package/dist/runtime/browser_session.d.ts +59 -0
- package/dist/runtime/browser_session.js +188 -0
- package/dist/runtime/browser_session_backend.d.ts +44 -0
- package/dist/runtime/browser_session_backend.js +221 -0
- package/dist/runtime/cancel_watcher.js +1 -1
- package/dist/runtime/credit_watcher.d.ts +1 -1
- package/dist/runtime/credit_watcher.js +5 -5
- package/dist/runtime/freeze_coordinator.d.ts +6 -1
- package/dist/runtime/freeze_coordinator.js +18 -4
- package/dist/runtime/index.d.ts +11 -6
- package/dist/runtime/index.js +96 -41
- package/dist/runtime/leaf_executor.d.ts +2 -2
- package/dist/runtime/program_runner.d.ts +1 -1
- package/dist/runtime/program_runner.js +1 -1
- package/dist/runtime/program_worker.d.ts +9 -3
- package/dist/runtime/program_worker.js +18 -7
- package/dist/runtime/recording_secret_resolver.js +1 -1
- package/dist/runtime/run_abort.js +3 -3
- package/dist/runtime/runner_control_client.d.ts +29 -4
- package/dist/runtime/runner_control_client.js +76 -32
- package/dist/runtime/runtime_flusher.d.ts +1 -1
- package/dist/runtime/runtime_flusher.js +3 -3
- package/dist/runtime/support/index.js +5 -6
- package/dist/runtime/tools/artifacts.js +1 -1
- package/dist/runtime/tools/types.d.ts +5 -5
- package/dist/runtime/tools/types.js +7 -7
- package/dist/runtime/tools/web_search.js +5 -6
- package/dist/runtime/uniqueness_reseed.d.ts +7 -0
- package/dist/runtime/uniqueness_reseed.js +65 -0
- package/dist/runtime/wire/artifact_storage.js +2 -2
- package/dist/runtime/wire/inference_proxy.d.ts +1 -1
- package/dist/runtime/wire/inference_proxy.js +2 -2
- package/dist/runtime/workflow_host.d.ts +47 -1
- package/dist/runtime/workflow_host.js +100 -10
- package/native/reseed.c +52 -0
- package/package.json +15 -7
- package/prebuilds/linux-x64/@boardwalk-labs+runner.node +0 -0
package/dist/runtime/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Worker composition root (the workflow runtime design). The
|
|
2
|
-
// worker
|
|
1
|
+
// Worker composition root (the workflow runtime design). The hosted worker container runs the
|
|
2
|
+
// compiled worker entrypoint; the dispatcher launches it per run with
|
|
3
3
|
// RUN_ID + the per-run control-plane handle. This file assembles real implementations behind every
|
|
4
4
|
// seam `runProgramWorker(runId, deps)` injects, then runs the one workflow program to terminal.
|
|
5
5
|
//
|
|
@@ -11,21 +11,21 @@
|
|
|
11
11
|
// - secrets.get() → broker /secrets/resolve (allowlist enforced server-side).
|
|
12
12
|
// - workflows.call()→ broker /children (durable child run, hold + poll).
|
|
13
13
|
// - events.emit() → broker /events (fan-out server-side).
|
|
14
|
-
// - artifacts / web_search → broker /artifacts + /tools/web_search (no
|
|
14
|
+
// - artifacts / web_search → broker /artifacts + /tools/web_search (no storage or search-provider creds here).
|
|
15
15
|
// - lifecycle / usage / telemetry → broker /claim,/version,/finalize,/usage,/telemetry.
|
|
16
|
-
// There is no
|
|
17
|
-
// the metadata-endpoint escape has nothing to steal. The legacy direct (pre-broker) path is removed.
|
|
16
|
+
// There is no database, cache, billing, or model-provider client on the runner — so its task role is
|
|
17
|
+
// near-zero and the metadata-endpoint escape has nothing to steal. The legacy direct (pre-broker) path is removed.
|
|
18
18
|
//
|
|
19
19
|
// Split: `assembleWorkerDeps(runtime)` is pure wiring (unit-tested with a fake fetch); `main()` is the
|
|
20
20
|
// bootstrap shell (read the control-plane env, install signal handlers).
|
|
21
21
|
//
|
|
22
|
-
// Per-session loops wired here (all brokered): a UsageFlusher meters token deltas (
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// Per-session loops wired here (all brokered): a UsageFlusher meters token deltas (→ /usage/tokens), a
|
|
23
|
+
// CreditWatcher polls funding (→ /credit, aborting the run on exhaustion), and a CancelWatcher polls
|
|
24
|
+
// for a user cancel (→ /cancel, aborting the run when the user cancels — the brokered worker holds no
|
|
25
|
+
// cache client, so it can't receive the platform's cancel publish directly).
|
|
26
26
|
//
|
|
27
|
-
// Documented v0 deferral (a clear seam): durable events — a no-op AgentEventStore (live
|
|
28
|
-
// via the broker only) until
|
|
27
|
+
// Documented v0 deferral (a clear seam): durable events — a no-op AgentEventStore (live fan-out
|
|
28
|
+
// via the broker only) until durable event storage lands.
|
|
29
29
|
import { createLogger, newId, AppError, ErrorCode, DEFAULT_LEASE_MS } from "./support/index.js";
|
|
30
30
|
import { LspService } from "@boardwalk-labs/engine/core";
|
|
31
31
|
import { BudgetMeter } from "./agent/budget.js";
|
|
@@ -37,7 +37,10 @@ import { EngineLeafExecutor } from "./leaf_executor.js";
|
|
|
37
37
|
import { parseByoProviders } from "./direct_inference.js";
|
|
38
38
|
import { applyIdentityToEnv, connectIdentityRelayFd, relayFdFromEnv, workerDiagnostics, } from "./identity_relay.js";
|
|
39
39
|
import { FreezeCoordinator } from "./freeze_coordinator.js";
|
|
40
|
+
import { reseedUserspaceCsprng } from "./uniqueness_reseed.js";
|
|
40
41
|
import { WorkerWorkflowHost } from "./workflow_host.js";
|
|
42
|
+
import { BrowserSessionManager } from "./browser_session.js";
|
|
43
|
+
import { loadGuestBrowserConfig, makeGuestBrowserBackend, connectSessionMcp, } from "./browser_session_backend.js";
|
|
41
44
|
import { runProgramWorker, } from "./program_worker.js";
|
|
42
45
|
import { RunnerControlClient } from "./runner_control_client.js";
|
|
43
46
|
import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
|
|
@@ -54,13 +57,13 @@ import { PhaseTracker } from "./phase_tracker.js";
|
|
|
54
57
|
import { mkdir } from "node:fs/promises";
|
|
55
58
|
import { join } from "node:path";
|
|
56
59
|
const log = createLogger("worker_entrypoint");
|
|
57
|
-
/** Durable events are deferred (
|
|
60
|
+
/** Durable events are deferred (durable event storage) — live fan-out via the broker only. */
|
|
58
61
|
/** Synthesize the run's AuthContext. The run was already authorized at trigger time; the
|
|
59
62
|
* worker acts on the org's behalf, so it carries the org + an owner role. Tool-level
|
|
60
63
|
* boundaries (the broker's server-side manifest allowlist) are the real guard.
|
|
61
64
|
*
|
|
62
65
|
* source='workflow' (NOT 'session_jwt'): the program must never perform SESSION_JWT_ONLY
|
|
63
|
-
* credential mutations
|
|
66
|
+
* credential mutations, so a tool that ever exposed such a service is denied by
|
|
64
67
|
* construction regardless of the owner role. */
|
|
65
68
|
export function workerAuthContext(run) {
|
|
66
69
|
const userId = run.actor.type === "user" ? run.actor.user_id : `workflow:${run.workflowId}`;
|
|
@@ -84,7 +87,7 @@ export function tokenMeterIdentifiers(runId, sessionId) {
|
|
|
84
87
|
return (leafIndex) => `${runId}:${sessionId}:${String(leafIndex)}:${String(seq++)}`;
|
|
85
88
|
}
|
|
86
89
|
/** Pure wiring: build the full ProgramWorkerDeps from the control-plane handle. The runner reaches
|
|
87
|
-
* every privileged seam through the broker over its run token — no
|
|
90
|
+
* every privileged seam through the broker over its run token — no database, cache, billing, or model creds. */
|
|
88
91
|
export function assembleWorkerDeps(runtime) {
|
|
89
92
|
const broker = new RunnerControlClient({
|
|
90
93
|
baseUrl: runtime.controlPlane.baseUrl,
|
|
@@ -114,8 +117,9 @@ export function assembleWorkerDeps(runtime) {
|
|
|
114
117
|
freeze = new FreezeCoordinator({ channel });
|
|
115
118
|
log.info("freeze_mode_enabled", { runId: runtime.runId });
|
|
116
119
|
}
|
|
117
|
-
// Live agent-event stream → /telemetry (broker
|
|
118
|
-
// web_search, artifacts, and the model call are all brokered — no
|
|
120
|
+
// Live agent-event stream → /telemetry (broker fans out server-side). Inference,
|
|
121
|
+
// web_search, artifacts, and the model call are all brokered — no cache client, model creds,
|
|
122
|
+
// search-provider creds, or object-storage creds here.
|
|
119
123
|
const eventPublisher = new BrokerEventPublisher({
|
|
120
124
|
send: (frames) => broker.publishTelemetry(frames),
|
|
121
125
|
});
|
|
@@ -129,11 +133,11 @@ export function assembleWorkerDeps(runtime) {
|
|
|
129
133
|
// bootstrap captured + scrubbed it (capturePlatformContext) so the agent's bash / subprocesses
|
|
130
134
|
// can't read it. The program reaches it ONLY via `runtime.apiToken()` (built below); we still
|
|
131
135
|
// record it in each run's redactor so a value the program threads into a tool can't echo back out
|
|
132
|
-
// of a tool result. Absent in the dev no-signing-key path.
|
|
136
|
+
// of a tool result. Absent in the dev no-signing-key path.
|
|
133
137
|
// MUTABLE: on the snapshot substrate a wake carries a fresh token (the frozen one expired).
|
|
134
138
|
let runApiKey = runtime.controlPlane.apiToken;
|
|
135
|
-
// The broker (Runner Control API)
|
|
136
|
-
//
|
|
139
|
+
// The broker (Runner Control API) shares an origin with the public API;
|
|
140
|
+
// `runtime.apiUrl` exposes it and the program appends `/v1` or `/mcp/v1`.
|
|
137
141
|
const apiUrl = publicApiOrigin(runtime.controlPlane.baseUrl);
|
|
138
142
|
// Per-run host: agent() leaf + sleep-hold + secrets + children + events, all brokered. `signal`
|
|
139
143
|
// carries cooperative cancellation (credit exhaustion) into every host hook.
|
|
@@ -143,7 +147,7 @@ export function assembleWorkerDeps(runtime) {
|
|
|
143
147
|
// Fallback budget-cap rate. A managed turn caps on the broker's EXACT upstream cost (forwarded on
|
|
144
148
|
// the inference result frame → BudgetMeter `realCostUsd`); this representative sonnet-class rate
|
|
145
149
|
// applies only to a turn with no upstream cost (BYO / unavailable). `max_usd` is a guardrail, not
|
|
146
|
-
// the bill —
|
|
150
|
+
// the bill — the platform meters the actual per-leaf usage.
|
|
147
151
|
rate: BUDGET_GUARDRAIL_RATE,
|
|
148
152
|
startedAt: Date.now(),
|
|
149
153
|
// deadline_seconds is WALL-CLOCK from the run's ORIGINAL start (incl. suspended idle), so a run
|
|
@@ -160,7 +164,7 @@ export function assembleWorkerDeps(runtime) {
|
|
|
160
164
|
const nextMeterIdentifier = tokenMeterIdentifiers(run.id, meteringSessionId);
|
|
161
165
|
// Per-run secret boundary: every value resolved (program `secrets.get` OR a tool's
|
|
162
166
|
// ctx.secrets.resolve) is recorded into one shared redactor; the leaf seeds a fresh engine
|
|
163
|
-
// Redactor from it so the loop scrubs those values out of all model-bound content.
|
|
167
|
+
// Redactor from it so the loop scrubs those values out of all model-bound content.
|
|
164
168
|
const redactor = new SecretRedactor();
|
|
165
169
|
// Record the run's own API key so a prompt-injected agent can't echo it back to the model.
|
|
166
170
|
if (runApiKey !== undefined && runApiKey !== "")
|
|
@@ -172,6 +176,26 @@ export function assembleWorkerDeps(runtime) {
|
|
|
172
176
|
// Broker-backed artifact store (shared by the program's artifacts.write hook AND the engine's
|
|
173
177
|
// host-backed `artifacts` tool). Presigned for large bodies; the runner holds no S3 creds.
|
|
174
178
|
const artifactStore = new BrokerArtifactStore(broker);
|
|
179
|
+
// Browser tier (browser-session computer use): a per-run manager over the image's process backend.
|
|
180
|
+
// Only present when the runner image ships the browser stack (runtime.browserBackend set); else
|
|
181
|
+
// `computer.openBrowser()` throws "not available". A captured screenshot is stored as a run artifact
|
|
182
|
+
// through the SAME broker store — decoded from the MCP image block's base64 as binary bytes.
|
|
183
|
+
const browserSessions = runtime.browserBackend !== undefined
|
|
184
|
+
? new BrowserSessionManager({
|
|
185
|
+
backend: runtime.browserBackend,
|
|
186
|
+
connect: connectSessionMcp,
|
|
187
|
+
writeArtifact: (name, contentType, base64, metadata) => artifactStore
|
|
188
|
+
.write({
|
|
189
|
+
name,
|
|
190
|
+
contentType,
|
|
191
|
+
body: base64,
|
|
192
|
+
encoding: "base64",
|
|
193
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
194
|
+
})
|
|
195
|
+
.then((res) => ({ id: res.id, name: res.name, url: res.signedUrl })),
|
|
196
|
+
nextId: () => newId(),
|
|
197
|
+
})
|
|
198
|
+
: undefined;
|
|
175
199
|
// Backend for the engine's host-backed built-in tools (webfetch/web_search/artifacts): web_search
|
|
176
200
|
// + artifact read-back go through the broker (no Tavily/S3 creds here); webfetch is an in-process
|
|
177
201
|
// fetch already gated by the worker's egress proxy.
|
|
@@ -210,9 +234,9 @@ export function assembleWorkerDeps(runtime) {
|
|
|
210
234
|
// until the artifact extracts. The empty `/workspace` and this dir are SEPARATE dirs on hosted,
|
|
211
235
|
// exactly the two-tier case the engine's AGENTS.md loader is built for.
|
|
212
236
|
programDir: () => programDir,
|
|
213
|
-
// Per-leaf, per-model token metering — reported THROUGH the broker (the worker holds no
|
|
214
|
-
// credential); the broker decides `billed_by_boardwalk` per model + meters to
|
|
215
|
-
// no-op there). Fire-and-forget: a metering hiccup must never fail the run.
|
|
237
|
+
// Per-leaf, per-model token metering — reported THROUGH the broker (the worker holds no billing
|
|
238
|
+
// credential); the broker decides `billed_by_boardwalk` per model + meters usage to the platform
|
|
239
|
+
// (BYO models no-op there). Fire-and-forget: a metering hiccup must never fail the run.
|
|
216
240
|
meterUsage: ({ model, inputTokens, outputTokens, cachedReadTokens, cachedWriteTokens, leafIndex, }) => {
|
|
217
241
|
void broker
|
|
218
242
|
.meterTokens({
|
|
@@ -237,7 +261,7 @@ export function assembleWorkerDeps(runtime) {
|
|
|
237
261
|
// comes from the org's connection vault via the Runner Control API — never stored on the worker.
|
|
238
262
|
brokerMcpToken: (serverUrl, invalidateToken) => broker.mcpToken(serverUrl, invalidateToken),
|
|
239
263
|
});
|
|
240
|
-
// Per-workflow persistent /workspace
|
|
264
|
+
// Per-workflow persistent /workspace: only when the manifest opts in. The BROKER additionally
|
|
241
265
|
// gates on hosted (self-hosted runs get null URLs), and snapshots are keyed per-workflow + scoped
|
|
242
266
|
// by the run token — so even the untrusted in-process program can't reach another tenant's data.
|
|
243
267
|
const workspaceStore = manifest.workspace?.persist === true
|
|
@@ -311,6 +335,19 @@ export function assembleWorkerDeps(runtime) {
|
|
|
311
335
|
// Snapshot-substrate suspension: seams freeze in place under the quiescence gate instead of
|
|
312
336
|
// raising onSuspend (which stays wired as the transitional/local path's fallback).
|
|
313
337
|
...(freeze !== undefined ? { freeze } : {}),
|
|
338
|
+
// Register-without-release for held HITL gates — only on the
|
|
339
|
+
// freeze substrate, backed by the broker's inputs endpoints.
|
|
340
|
+
...(freeze !== undefined
|
|
341
|
+
? {
|
|
342
|
+
heldInput: {
|
|
343
|
+
register: (seq, gate) => broker.registerInput(seq, gate),
|
|
344
|
+
poll: (seq) => broker.pollInputAnswers(seq),
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
: {}),
|
|
348
|
+
// Browser tier: `computer.openBrowser()` + `agent({ session })` resolve through this manager
|
|
349
|
+
// (absent ⇒ the host throws "not available on this runner image").
|
|
350
|
+
...(browserSessions !== undefined ? { browserSessions } : {}),
|
|
314
351
|
phases: phaseTracker,
|
|
315
352
|
});
|
|
316
353
|
// Late-bind the coordinator's per-run hooks now that the run-scoped objects exist.
|
|
@@ -319,18 +356,21 @@ export function assembleWorkerDeps(runtime) {
|
|
|
319
356
|
let freezeWallMs = 0;
|
|
320
357
|
coordinator.setHooks({
|
|
321
358
|
// At quiescence, immediately before the freeze: book the runtime tail (suspended time must
|
|
322
|
-
// never appear billed
|
|
359
|
+
// never appear billed) and persist the workspace (crash-during-suspension
|
|
323
360
|
// recovery parity with the sleep-hold path). The wall stamp anchors the idle rebase below.
|
|
324
361
|
onBeforeFreeze: async () => {
|
|
325
362
|
freezeWallMs = Date.now();
|
|
326
363
|
await activeFlusher?.flushNow();
|
|
327
364
|
await workspaceStore?.persist();
|
|
328
365
|
},
|
|
329
|
-
// On wake:
|
|
330
|
-
//
|
|
331
|
-
// the
|
|
332
|
-
//
|
|
366
|
+
// On wake: reseed the userspace CSPRNG (clause 3 — a suspend snapshot restored more than
|
|
367
|
+
// once, e.g. a re-dispatch retry, would otherwise repeat its post-wake `crypto.*` draws),
|
|
368
|
+
// swap the fresh tokens onto the broker client + the program-facing apiToken() (recording
|
|
369
|
+
// the new values in the run's redactor, same discipline as boot), and exclude the frozen
|
|
370
|
+
// window from billed runtime using the wake's authoritative wall clock. The reseed runs
|
|
371
|
+
// BEFORE the seam resolves, so no woken author code draws from the stale (pre-suspend) DRBG.
|
|
333
372
|
onAfterWake: (wake) => {
|
|
373
|
+
reseedUserspaceCsprng();
|
|
334
374
|
broker.swapRunToken(wake.run_token);
|
|
335
375
|
redactor.record(wake.run_token);
|
|
336
376
|
if (wake.api_token !== undefined && wake.api_token !== "") {
|
|
@@ -364,6 +404,9 @@ export function assembleWorkerDeps(runtime) {
|
|
|
364
404
|
// Resolves when a host seam suspends the run; the program runner races it against the body.
|
|
365
405
|
suspendSignal,
|
|
366
406
|
...(workspaceStore !== undefined ? { workspace: workspaceStore } : {}),
|
|
407
|
+
// The orchestrator reaps every still-open browser session on terminal (kill Chromium + its
|
|
408
|
+
// Playwright MCP) so no browser process leaks past the run.
|
|
409
|
+
...(browserSessions !== undefined ? { browserSessions } : {}),
|
|
367
410
|
});
|
|
368
411
|
};
|
|
369
412
|
return {
|
|
@@ -412,7 +455,7 @@ export function assembleWorkerDeps(runtime) {
|
|
|
412
455
|
suspender: {
|
|
413
456
|
suspend: (signal, workerId) => broker.suspend(signal, workerId),
|
|
414
457
|
},
|
|
415
|
-
// Runtime metering
|
|
458
|
+
// Runtime metering: flush runtime as periodic deltas (+ a terminal tail) through the broker,
|
|
416
459
|
// idempotent per flush, so a long/perpetual run bills as it burns and the credit watcher sees it —
|
|
417
460
|
// not a single charge at terminal (which a never-terminating run never reached). A fresh per-session
|
|
418
461
|
// id keeps a restarted run's sessions distinct in the idempotency key (distinct ids sum).
|
|
@@ -434,7 +477,7 @@ export function assembleWorkerDeps(runtime) {
|
|
|
434
477
|
return { stop: () => flusher.stop(), flushFinal: () => flusher.flushFinal() };
|
|
435
478
|
},
|
|
436
479
|
buildHost,
|
|
437
|
-
// Mid-run credit watching
|
|
480
|
+
// Mid-run credit watching: a CreditWatcher polls the broker's GET /credit on a timer; when
|
|
438
481
|
// the org runs out, onExhausted aborts the run (the orchestrator wires it to AbortController).
|
|
439
482
|
startCreditWatch: ({ run, onExhausted }) => {
|
|
440
483
|
const watcher = new CreditWatcher({
|
|
@@ -445,10 +488,10 @@ export function assembleWorkerDeps(runtime) {
|
|
|
445
488
|
watcher.start();
|
|
446
489
|
return { stop: () => watcher.stop() };
|
|
447
490
|
},
|
|
448
|
-
// Mid-run user-cancel watching
|
|
491
|
+
// Mid-run user-cancel watching: a CancelWatcher polls the broker's GET /cancel on a timer;
|
|
449
492
|
// when the user cancels (the run is flipped to `cancelling`), onCancelled aborts the run. This is
|
|
450
|
-
// how the brokered (
|
|
451
|
-
// reach it.
|
|
493
|
+
// how the brokered (cache-less) worker learns of a cancel — the platform's cancel publish can't
|
|
494
|
+
// reach it directly.
|
|
452
495
|
startCancelWatch: ({ run, onCancelled }) => {
|
|
453
496
|
const watcher = new CancelWatcher({
|
|
454
497
|
runId: run.id,
|
|
@@ -458,7 +501,7 @@ export function assembleWorkerDeps(runtime) {
|
|
|
458
501
|
watcher.start();
|
|
459
502
|
return { stop: () => watcher.stop() };
|
|
460
503
|
},
|
|
461
|
-
// Lease renewal
|
|
504
|
+
// Lease renewal: a LeaseRenewer extends the lease through the broker's POST /renew on a timer
|
|
462
505
|
// (well under the lease), so a run longer than the 5-min lease isn't reclaimed mid-flight by the
|
|
463
506
|
// recovery sweep. `renew` re-extends with the SAME workerId that claimed it; a null result (the
|
|
464
507
|
// run is no longer ours) fires onLost → the run aborts `lease_lost` without finalizing.
|
|
@@ -481,7 +524,7 @@ export function assembleWorkerDeps(runtime) {
|
|
|
481
524
|
}
|
|
482
525
|
// ---- Bootstrap (real container only) -------------------------------------------------
|
|
483
526
|
/**
|
|
484
|
-
* The per-run platform values the dispatcher injects as container env
|
|
527
|
+
* The per-run platform values the dispatcher injects as container env. They
|
|
485
528
|
* are captured into private worker state at bootstrap and DELETED from `process.env` before any user
|
|
486
529
|
* program / agent leaf / subprocess can run — the run token + API token are credentials, and nothing
|
|
487
530
|
* untrusted run code touches should inherit them (the run env/credential rules). The user owns the rest
|
|
@@ -495,7 +538,7 @@ export const PLATFORM_ENV_KEYS = [
|
|
|
495
538
|
"BOARDWALK_TASK_CPU_UNITS",
|
|
496
539
|
];
|
|
497
540
|
/** The public-API origin a run uses for raw API / MCP / CLI calls. The broker (Runner Control API)
|
|
498
|
-
*
|
|
541
|
+
* shares an origin with the public API, so the program appends `/v1` or
|
|
499
542
|
* `/mcp/v1`. Falls back to the broker URL unchanged if it can't be parsed. */
|
|
500
543
|
export function publicApiOrigin(controlPlaneBaseUrl) {
|
|
501
544
|
try {
|
|
@@ -519,7 +562,7 @@ export function capturePlatformContext(env) {
|
|
|
519
562
|
return value.trim();
|
|
520
563
|
};
|
|
521
564
|
// The dispatcher always injects the control-plane handle (the Runner Credential Broker model). The runner is
|
|
522
|
-
// brokered-only — there is no
|
|
565
|
+
// brokered-only — there is no database, cache, or billing fallback — so a missing handle is a hard config error.
|
|
523
566
|
const runId = read("RUN_ID");
|
|
524
567
|
const apiToken = env.BOARDWALK_API_KEY?.trim();
|
|
525
568
|
const controlPlane = {
|
|
@@ -552,6 +595,12 @@ export async function main() {
|
|
|
552
595
|
relay.announceReady(workerDiagnostics());
|
|
553
596
|
const identity = await relay.awaitIdentity();
|
|
554
597
|
applyIdentityToEnv(identity, process.env);
|
|
598
|
+
// Clause 3 (SNAPSHOT_UNIQUENESS_CONTRACT): this run was restored from the SHARED base
|
|
599
|
+
// snapshot, so its OpenSSL DRBG is identical to every other run's. Reseed it from the
|
|
600
|
+
// (VMGenID-diverged) OS entropy BEFORE `acceptIdentity` releases the worker into the brokered
|
|
601
|
+
// lifecycle — so no `crypto.*` draw by the SDK, the agent, or author code can collide across
|
|
602
|
+
// clones. Every wake reseeds again (the after-wake hook).
|
|
603
|
+
reseedUserspaceCsprng();
|
|
555
604
|
relay.acceptIdentity();
|
|
556
605
|
// The relay now becomes the suspend/wake channel: assembleWorkerDeps opens it into the
|
|
557
606
|
// FreezeCoordinator, and the host's suspending seams freeze in place instead of exiting.
|
|
@@ -564,6 +613,11 @@ export async function main() {
|
|
|
564
613
|
const runId = platform.runId;
|
|
565
614
|
const byoProviders = parseByoProviders(process.env.BOARDWALK_BYO_PROVIDERS);
|
|
566
615
|
Reflect.deleteProperty(process.env, "BOARDWALK_BYO_PROVIDERS");
|
|
616
|
+
// Browser tier: only when the runner IMAGE declares the browser stack (BOARDWALK_BROWSER_TIER=1 +
|
|
617
|
+
// a Chrome path). The backend is run-independent (buildHost builds the per-run manager over it);
|
|
618
|
+
// absent ⇒ `computer.openBrowser()` fails clearly. Read here so env-reading stays out of the pure wiring.
|
|
619
|
+
const guestBrowserConfig = loadGuestBrowserConfig(process.env);
|
|
620
|
+
const browserBackend = guestBrowserConfig !== null ? makeGuestBrowserBackend(guestBrowserConfig) : undefined;
|
|
567
621
|
const deps = assembleWorkerDeps({
|
|
568
622
|
workerId: process.env.WORKER_ID ?? `worker-${runId}`,
|
|
569
623
|
workspaceRoot: process.env.WORKSPACE_ROOT ?? "/workspace",
|
|
@@ -572,12 +626,13 @@ export async function main() {
|
|
|
572
626
|
vcpus: platform.vcpus,
|
|
573
627
|
...(byoProviders.length > 0 ? { byoProviders } : {}),
|
|
574
628
|
...(freezeRelay !== undefined ? { freezeRelay } : {}),
|
|
629
|
+
...(browserBackend !== undefined ? { browserBackend } : {}),
|
|
575
630
|
});
|
|
576
|
-
// The only thing to drain is the batched telemetry buffer — the runner opens no
|
|
631
|
+
// The only thing to drain is the batched telemetry buffer — the runner opens no database, cache, or queue.
|
|
577
632
|
const cleanup = async () => {
|
|
578
633
|
await deps.flushTelemetry?.().catch(() => undefined);
|
|
579
634
|
};
|
|
580
|
-
// SIGTERM:
|
|
635
|
+
// SIGTERM: the orchestrator is stopping the task. Hold-and-pay has no mid-run checkpoint; we exit and let the
|
|
581
636
|
// lease expire → the scheduler-sweep reclaims it and a fresh worker RESTARTS the run from the
|
|
582
637
|
// top (Lambda/GHA semantics; durable children re-attach via idempotency). Crash-safe by design.
|
|
583
638
|
let shuttingDown = false;
|
|
@@ -32,7 +32,7 @@ export interface EngineLeafExecutorDeps {
|
|
|
32
32
|
budget: BudgetMeter;
|
|
33
33
|
/** RUN-level redactor (shared with the secret resolver): every resolved secret value is recorded
|
|
34
34
|
* here. We seed a fresh engine `Redactor` from it per leaf so the loop scrubs known values out of
|
|
35
|
-
* the prompt, tool args/results, and transcript before they reach the model
|
|
35
|
+
* the prompt, tool args/results, and transcript before they reach the model. */
|
|
36
36
|
redactor: SecretRedactor;
|
|
37
37
|
/** Builds this leaf's event sink; `leafIndex` (1-based) is only a metering identifier — the sink
|
|
38
38
|
* owns the run-global cursor. `identity` names the leaf on its turn frames. */
|
|
@@ -51,7 +51,7 @@ export interface EngineLeafExecutorDeps {
|
|
|
51
51
|
* omitted ⇒ no bundled tier (only the workspace AGENTS.md applies). */
|
|
52
52
|
programDir?: () => string | null;
|
|
53
53
|
/** Per-leaf token metering seam (fire-and-forget). Reports THIS leaf's tokens + its model to the
|
|
54
|
-
* broker, which decides `billed_by_boardwalk` per model + meters to
|
|
54
|
+
* broker, which decides `billed_by_boardwalk` per model + meters usage to the platform. Omitted in tests. */
|
|
55
55
|
meterUsage?: (input: MeterUsageInput) => void;
|
|
56
56
|
/** Backend for the engine's host-backed built-in tools (`webfetch` / `web_search` / `artifacts`):
|
|
57
57
|
* set as the leaf's `capabilities.host` so the engine registers them. Broker-backed on hosted runs
|
|
@@ -53,7 +53,7 @@ export interface ProgramRunnerDeps {
|
|
|
53
53
|
* Scrubs known secret values out of a string (the run's `SecretRedactor.redactText`). Applied to a
|
|
54
54
|
* top-level throw's message before it is logged AND before it is returned to the worker — a program
|
|
55
55
|
* that resolves a secret and then throws it in an error message must NOT land that secret raw in the
|
|
56
|
-
* logs or the finalized run output
|
|
56
|
+
* logs or the finalized run output. Defaults to identity (tests/local).
|
|
57
57
|
*/
|
|
58
58
|
redactText?: (text: string) => string;
|
|
59
59
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// WorkflowProgramRunner — executes a workflow program (the JS-body model, the workflow runtime design).
|
|
2
2
|
//
|
|
3
|
-
// A run is the execution of a built program ARTIFACT
|
|
3
|
+
// A run is the execution of a built program ARTIFACT: the worker is handed the VERIFIED tarball
|
|
4
4
|
// (its sha256 already checked against the pinned digest by the orchestrator) plus the entry module
|
|
5
5
|
// name. This module is the mechanism: it installs the host adapter + trigger payload onto the
|
|
6
6
|
// `@boardwalk-labs/workflow` singleton, extracts the artifact into a unique temp dir under a
|
|
@@ -24,7 +24,7 @@ export interface ProgramVersionReader {
|
|
|
24
24
|
program: ProgramRef;
|
|
25
25
|
} | null>;
|
|
26
26
|
}
|
|
27
|
-
/** Books the run's RUNTIME usage as periodic deltas (the worker's RuntimeFlusher
|
|
27
|
+
/** Books the run's RUNTIME usage as periodic deltas (the worker's RuntimeFlusher). The
|
|
28
28
|
* orchestrator drives the lifecycle: the timer flushes mid-run, `stop()` halts it at the body's end,
|
|
29
29
|
* and `flushFinal()` books the tail on a clean terminal (skipped on a `lease_lost` handoff — the new
|
|
30
30
|
* owner books its own runtime). Replaces the old single terminal runtime charge. */
|
|
@@ -51,7 +51,7 @@ export interface RunFinalizer {
|
|
|
51
51
|
export interface RunSuspender {
|
|
52
52
|
suspend(signal: SuspendSignal, workerId: string): Promise<void>;
|
|
53
53
|
}
|
|
54
|
-
/** Restores/snapshots the workflow's persistent `/workspace
|
|
54
|
+
/** Restores/snapshots the workflow's persistent `/workspace`. Best-effort — both no-op when the
|
|
55
55
|
* run isn't eligible (not opted-in / self-hosted), and neither throws. */
|
|
56
56
|
export interface WorkspaceHandle {
|
|
57
57
|
hydrate(): Promise<void>;
|
|
@@ -97,6 +97,12 @@ export type ProgramHostBuilder = (run: Run, manifest: WorkflowManifest, signal:
|
|
|
97
97
|
* program runner so a suspend short-circuits the body out of band (the durable-suspension design). Absent ⇒
|
|
98
98
|
* no durable suspension on this path (a suspend then surfaces as a thrown SuspendError). */
|
|
99
99
|
suspendSignal?: Promise<SuspendSignal>;
|
|
100
|
+
/** The run's browser-session manager (browser tier). The orchestrator reaps every still-open session
|
|
101
|
+
* on EVERY terminal path so no Chromium / Playwright MCP process leaks past the run. `closeAll` is
|
|
102
|
+
* best-effort + never throws. Absent on images without the browser stack. */
|
|
103
|
+
browserSessions?: {
|
|
104
|
+
closeAll(): Promise<void>;
|
|
105
|
+
};
|
|
100
106
|
}>;
|
|
101
107
|
/** Handle to a running per-session loop (metering or credit watch); `stop()` ends + drains it. */
|
|
102
108
|
export interface RunSessionHandle {
|
|
@@ -155,7 +161,7 @@ export interface ProgramWorkerDeps {
|
|
|
155
161
|
/** Emit the program's `console.*` output as `log` run-events while the body runs (optional —
|
|
156
162
|
* absent disables capture). Wired by the entrypoint to the batched telemetry publisher. */
|
|
157
163
|
onProgramLog?: (stream: LogStream, text: string) => void;
|
|
158
|
-
/**
|
|
164
|
+
/** Task ARN (or any stable worker identity). */
|
|
159
165
|
workerId: string;
|
|
160
166
|
/** Drain any buffered telemetry before the worker exits (brokered path's BrokerEventPublisher).
|
|
161
167
|
* Called by the worker entrypoint's cleanup; the orchestrator itself never invokes it. */
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
// aborted mid-flight — credit exhaustion — finalize failed with the abort reason).
|
|
12
12
|
//
|
|
13
13
|
// While the program runs, two per-session loops watch it (both brokered): a UsageFlusher meters token
|
|
14
|
-
// deltas
|
|
14
|
+
// deltas and a CreditWatcher polls the org's funding. When credit hits zero the watcher
|
|
15
15
|
// aborts the run's AbortSignal, which the WorkflowHost honors at every hook (cooperative
|
|
16
16
|
// cancellation, run_abort.ts) — `signal.aborted` is authoritative for the terminal status.
|
|
17
17
|
//
|
|
18
18
|
// What's gone vs. the old worker: no checkpoint load/pause, no resume, no sleep/wait_for_child
|
|
19
|
-
// pause outcomes (the program holds), no per-turn transcript. The
|
|
19
|
+
// pause outcomes (the program holds), no per-turn transcript. The agent loop now lives one
|
|
20
20
|
// level down, behind the host's agent() leaf.
|
|
21
21
|
//
|
|
22
22
|
// v0 deferral (a clear seam): run-level outcome validation (needs program output capture).
|
|
@@ -111,6 +111,7 @@ export async function runProgramWorker(runId, deps) {
|
|
|
111
111
|
return { kind: "failed", reason: "host_build_failed" };
|
|
112
112
|
}
|
|
113
113
|
const { host, redactor, workspace, phases, activity, setProgramDir, lsp, suspendSignal } = built;
|
|
114
|
+
const browserSessions = built.browserSessions;
|
|
114
115
|
// Guarantee the /workspace sandbox dir exists for EVERY run (persist or not) so a program can write
|
|
115
116
|
// to /workspace without a defensive mkdir. Runs before hydrate (whose extract targets the dir).
|
|
116
117
|
// Best-effort — the image pre-creates the dir; a failure here would resurface at the program's write.
|
|
@@ -130,16 +131,16 @@ export async function runProgramWorker(runId, deps) {
|
|
|
130
131
|
if (workspace !== undefined)
|
|
131
132
|
await workspace.hydrate();
|
|
132
133
|
// Token metering is PER-LEAF: each agent() leaf reports its own tokens + model to the broker, which
|
|
133
|
-
// decides `billed_by_boardwalk` per model + meters to
|
|
134
|
+
// decides `billed_by_boardwalk` per model + meters usage to the platform (see leaf_executor `meterUsage`). A
|
|
134
135
|
// workflow has no run-level model, so there is no run-level token metering here.
|
|
135
|
-
// Mid-run credit watching
|
|
136
|
+
// Mid-run credit watching: when the org runs out of credit, abort the run cooperatively.
|
|
136
137
|
const credit = deps.startCreditWatch?.({
|
|
137
138
|
run: claimed,
|
|
138
139
|
onExhausted: () => {
|
|
139
140
|
controller.abort(new RunAbortedError("credit_exhausted"));
|
|
140
141
|
},
|
|
141
142
|
});
|
|
142
|
-
// Mid-run user-cancel watching
|
|
143
|
+
// Mid-run user-cancel watching: when the user cancels, abort the run cooperatively. The host
|
|
143
144
|
// honors the abort at the next hook boundary (a `sleep` hold wakes immediately); the broker then
|
|
144
145
|
// upgrades the terminal write to `cancelled` because the run was flipped to `cancelling`.
|
|
145
146
|
const cancel = deps.startCancelWatch?.({
|
|
@@ -148,7 +149,7 @@ export async function runProgramWorker(runId, deps) {
|
|
|
148
149
|
controller.abort(new RunAbortedError("cancelled"));
|
|
149
150
|
},
|
|
150
151
|
});
|
|
151
|
-
// Lease renewal
|
|
152
|
+
// Lease renewal: heartbeat the lease so a long run isn't reclaimed mid-flight. If the lease is
|
|
152
153
|
// definitively lost (another worker reclaimed it), abort `lease_lost` — the run stops without
|
|
153
154
|
// finalizing (the new owner owns the terminal write; see the lease_lost guard after the body).
|
|
154
155
|
const lease = deps.startLeaseRenew?.({
|
|
@@ -157,7 +158,7 @@ export async function runProgramWorker(runId, deps) {
|
|
|
157
158
|
controller.abort(new RunAbortedError("lease_lost"));
|
|
158
159
|
},
|
|
159
160
|
});
|
|
160
|
-
// Runtime metering
|
|
161
|
+
// Runtime metering: flush runtime as periodic deltas from the claim, so a long/perpetual run
|
|
161
162
|
// bills as it burns (and the credit watcher sees it) instead of only at terminal. The tail is booked
|
|
162
163
|
// by `flushFinal()` after the body (on every path except a lease_lost handoff).
|
|
163
164
|
const runtimeFlush = deps.startRuntimeFlush?.({ run: claimed, startedAtMs: sessionStartMs });
|
|
@@ -208,6 +209,16 @@ export async function runProgramWorker(runId, deps) {
|
|
|
208
209
|
// is safe (it runs before the workspace snapshot, which is the slowest step).
|
|
209
210
|
if (lsp !== undefined)
|
|
210
211
|
await lsp.close();
|
|
212
|
+
// Reap every still-open browser session (kill Chromium + its Playwright MCP) on EVERY terminal
|
|
213
|
+
// path, before the workspace snapshot. Best-effort — a dead session must not mask the run's outcome.
|
|
214
|
+
if (browserSessions !== undefined) {
|
|
215
|
+
await browserSessions.closeAll().catch((err) => {
|
|
216
|
+
log.warn("browser_sessions_close_failed", {
|
|
217
|
+
runId,
|
|
218
|
+
error: err instanceof Error ? err.message : String(err),
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
211
222
|
// Snapshot the final /workspace so the workflow's NEXT run hydrates it. Best-effort.
|
|
212
223
|
if (workspace !== undefined) {
|
|
213
224
|
await workspace.persist();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// RecordingSecretResolver — the redaction feeder
|
|
1
|
+
// RecordingSecretResolver — the redaction feeder.
|
|
2
2
|
//
|
|
3
3
|
// Decorates any SecretResolver so EVERY value a run resolves — whether the workflow program asked
|
|
4
4
|
// via `secrets.get(name)` or a tool asked via `ctx.secrets.resolve(ref)` — is recorded into the
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// run_abort — the provider-agnostic cooperative-cancellation substrate for a run (the Runner Credential Broker model).
|
|
2
2
|
//
|
|
3
|
-
// The cancellation primitive is a Web-standard `AbortSignal` — NOT a
|
|
3
|
+
// The cancellation primitive is a Web-standard `AbortSignal` — NOT a framework/model concept. The worker
|
|
4
4
|
// owns one `AbortController` per run session; a watcher (credit, later user-cancel) calls
|
|
5
5
|
// `controller.abort(new RunAbortedError(reason))`. The WorkflowHost honors that signal at every hook
|
|
6
6
|
// boundary (`agent`/`sleep`/`workflows.call`/…), unwinding the program. The ONLY place that translates
|
|
7
|
-
// the signal into a model-specific stop is the
|
|
8
|
-
//
|
|
7
|
+
// the signal into a model-specific stop is the agent leaf (signal → the leaf's cancel path); a different
|
|
8
|
+
// leaf implementation honors the SAME `AbortSignal` its own way. So nothing here, in the host, or in the
|
|
9
9
|
// program-facing SDK depends on the model backend.
|
|
10
10
|
//
|
|
11
11
|
// `signal.aborted` is AUTHORITATIVE at the orchestrator: a program that catches RunAbortedError and
|
|
@@ -13,6 +13,10 @@ export interface RunnerControlClientConfig {
|
|
|
13
13
|
runId: string;
|
|
14
14
|
/** Injected fetch (defaults to global fetch). */
|
|
15
15
|
fetchImpl?: typeof fetch;
|
|
16
|
+
/** Per-call ceiling for short control calls (default 30s) — bounds a poll frozen mid-flight. */
|
|
17
|
+
controlTimeoutMs?: number;
|
|
18
|
+
/** Per-call ceiling for bulk artifact/workspace transfers (default 5 min). */
|
|
19
|
+
bulkTimeoutMs?: number;
|
|
16
20
|
}
|
|
17
21
|
/** The pinned program's download reference (the worker fetches + verifies + extracts it). */
|
|
18
22
|
export interface BrokerProgram {
|
|
@@ -47,9 +51,24 @@ export declare class RunnerControlClient {
|
|
|
47
51
|
/** The live bearer. Mutable: on the snapshot substrate a wake carries a FRESH run token (the
|
|
48
52
|
* frozen one expired while suspended) and the worker swaps it at runtime. */
|
|
49
53
|
private runToken;
|
|
54
|
+
private readonly controlTimeoutMs;
|
|
55
|
+
private readonly bulkTimeoutMs;
|
|
50
56
|
constructor(cfg: RunnerControlClientConfig);
|
|
51
57
|
/** Swap the bearer for a fresh run token (the wake path). Every subsequent call uses it. */
|
|
52
58
|
swapRunToken(token: string): void;
|
|
59
|
+
/**
|
|
60
|
+
* Every SHORT control call (claim / renew / cancel / credit / journal / …) goes through here so
|
|
61
|
+
* it carries a hard timeout. Without one, a poll frozen mid-flight on the snapshot substrate
|
|
62
|
+
* hangs FOREVER on restore (the socket is dead but never reset), and since a watcher serializes
|
|
63
|
+
* its ticks, one hung tick wedges that watcher — the dead-connections gotcha for the
|
|
64
|
+
* background pollers (lease/cancel/credit), which run on untracked timers the quiescence gate
|
|
65
|
+
* doesn't cover. Also plain robustness: no broker call should hang on a network blip. The
|
|
66
|
+
* streaming inference call is the ONE exception (long-lived NDJSON) and bypasses this.
|
|
67
|
+
*/
|
|
68
|
+
private controlFetch;
|
|
69
|
+
/** Bulk transfers (artifact + workspace up/download over presigned S3) — a much larger ceiling
|
|
70
|
+
* than a control call, but still bounded so a dead socket can't hang the run. */
|
|
71
|
+
private bulkFetch;
|
|
53
72
|
/** Claim the run's lease. Returns the run on success, or null when it isn't claimable (409 —
|
|
54
73
|
* another worker has it, or it isn't pending), which the worker treats as "claim lost". */
|
|
55
74
|
claim(workerId: string, leaseSeconds: number): Promise<{
|
|
@@ -89,8 +108,8 @@ export declare class RunnerControlClient {
|
|
|
89
108
|
* retried/duplicate flush idempotent; distinct per-flush ids sum into the run's runtime total. */
|
|
90
109
|
reportUsage(runtimeSeconds: number, identifier: string): Promise<void>;
|
|
91
110
|
/** Report a token-usage delta for incremental in-run metering (the usage flusher → broker). The
|
|
92
|
-
* broker gates on the run's per-connection `billed_by_boardwalk` server-side + meters to
|
|
93
|
-
* `identifier` makes a retried/duplicate flush idempotent. Satisfies {@link TokenUsageReporter}. */
|
|
111
|
+
* broker gates on the run's per-connection `billed_by_boardwalk` server-side + meters usage to the
|
|
112
|
+
* platform; `identifier` makes a retried/duplicate flush idempotent. Satisfies {@link TokenUsageReporter}. */
|
|
94
113
|
meterTokens(input: {
|
|
95
114
|
inputTokens: number;
|
|
96
115
|
outputTokens: number;
|
|
@@ -101,14 +120,20 @@ export declare class RunnerControlClient {
|
|
|
101
120
|
cachedWriteTokens?: number;
|
|
102
121
|
}): Promise<void>;
|
|
103
122
|
/** Check whether the run's org is still funded (the CreditWatcher → broker). The broker reads the
|
|
104
|
-
* live
|
|
123
|
+
* live billing balance server-side; `false` means out of credit (the watcher then aborts the run). */
|
|
105
124
|
checkCredit(): Promise<boolean>;
|
|
106
125
|
/** Check whether the run has been asked to cancel (the CancelWatcher → broker). `true` once the user
|
|
107
126
|
* cancelled the run (the broker flipped it to `cancelling`/`cancelled`); the watcher then aborts the
|
|
108
127
|
* run. Brokered because the runner holds no DB/Redis — this replaces the unreachable Redis channel. */
|
|
109
128
|
checkCancelled(): Promise<boolean>;
|
|
129
|
+
/** Register-without-release: register a HELD HITL gate's request row
|
|
130
|
+
* so it is answerable while the run keeps running — no suspend. Idempotent. Returns whether a new
|
|
131
|
+
* gate was registered. */
|
|
132
|
+
registerInput(seq: number, gate: unknown): Promise<boolean>;
|
|
133
|
+
/** Poll the resolved answers for a held gate at `seq` (empty until a human responds). */
|
|
134
|
+
pollInputAnswers(seq: number): Promise<Record<string, unknown>>;
|
|
110
135
|
/** Mint a presigned GET URL to restore this workflow's last `/workspace` snapshot (workspace
|
|
111
|
-
* persistence
|
|
136
|
+
* persistence). `null` when the run isn't eligible (not opted-in, or self-hosted). */
|
|
112
137
|
workspaceHydrateUrl(): Promise<string | null>;
|
|
113
138
|
/** Mint a presigned PUT URL to snapshot this workflow's `/workspace` (the worker uploads the tarball
|
|
114
139
|
* straight to S3). `null` when the run isn't eligible. `sizeBytes` is the archive's on-disk size
|