@boardwalk-labs/runner 0.2.16 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/dist/bin.js +5 -0
  3. package/dist/runtime/agent/budget.d.ts +43 -24
  4. package/dist/runtime/agent/budget.js +108 -57
  5. package/dist/runtime/agent/events.d.ts +1 -1
  6. package/dist/runtime/broker_child_dispatcher.d.ts +5 -2
  7. package/dist/runtime/broker_child_dispatcher.js +24 -6
  8. package/dist/runtime/budget_gate.d.ts +58 -21
  9. package/dist/runtime/budget_gate.js +227 -46
  10. package/dist/runtime/host_capabilities.d.ts +4 -0
  11. package/dist/runtime/host_capabilities.js +25 -0
  12. package/dist/runtime/host_server.d.ts +137 -0
  13. package/dist/runtime/host_server.js +562 -0
  14. package/dist/runtime/identity_relay.js +19 -9
  15. package/dist/runtime/index.d.ts +38 -0
  16. package/dist/runtime/index.js +109 -35
  17. package/dist/runtime/leaf_executor.d.ts +7 -4
  18. package/dist/runtime/leaf_executor.js +11 -6
  19. package/dist/runtime/program_runner.d.ts +62 -42
  20. package/dist/runtime/program_runner.js +156 -101
  21. package/dist/runtime/program_worker.d.ts +22 -11
  22. package/dist/runtime/program_worker.js +26 -48
  23. package/dist/runtime/python_program.d.ts +68 -0
  24. package/dist/runtime/python_program.js +270 -0
  25. package/dist/runtime/run_context.d.ts +13 -0
  26. package/dist/runtime/run_context.js +77 -0
  27. package/dist/runtime/runner_control_client.d.ts +23 -0
  28. package/dist/runtime/runner_control_client.js +69 -147
  29. package/dist/runtime/screen_capture_backend.d.ts +3 -0
  30. package/dist/runtime/screen_capture_backend.js +17 -2
  31. package/dist/runtime/shell_exec.d.ts +17 -0
  32. package/dist/runtime/shell_exec.js +144 -0
  33. package/dist/runtime/support/index.d.ts +18 -0
  34. package/dist/runtime/support/index.js +38 -6
  35. package/dist/runtime/wire/inference_proxy.js +1 -1
  36. package/dist/runtime/wire/run.d.ts +5 -2
  37. package/dist/runtime/workflow_host.d.ts +53 -7
  38. package/dist/runtime/workflow_host.js +82 -42
  39. package/package.json +4 -3
@@ -72,6 +72,43 @@ export class RunnerControlClient {
72
72
  bulkFetch(url, init) {
73
73
  return this.retryingFetch(url, init, this.bulkTimeoutMs);
74
74
  }
75
+ // The three shapes every UNIFORM control endpoint reduces to. Endpoints where a specific
76
+ // status is a meaningful answer (claim/renew 409, version/child 404, mcp/token 403, the
77
+ // no-body hydrate-url POST, and the streaming inference call) keep their own fetch logic.
78
+ // `op` is the error/log label passed to {@link brokerError} (it sometimes differs from the path).
79
+ /** GET a run-scoped control path; any status but 200 throws. The `as T` is the client's one
80
+ * trust-boundary cast: the broker is the platform's own API and each caller names the
81
+ * handler's documented reply shape. */
82
+ async getJson(op, suffix) {
83
+ const res = await this.controlFetch(this.url(suffix), {
84
+ method: "GET",
85
+ headers: this.headers(false),
86
+ });
87
+ if (res.status !== 200)
88
+ throw await brokerError(res, op);
89
+ return (await res.json());
90
+ }
91
+ /** POST a JSON body to a run-scoped control path; any status but `expect` throws. */
92
+ async postJson(op, suffix, body, expect = 200) {
93
+ const res = await this.controlFetch(this.url(suffix), {
94
+ method: "POST",
95
+ headers: this.headers(true),
96
+ body: JSON.stringify(body),
97
+ });
98
+ if (res.status !== expect)
99
+ throw await brokerError(res, op);
100
+ return (await res.json());
101
+ }
102
+ /** POST a JSON body to a run-scoped control path expecting an empty 204 reply. */
103
+ async postVoid(op, suffix, body) {
104
+ const res = await this.controlFetch(this.url(suffix), {
105
+ method: "POST",
106
+ headers: this.headers(true),
107
+ body: JSON.stringify(body),
108
+ });
109
+ if (res.status !== 204)
110
+ throw await brokerError(res, op);
111
+ }
75
112
  /**
76
113
  * One attempt per entry in the backoff schedule (+1): retry thrown network failures (connection
77
114
  * reset/refused mid-rollover, our own per-attempt timeout on a dead socket) and the
@@ -127,6 +164,8 @@ export class RunnerControlClient {
127
164
  return {
128
165
  run: body.run,
129
166
  lastEventCursor: body.lastEventCursor ?? 0,
167
+ ...(body.workflowVersion !== undefined ? { workflowVersion: body.workflowVersion } : {}),
168
+ ...(body.environment !== undefined ? { environment: body.environment } : {}),
130
169
  };
131
170
  }
132
171
  /** Heartbeat: extend our lease so a long run isn't reclaimed mid-flight. Returns the new
@@ -149,13 +188,7 @@ export class RunnerControlClient {
149
188
  * whose lease expired and whose run was reclaimed + re-dispatched to a new owner), so a
150
189
  * hung/partitioned worker that later recovers can't clobber the live run or revive a terminal one. */
151
190
  async finalize(status, output, workerId) {
152
- const res = await this.controlFetch(this.url("finalize"), {
153
- method: "POST",
154
- headers: this.headers(true),
155
- body: JSON.stringify({ status, output, workerId }),
156
- });
157
- if (res.status !== 204)
158
- throw await brokerError(res, "finalize");
191
+ await this.postVoid("finalize", "finalize", { status, output, workerId });
159
192
  }
160
193
  /** Fetch the run's pinned manifest + program source, or null when the version is missing (404). */
161
194
  async getVersion() {
@@ -172,71 +205,40 @@ export class RunnerControlClient {
172
205
  /** Book a runtime-seconds DELTA (the worker's RuntimeFlusher → broker). `identifier` makes a
173
206
  * retried/duplicate flush idempotent; distinct per-flush ids sum into the run's runtime total. */
174
207
  async reportUsage(runtimeSeconds, identifier) {
175
- const res = await this.controlFetch(this.url("usage"), {
176
- method: "POST",
177
- headers: this.headers(true),
178
- body: JSON.stringify({ runtimeSeconds, identifier }),
179
- });
180
- if (res.status !== 204)
181
- throw await brokerError(res, "usage");
208
+ await this.postVoid("usage", "usage", { runtimeSeconds, identifier });
182
209
  }
183
210
  /** Report a token-usage delta for incremental in-run metering (the usage flusher → broker). The
184
211
  * broker gates on the run's per-connection `billed_by_boardwalk` server-side + meters usage to the
185
212
  * platform; `identifier` makes a retried/duplicate flush idempotent. Satisfies {@link TokenUsageReporter}. */
186
213
  async meterTokens(input) {
187
- const res = await this.controlFetch(this.url("usage/tokens"), {
188
- method: "POST",
189
- headers: this.headers(true),
190
- body: JSON.stringify(input),
191
- });
192
- if (res.status !== 204)
193
- throw await brokerError(res, "usage/tokens");
214
+ await this.postVoid("usage/tokens", "usage/tokens", input);
194
215
  }
195
216
  /** Check whether the run's org is still funded (the CreditWatcher → broker). The broker reads the
196
217
  * live billing balance server-side; `false` means out of credit (the watcher then aborts the run). */
197
218
  async checkCredit() {
198
- const res = await this.controlFetch(this.url("credit"), {
199
- method: "GET",
200
- headers: this.headers(false),
201
- });
202
- if (res.status !== 200)
203
- throw await brokerError(res, "credit");
204
- return (await res.json()).funded;
219
+ return (await this.getJson("credit", "credit")).funded;
205
220
  }
206
221
  /** Check whether the run has been asked to cancel (the CancelWatcher → broker). `true` once the user
207
222
  * cancelled the run (the broker flipped it to `cancelling`/`cancelled`); the watcher then aborts the
208
223
  * run. Brokered because the runner holds no DB/Redis — this replaces the unreachable Redis channel. */
209
224
  async checkCancelled() {
210
- const res = await this.controlFetch(this.url("cancel"), {
211
- method: "GET",
212
- headers: this.headers(false),
213
- });
214
- if (res.status !== 200)
215
- throw await brokerError(res, "cancel");
216
- return (await res.json()).cancelRequested;
225
+ return (await this.getJson("cancel", "cancel")).cancelRequested;
217
226
  }
218
227
  /** Register-without-release: register a HELD HITL gate's request row
219
228
  * so it is answerable while the run keeps running — no suspend. Idempotent. Returns whether a new
220
229
  * gate was registered. */
221
230
  async registerInput(seq, gate) {
222
- const res = await this.controlFetch(this.url("inputs"), {
223
- method: "POST",
224
- headers: this.headers(true),
225
- body: JSON.stringify({ seq, humanInput: gate }),
231
+ const body = await this.postJson("register-input", "inputs", {
232
+ seq,
233
+ humanInput: gate,
226
234
  });
227
- if (res.status !== 200)
228
- throw await brokerError(res, "register-input");
229
- return (await res.json()).registered;
235
+ return body.registered;
230
236
  }
231
237
  /** Poll the resolved answers for a held gate at `seq` (empty until a human responds). */
232
238
  async pollInputAnswers(seq) {
233
- const res = await this.controlFetch(this.url(`inputs/${encodeURIComponent(String(seq))}`), {
234
- method: "GET",
235
- headers: this.headers(false),
236
- });
237
- if (res.status !== 200)
238
- throw await brokerError(res, "poll-inputs");
239
- return (await res.json()).answers;
239
+ const suffix = `inputs/${encodeURIComponent(String(seq))}`;
240
+ return (await this.getJson("poll-inputs", suffix))
241
+ .answers;
240
242
  }
241
243
  /** Mint a presigned GET URL to restore this workflow's last `/workspace` snapshot (workspace
242
244
  * persistence). `null` when the run isn't eligible (not opted-in, or self-hosted). */
@@ -254,14 +256,7 @@ export class RunnerControlClient {
254
256
  * (the worker tars BEFORE requesting the URL) — the broker records it for the org storage counter +
255
257
  * daily meter; the snapshot overwrites one per-workflow key, so it's the workflow's full footprint. */
256
258
  async workspacePersistUrl(sizeBytes) {
257
- const res = await this.controlFetch(this.url("workspace/persist-url"), {
258
- method: "POST",
259
- headers: this.headers(true),
260
- body: JSON.stringify({ sizeBytes }),
261
- });
262
- if (res.status !== 200)
263
- throw await brokerError(res, "workspace/persist-url");
264
- const body = (await res.json());
259
+ const body = await this.postJson("workspace/persist-url", "workspace/persist-url", { sizeBytes });
265
260
  return body.url === null ? null : { url: body.url, contentType: body.contentType ?? "" };
266
261
  }
267
262
  /** Download bytes from a presigned S3 URL (workspace hydrate). `null` on 404 (no snapshot yet —
@@ -278,74 +273,37 @@ export class RunnerControlClient {
278
273
  * third-party-verifiable token (gated server-side on `permissions.id_token: "write"`) — used to
279
274
  * federate into the org's OWN cloud (AWS/GCP). DIFFERENT from this client's run token. */
280
275
  async requestOidcToken(audience) {
281
- const res = await this.controlFetch(this.url("oidc/token"), {
282
- method: "POST",
283
- headers: this.headers(true),
284
- body: JSON.stringify({ audience }),
276
+ return this.postJson("oidc/token", "oidc/token", {
277
+ audience,
285
278
  });
286
- if (res.status !== 200)
287
- throw await brokerError(res, "oidc/token");
288
- return (await res.json());
289
279
  }
290
280
  /** Publish a batch of live agent-event frames (the SSE live-tail source) — the broker publishes
291
281
  * them to the run's Redis channel server-side, so the runner holds no Redis credential. */
292
282
  async publishTelemetry(frames) {
293
- const res = await this.controlFetch(this.url("telemetry"), {
294
- method: "POST",
295
- headers: this.headers(true),
296
- body: JSON.stringify({ frames }),
297
- });
298
- if (res.status !== 204)
299
- throw await brokerError(res, "telemetry");
283
+ await this.postVoid("telemetry", "telemetry", { frames });
300
284
  }
301
285
  /** Push a batch of encoded desktop frames (base64 JPEG) for the live-view surface — the broker
302
286
  * republishes them to the run's live-view channel server-side (never durably stored; the session
303
287
  * recording is the durable copy). See docs/SCREEN_CAPTURE.md §5. */
304
288
  async publishLiveView(frames) {
305
- const res = await this.controlFetch(this.url("liveview"), {
306
- method: "POST",
307
- headers: this.headers(true),
308
- body: JSON.stringify({ frames }),
309
- });
310
- if (res.status !== 204)
311
- throw await brokerError(res, "liveview");
289
+ await this.postVoid("liveview", "liveview", { frames });
312
290
  }
313
291
  /** Is a browser currently watching this run's live-view? The capture loop polls this so it only
314
292
  * captures + pushes frames while someone is attached (capture costs guest CPU + metered egress). */
315
293
  async liveViewWanted() {
316
- const res = await this.controlFetch(this.url("liveview/wanted"), {
317
- method: "GET",
318
- headers: this.headers(false),
319
- });
320
- if (res.status !== 200)
321
- throw await brokerError(res, "liveview/wanted");
322
- const body = (await res.json());
294
+ const body = await this.getJson("liveview/wanted", "liveview/wanted");
323
295
  return body.wanted === true;
324
296
  }
325
297
  /** Store a run artifact through the broker (which holds the S3 credential + neutralizes the served
326
298
  * content type server-side). Returns the catalog id + a signed download URL. */
327
299
  async writeArtifact(input) {
328
- const res = await this.controlFetch(this.url("artifacts"), {
329
- method: "POST",
330
- headers: this.headers(true),
331
- body: JSON.stringify(input),
332
- });
333
- if (res.status !== 201)
334
- throw await brokerError(res, "artifacts");
335
- return (await res.json());
300
+ return this.postJson("artifacts", "artifacts", input, 201);
336
301
  }
337
302
  /** Phase 1 of the LARGE-artifact path (the Runner Credential Broker model): presign an S3 PUT. The
338
303
  * broker derives the S3 key + neutralizes/pins the served content type; it returns the upload URL +
339
304
  * required headers + the `s3Key` to echo back at commit. No catalog row exists yet. */
340
305
  async presignArtifact(input) {
341
- const res = await this.controlFetch(this.url("artifacts/presign"), {
342
- method: "POST",
343
- headers: this.headers(true),
344
- body: JSON.stringify(input),
345
- });
346
- if (res.status !== 201)
347
- throw await brokerError(res, "artifacts/presign");
348
- return (await res.json());
306
+ return this.postJson("artifacts/presign", "artifacts/presign", input, 201);
349
307
  }
350
308
  /** Upload bytes to a presigned S3 URL (the large-artifact path). The `headers` come from the
351
309
  * presign response and MUST be sent verbatim — the content type is pinned into the signature, so
@@ -360,47 +318,24 @@ export class RunnerControlClient {
360
318
  * broker re-validates the run prefix + re-neutralizes the content type, then returns the catalog id
361
319
  * + a signed download URL. */
362
320
  async commitArtifact(input) {
363
- const res = await this.controlFetch(this.url("artifacts/commit"), {
364
- method: "POST",
365
- headers: this.headers(true),
366
- body: JSON.stringify(input),
367
- });
368
- if (res.status !== 201)
369
- throw await brokerError(res, "artifacts/commit");
370
- return (await res.json());
321
+ return this.postJson("artifacts/commit", "artifacts/commit", input, 201);
371
322
  }
372
323
  /** List the artifacts this run has produced. */
373
324
  async listArtifacts() {
374
- const res = await this.controlFetch(this.url("artifacts"), {
375
- method: "GET",
376
- headers: this.headers(false),
377
- });
378
- if (res.status !== 200)
379
- throw await brokerError(res, "artifacts-list");
380
- return (await res.json()).artifacts;
325
+ return (await this.getJson("artifacts-list", "artifacts"))
326
+ .artifacts;
381
327
  }
382
328
  /** Mint a fresh signed download URL for one of this run's artifacts. */
383
329
  async signArtifactUrl(artifactId, ttlSeconds) {
384
- const res = await this.controlFetch(this.url(`artifacts/${encodeURIComponent(artifactId)}/signed-url`), {
385
- method: "POST",
386
- headers: this.headers(true),
387
- body: JSON.stringify({ ttlSeconds }),
388
- });
389
- if (res.status !== 200)
390
- throw await brokerError(res, "artifacts-sign");
391
- return (await res.json());
330
+ const suffix = `artifacts/${encodeURIComponent(artifactId)}/signed-url`;
331
+ return this.postJson("artifacts-sign", suffix, { ttlSeconds });
392
332
  }
393
333
  /** Resolve an org secret the run's manifest allows (the program's `secrets.get`). The broker
394
334
  * enforces the allowlist + returns the value; a forbidden/missing secret surfaces as a throw. */
395
335
  async resolveSecret(name) {
396
- const res = await this.controlFetch(this.url("secrets/resolve"), {
397
- method: "POST",
398
- headers: this.headers(true),
399
- body: JSON.stringify({ name }),
336
+ const body = await this.postJson("secrets/resolve", "secrets/resolve", {
337
+ name,
400
338
  });
401
- if (res.status !== 200)
402
- throw await brokerError(res, "secrets/resolve");
403
- const body = (await res.json());
404
339
  return body.value;
405
340
  }
406
341
  /** Broker a short-lived OAuth bearer for a hosted MCP server (the engine's `mcpToken` hook, called
@@ -427,14 +362,7 @@ export class RunnerControlClient {
427
362
  /** Proxy a web_search through the broker (which holds the Tavily key) — the runner sends the
428
363
  * query, the broker calls Tavily and returns the results. */
429
364
  async webSearch(input) {
430
- const res = await this.controlFetch(this.url("tools/web_search"), {
431
- method: "POST",
432
- headers: this.headers(true),
433
- body: JSON.stringify(input),
434
- });
435
- if (res.status !== 200)
436
- throw await brokerError(res, "tools/web_search");
437
- return (await res.json());
365
+ return this.postJson("tools/web_search", "tools/web_search", input);
438
366
  }
439
367
  /** Create (or idempotently re-attach to) a child run for `workflows.call`. */
440
368
  async startChild(slug, input) {
@@ -450,14 +378,8 @@ export class RunnerControlClient {
450
378
  }
451
379
  /** Provision a durable schedule for `workflows.schedule`; returns the new schedule's id. */
452
380
  async scheduleWorkflow(slug, input, spec) {
453
- const res = await this.controlFetch(this.url("schedules"), {
454
- method: "POST",
455
- headers: this.headers(true),
456
- body: JSON.stringify({ slug, input, ...spec }),
457
- });
458
- if (res.status !== 201)
459
- throw await brokerError(res, "schedules");
460
- return (await res.json()).scheduleId;
381
+ const body = await this.postJson("schedules", "schedules", { slug, input, ...spec }, 201);
382
+ return body.scheduleId;
461
383
  }
462
384
  /** Poll a child run's status/output, or null when it isn't this run's child (404). */
463
385
  async getChild(childRunId) {
@@ -16,4 +16,7 @@ export interface CaptureConfig {
16
16
  }
17
17
  /** Read the capture config from env, or null when the desktop stack is absent or recording is off. */
18
18
  export declare function loadCaptureConfig(env: NodeJS.ProcessEnv): CaptureConfig | null;
19
+ /** ffmpeg args: one x11grab input, two outputs (change-driven segmented MP4 + a single overwritten
20
+ * JPEG). Exported for unit testing. */
21
+ export declare function ffmpegArgs(cfg: CaptureConfig, dir: string): string[];
19
22
  export declare function makeCaptureBackend(cfg: CaptureConfig): CaptureBackend;
@@ -2,6 +2,11 @@
2
2
  // the X display `:0` with two outputs — rolling MP4 segments (recording) and a single low-fps JPEG that
3
3
  // is continuously overwritten (the live-view frame). See docs/SCREEN_CAPTURE.md §4.
4
4
  //
5
+ // The recording is CHANGE-DRIVEN (mpdecimate + VFR): it stays always-on but only encodes frames that
6
+ // differ from the previous one, so an idle/headless desktop encodes ~nothing (a blank desktop collapses
7
+ // to ~1 frame) while a changing screen records in full. This keeps the always-on desktop cheap enough
8
+ // to run on every VM without a "record only when watched" gate.
9
+ //
5
10
  // Only runs where the runner IMAGE ships the desktop stack (Xvfb + ffmpeg), gated by
6
11
  // BOARDWALK_BROWSER_TIER=1 (the same "desktop present" signal the browser tier uses) + a
7
12
  // BOARDWALK_RECORDING_ENABLED kill switch (default on). Off on Fargate / self-hosted images with no
@@ -42,8 +47,9 @@ export function loadCaptureConfig(env) {
42
47
  wantedPollIntervalMs: intFromEnv(env.BOARDWALK_LIVEVIEW_WANTED_POLL_MS, 3000),
43
48
  };
44
49
  }
45
- /** ffmpeg args: one x11grab input, two outputs (segmented MP4 + a single overwritten JPEG). */
46
- function ffmpegArgs(cfg, dir) {
50
+ /** ffmpeg args: one x11grab input, two outputs (change-driven segmented MP4 + a single overwritten
51
+ * JPEG). Exported for unit testing. */
52
+ export function ffmpegArgs(cfg, dir) {
47
53
  const liveFps = Math.max(1, Math.round(1000 / cfg.liveFrameIntervalMs));
48
54
  return [
49
55
  "-y",
@@ -58,8 +64,17 @@ function ffmpegArgs(cfg, dir) {
58
64
  "-i",
59
65
  cfg.display,
60
66
  // Recording: H.264 MP4 segments, each a standalone playable file (docs/SCREEN_CAPTURE.md §4.2).
67
+ // Change-driven: `mpdecimate` drops near-duplicate frames and `-fps_mode vfr` lets the muxer honor
68
+ // those drops, so a static/idle desktop encodes ~nothing (collapses to ~1 frame) while a changing
69
+ // screen still records in full. Always-on — cost tracks on-screen activity, not a flat framerate.
70
+ // (`-preset ultrafast` would cut per-frame CPU further on busy screens but inflates file size; kept
71
+ // `veryfast` so the change is a pure win with no storage/bandwidth regression.)
61
72
  "-map",
62
73
  "0:v",
74
+ "-vf",
75
+ "mpdecimate",
76
+ "-fps_mode",
77
+ "vfr",
63
78
  "-c:v",
64
79
  "libx264",
65
80
  "-preset",
@@ -0,0 +1,17 @@
1
+ import type { ShellResult } from "@boardwalk-labs/workflow/runtime";
2
+ /** Default cap on captured stdout/stderr bytes (matches the SDK's documented 16 MiB). */
3
+ export declare const SHELL_DEFAULT_MAX_BUFFER: number;
4
+ export interface ShellExecOptions {
5
+ cwd?: string | undefined;
6
+ env?: Record<string, string> | undefined;
7
+ timeoutMs?: number | undefined;
8
+ maxBuffer?: number | undefined;
9
+ }
10
+ export interface ShellExecDeps {
11
+ /** The run's workspace root — the default cwd, and the base a relative `cwd` resolves against. */
12
+ workspaceRoot: string;
13
+ /** The run's cooperative-cancellation signal: an abort kills the command and REJECTS. */
14
+ signal?: AbortSignal | undefined;
15
+ }
16
+ /** Run one shell command to completion (see the module header for the resolve/reject contract). */
17
+ export declare function runShell(cmd: string, opts: ShellExecOptions | undefined, deps: ShellExecDeps): Promise<ShellResult>;
@@ -0,0 +1,144 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // runShell — the host-side backing for the protocol's `shell` capability.
3
+ //
4
+ // The old SDK ran `execSync` inside the program process; under the host protocol the program is
5
+ // a protocol client, so the command runs HERE (same VM, same trust domain — the program layer is
6
+ // trusted) and the completed result crosses the wire. Contract (sdk shell.ts / protocol.ts):
7
+ // resolve `{exitCode, stdout, stderr}` — a non-zero exit RESOLVES (data to branch on, never
8
+ // thrown); only a command that could not run at all, an exceeded output buffer, or a cancelled
9
+ // run rejects. A command killed by the `timeoutMs` option resolves with the shell convention
10
+ // `128 + signal number` (SIGTERM ⇒ 143), the same exit code a shell would report.
11
+ //
12
+ // cwd defaults to the run's workspace root (the same dir `agent({ cwd })` resolves against);
13
+ // `env` merges OVER the process env — the author owns process.env outright, and platform
14
+ // credentials were scrubbed from it at bootstrap, so inheriting it leaks nothing.
15
+ import { spawn } from "node:child_process";
16
+ import { isAbsolute, join } from "node:path";
17
+ import { throwIfAborted } from "./run_abort.js";
18
+ /** Default cap on captured stdout/stderr bytes (matches the SDK's documented 16 MiB). */
19
+ export const SHELL_DEFAULT_MAX_BUFFER = 16 * 1024 * 1024;
20
+ /** SIGKILL follows this long after a kill's SIGTERM if the process ignores it. */
21
+ const SHELL_KILL_GRACE_MS = 5_000;
22
+ const SIGNAL_EXIT_CODES = {
23
+ SIGHUP: 129,
24
+ SIGINT: 130,
25
+ SIGQUIT: 131,
26
+ SIGKILL: 137,
27
+ SIGTERM: 143,
28
+ };
29
+ /** Run one shell command to completion (see the module header for the resolve/reject contract). */
30
+ export function runShell(cmd, opts, deps) {
31
+ throwIfAborted(deps.signal);
32
+ const cwd = opts?.cwd === undefined
33
+ ? deps.workspaceRoot
34
+ : isAbsolute(opts.cwd)
35
+ ? opts.cwd
36
+ : join(deps.workspaceRoot, opts.cwd);
37
+ return new Promise((resolve, reject) => {
38
+ const child = spawn(cmd, {
39
+ shell: true,
40
+ cwd,
41
+ env: opts?.env === undefined ? process.env : { ...process.env, ...opts.env },
42
+ stdio: ["ignore", "pipe", "pipe"],
43
+ // Own process GROUP: `sh -c` may not exec-optimize, leaving the real command a GRANDCHILD
44
+ // holding the stdio pipes — killing only the shell would leak it and `close` would not
45
+ // fire until the grandchild exits. Group-kill reaches the whole tree. (Lockstep with the
46
+ // engine's src/run/shell_exec.ts.)
47
+ detached: true,
48
+ });
49
+ const maxBuffer = opts?.maxBuffer ?? SHELL_DEFAULT_MAX_BUFFER;
50
+ let stdout = "";
51
+ let stderr = "";
52
+ let settledReason = null;
53
+ let killTimer = null;
54
+ let graceTimer = null;
55
+ const killTree = (signal) => {
56
+ // Negative pid = the process group (see `detached`); fall back to the direct child when
57
+ // the group is already gone.
58
+ try {
59
+ if (child.pid !== undefined)
60
+ process.kill(-child.pid, signal);
61
+ else
62
+ child.kill(signal);
63
+ }
64
+ catch {
65
+ child.kill(signal);
66
+ }
67
+ };
68
+ const kill = (reason) => {
69
+ if (settledReason === null)
70
+ settledReason = reason;
71
+ killTree("SIGTERM");
72
+ // A process ignoring SIGTERM still ends: SIGKILL after a short grace.
73
+ graceTimer ??= setTimeout(() => {
74
+ killTree("SIGKILL");
75
+ }, SHELL_KILL_GRACE_MS);
76
+ graceTimer.unref();
77
+ };
78
+ if (opts?.timeoutMs !== undefined) {
79
+ killTimer = setTimeout(() => {
80
+ kill("timeout");
81
+ }, opts.timeoutMs);
82
+ }
83
+ const capture = (current, chunk) => {
84
+ const next = current + chunk.toString("utf8");
85
+ if (next.length > maxBuffer)
86
+ kill("maxBuffer"); // cap exceeded — stop the command, then reject
87
+ return next.length > maxBuffer ? next.slice(0, maxBuffer) : next;
88
+ };
89
+ child.stdout?.on("data", (chunk) => {
90
+ stdout = capture(stdout, chunk);
91
+ });
92
+ child.stderr?.on("data", (chunk) => {
93
+ stderr = capture(stderr, chunk);
94
+ });
95
+ const onAbort = () => {
96
+ kill("abort");
97
+ };
98
+ if (deps.signal !== undefined)
99
+ deps.signal.addEventListener("abort", onAbort, { once: true });
100
+ const cleanup = () => {
101
+ if (killTimer !== null)
102
+ clearTimeout(killTimer);
103
+ if (graceTimer !== null)
104
+ clearTimeout(graceTimer);
105
+ if (deps.signal !== undefined)
106
+ deps.signal.removeEventListener("abort", onAbort);
107
+ };
108
+ child.on("error", (err) => {
109
+ // The command could not run at all (e.g. no shell) — a rejecting case.
110
+ cleanup();
111
+ reject(err);
112
+ });
113
+ child.on("close", (code, signal) => {
114
+ cleanup();
115
+ if (settledReason === "abort") {
116
+ try {
117
+ throwIfAborted(deps.signal);
118
+ reject(new Error("shell command aborted"));
119
+ }
120
+ catch (abortErr) {
121
+ reject(abortErr instanceof Error ? abortErr : new Error(String(abortErr)));
122
+ }
123
+ return;
124
+ }
125
+ if (settledReason === "maxBuffer") {
126
+ // The ratified contract: an exceeded output cap REJECTS (never a silently truncated
127
+ // success). The message keeps node's conventional wording for greppability.
128
+ reject(new Error(`shell output exceeded maxBuffer (${String(maxBuffer)} bytes)`));
129
+ return;
130
+ }
131
+ if (code !== null) {
132
+ resolve({ exitCode: code, stdout, stderr });
133
+ return;
134
+ }
135
+ // Killed by the timeout (or an external signal): resolve with the shell's 128+signum
136
+ // convention so a timed-out command is still data to branch on.
137
+ if (signal !== null) {
138
+ resolve({ exitCode: SIGNAL_EXIT_CODES[signal] ?? 128, stdout, stderr });
139
+ return;
140
+ }
141
+ resolve({ exitCode: -1, stdout, stderr }); // neither code nor signal — Node says can't happen
142
+ });
143
+ });
144
+ }
@@ -25,6 +25,12 @@ export declare class AppError extends Error {
25
25
  constructor(code: ErrorCode, message: string, detail?: unknown);
26
26
  }
27
27
  export declare function isAppError(err: unknown): err is AppError;
28
+ /**
29
+ * The SEMANTIC code of a thrown value, or undefined when it carries none. Duck-typed rather than
30
+ * `instanceof` — errors cross package boundaries (SDK/engine dual copies), where class checks fail.
31
+ * The SCREAMING_SNAKE shape gate keeps prose out of a field consumers render as a code.
32
+ */
33
+ export declare function errorCodeOf(err: unknown): string | undefined;
28
34
  /** Run-lease heartbeat period (ported from the platform's checkpoint module — the broker's
29
35
  * renew endpoint mirrors it). */
30
36
  export declare const DEFAULT_LEASE_MS: number;
@@ -37,6 +43,18 @@ export interface Logger {
37
43
  warn(message: string, fields?: LogFields): void;
38
44
  error(message: string, fields?: LogFields): void;
39
45
  }
46
+ /**
47
+ * Freeze the runner's log level from a TRUSTED env — the platform boot env, snapshotted before the
48
+ * identity relay overlays a run's author env onto process.env (see `main`). Call once at bootstrap.
49
+ *
50
+ * Until it's called — in tests, and in the CLI/daemon before boot — {@link activeLevel} falls back to
51
+ * reading process.env live, so an operator's `--verbose`/`--debug` (which sets the env BEFORE boot,
52
+ * bin.ts) still applies. After it's called, a workflow author's `meta.env` (overlaid onto process.env
53
+ * at run time) can no longer raise the runner's own log verbosity. An author-facing verbosity control
54
+ * belongs in the manifest `meta` (e.g. a `logVerbosity` field), delivered over the trusted control
55
+ * plane — never an env knob.
56
+ */
57
+ export declare function configureLogging(env: NodeJS.ProcessEnv): void;
40
58
  export declare function createLogger(module: string): Logger;
41
59
  export type OrgRole = "owner" | "admin" | "member" | "viewer";
42
60
  export type AuthSource = "session_jwt" | "oauth_jwt" | "api_key" | "workflow";
@@ -61,6 +61,20 @@ export class AppError extends Error {
61
61
  export function isAppError(err) {
62
62
  return err instanceof AppError;
63
63
  }
64
+ /** A machine-readable error code is SCREAMING_SNAKE — an engine `EngineError.code` (`VALIDATION`,
65
+ * `PROVIDER_ERROR`, …), an {@link AppError} code, and a Node syscall code (`ENOENT`) all are. */
66
+ const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
67
+ /**
68
+ * The SEMANTIC code of a thrown value, or undefined when it carries none. Duck-typed rather than
69
+ * `instanceof` — errors cross package boundaries (SDK/engine dual copies), where class checks fail.
70
+ * The SCREAMING_SNAKE shape gate keeps prose out of a field consumers render as a code.
71
+ */
72
+ export function errorCodeOf(err) {
73
+ if (typeof err !== "object" || err === null)
74
+ return undefined;
75
+ const code = err.code;
76
+ return typeof code === "string" && ERROR_CODE_RE.test(code) ? code : undefined;
77
+ }
64
78
  // ---- leases ----
65
79
  /** Run-lease heartbeat period (ported from the platform's checkpoint module — the broker's
66
80
  * renew endpoint mirrors it). */
@@ -71,15 +85,33 @@ export function newId() {
71
85
  return randomUUID();
72
86
  }
73
87
  const LEVEL_ORDER = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
74
- /** Active log level: BOARDWALK_RUNNER_LOG_LEVEL (debug|info|warn|error), default info.
75
- * BOARDWALK_RUNNER_DEBUG=1 is a legacy alias for debug. Read per call so a CLI flag that
76
- * sets the env (e.g. `--verbose` / `--debug`) applies without logger re-creation. */
77
- function activeLevel() {
78
- if (process.env.BOARDWALK_RUNNER_DEBUG === "1")
88
+ /** Numeric log level from an env: BOARDWALK_RUNNER_LOG_LEVEL (debug|info|warn|error), default info;
89
+ * BOARDWALK_RUNNER_DEBUG=1 is a legacy alias for debug. Reads the passed `env`, not process.env. */
90
+ function levelFromEnv(env) {
91
+ if (env.BOARDWALK_RUNNER_DEBUG === "1")
79
92
  return LEVEL_ORDER.DEBUG ?? 10;
80
- const name = process.env.BOARDWALK_RUNNER_LOG_LEVEL?.toUpperCase() ?? "INFO";
93
+ const name = env.BOARDWALK_RUNNER_LOG_LEVEL?.toUpperCase() ?? "INFO";
81
94
  return LEVEL_ORDER[name] ?? 20;
82
95
  }
96
+ /** The level frozen at worker bootstrap from the TRUSTED boot env, or null until then. */
97
+ let configuredLevel = null;
98
+ /**
99
+ * Freeze the runner's log level from a TRUSTED env — the platform boot env, snapshotted before the
100
+ * identity relay overlays a run's author env onto process.env (see `main`). Call once at bootstrap.
101
+ *
102
+ * Until it's called — in tests, and in the CLI/daemon before boot — {@link activeLevel} falls back to
103
+ * reading process.env live, so an operator's `--verbose`/`--debug` (which sets the env BEFORE boot,
104
+ * bin.ts) still applies. After it's called, a workflow author's `meta.env` (overlaid onto process.env
105
+ * at run time) can no longer raise the runner's own log verbosity. An author-facing verbosity control
106
+ * belongs in the manifest `meta` (e.g. a `logVerbosity` field), delivered over the trusted control
107
+ * plane — never an env knob.
108
+ */
109
+ export function configureLogging(env) {
110
+ configuredLevel = levelFromEnv(env);
111
+ }
112
+ function activeLevel() {
113
+ return configuredLevel ?? levelFromEnv(process.env);
114
+ }
83
115
  function emit(level, module, message, fields) {
84
116
  if ((LEVEL_ORDER[level] ?? 20) < activeLevel())
85
117
  return;
@@ -22,7 +22,7 @@
22
22
  // A well-formed stream is zero-or-more `delta` frames followed by EXACTLY ONE `result` frame, OR a
23
23
  // single terminal `error` frame. The neutral conversation shapes (ChatMessage / ChatTurn / ToolSpec)
24
24
  // are the engine's `@boardwalk-labs/engine/core` types — so the worker's loop and the broker's
25
- // adapters speak the same wire as `boardwalk dev` and the self-hosted server (one agent loop).
25
+ // adapters speak the same wire as the self-hosted server (one agent loop).
26
26
  /** `POST /runner/v1/runs/{run_id}/inference` (run-token authed; the addressed run must match). */
27
27
  export const RUNNER_INFERENCE_PATH_RE = /^\/runner\/v1\/runs\/([^/]+)\/inference$/;
28
28
  /** Build the inference path for a run. */