@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.
Files changed (97) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +68 -0
  3. package/dist/bin.d.ts +2 -0
  4. package/dist/bin.js +214 -0
  5. package/dist/contract.d.ts +154 -0
  6. package/dist/contract.js +196 -0
  7. package/dist/daemon/daemon.d.ts +45 -0
  8. package/dist/daemon/daemon.js +129 -0
  9. package/dist/daemon/identity.d.ts +15 -0
  10. package/dist/daemon/identity.js +44 -0
  11. package/dist/daemon/index.d.ts +3 -0
  12. package/dist/daemon/index.js +4 -0
  13. package/dist/daemon/pool_client.d.ts +31 -0
  14. package/dist/daemon/pool_client.js +101 -0
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +8 -0
  17. package/dist/runtime/agent/budget.d.ts +123 -0
  18. package/dist/runtime/agent/budget.js +174 -0
  19. package/dist/runtime/agent/events.d.ts +46 -0
  20. package/dist/runtime/agent/events.js +8 -0
  21. package/dist/runtime/agent/model_rates.d.ts +7 -0
  22. package/dist/runtime/agent/model_rates.js +21 -0
  23. package/dist/runtime/agent/secret_redactor.d.ts +42 -0
  24. package/dist/runtime/agent/secret_redactor.js +98 -0
  25. package/dist/runtime/broker_artifact_store.d.ts +17 -0
  26. package/dist/runtime/broker_artifact_store.js +46 -0
  27. package/dist/runtime/broker_child_dispatcher.d.ts +31 -0
  28. package/dist/runtime/broker_child_dispatcher.js +88 -0
  29. package/dist/runtime/broker_event_publisher.d.ts +25 -0
  30. package/dist/runtime/broker_event_publisher.js +69 -0
  31. package/dist/runtime/broker_tool_host.d.ts +49 -0
  32. package/dist/runtime/broker_tool_host.js +240 -0
  33. package/dist/runtime/cancel_watcher.d.ts +31 -0
  34. package/dist/runtime/cancel_watcher.js +77 -0
  35. package/dist/runtime/credit_watcher.d.ts +30 -0
  36. package/dist/runtime/credit_watcher.js +78 -0
  37. package/dist/runtime/direct_inference.d.ts +29 -0
  38. package/dist/runtime/direct_inference.js +78 -0
  39. package/dist/runtime/index.d.ts +85 -0
  40. package/dist/runtime/index.js +525 -0
  41. package/dist/runtime/inference_transport.d.ts +5 -0
  42. package/dist/runtime/inference_transport.js +9 -0
  43. package/dist/runtime/leaf_executor.d.ts +104 -0
  44. package/dist/runtime/leaf_executor.js +333 -0
  45. package/dist/runtime/lease_renewer.d.ts +31 -0
  46. package/dist/runtime/lease_renewer.js +80 -0
  47. package/dist/runtime/main.d.ts +1 -0
  48. package/dist/runtime/main.js +7 -0
  49. package/dist/runtime/phase_tracker.d.ts +26 -0
  50. package/dist/runtime/phase_tracker.js +45 -0
  51. package/dist/runtime/program_log_capture.d.ts +20 -0
  52. package/dist/runtime/program_log_capture.js +79 -0
  53. package/dist/runtime/program_runner.d.ts +82 -0
  54. package/dist/runtime/program_runner.js +116 -0
  55. package/dist/runtime/program_worker.d.ts +177 -0
  56. package/dist/runtime/program_worker.js +272 -0
  57. package/dist/runtime/recording_secret_resolver.d.ts +9 -0
  58. package/dist/runtime/recording_secret_resolver.js +22 -0
  59. package/dist/runtime/run_abort.d.ts +16 -0
  60. package/dist/runtime/run_abort.js +38 -0
  61. package/dist/runtime/run_event_emitter.d.ts +24 -0
  62. package/dist/runtime/run_event_emitter.js +63 -0
  63. package/dist/runtime/runner_control_client.d.ts +184 -0
  64. package/dist/runtime/runner_control_client.js +501 -0
  65. package/dist/runtime/runtime_flusher.d.ts +41 -0
  66. package/dist/runtime/runtime_flusher.js +90 -0
  67. package/dist/runtime/sandbox_config.d.ts +21 -0
  68. package/dist/runtime/sandbox_config.js +34 -0
  69. package/dist/runtime/support/index.d.ts +53 -0
  70. package/dist/runtime/support/index.js +114 -0
  71. package/dist/runtime/suspension.d.ts +141 -0
  72. package/dist/runtime/suspension.js +120 -0
  73. package/dist/runtime/testing_artifact_build.d.ts +27 -0
  74. package/dist/runtime/testing_artifact_build.js +96 -0
  75. package/dist/runtime/tools/artifacts.d.ts +146 -0
  76. package/dist/runtime/tools/artifacts.js +125 -0
  77. package/dist/runtime/tools/types.d.ts +114 -0
  78. package/dist/runtime/tools/types.js +61 -0
  79. package/dist/runtime/tools/web_search.d.ts +66 -0
  80. package/dist/runtime/tools/web_search.js +165 -0
  81. package/dist/runtime/wire/artifact_storage.d.ts +37 -0
  82. package/dist/runtime/wire/artifact_storage.js +93 -0
  83. package/dist/runtime/wire/artifact_verify.d.ts +1 -0
  84. package/dist/runtime/wire/artifact_verify.js +8 -0
  85. package/dist/runtime/wire/human_input.d.ts +62 -0
  86. package/dist/runtime/wire/human_input.js +156 -0
  87. package/dist/runtime/wire/inference_proxy.d.ts +70 -0
  88. package/dist/runtime/wire/inference_proxy.js +157 -0
  89. package/dist/runtime/wire/manifest.d.ts +16 -0
  90. package/dist/runtime/wire/manifest.js +18 -0
  91. package/dist/runtime/wire/run.d.ts +54 -0
  92. package/dist/runtime/wire/run.js +2 -0
  93. package/dist/runtime/workflow_host.d.ts +194 -0
  94. package/dist/runtime/workflow_host.js +382 -0
  95. package/dist/runtime/workspace_store.d.ts +73 -0
  96. package/dist/runtime/workspace_store.js +118 -0
  97. 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;