@boardwalk-labs/runner 0.1.14 → 0.1.17

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.
@@ -27,6 +27,10 @@ export class BrokerArtifactStore {
27
27
  name: input.name,
28
28
  contentType: input.contentType,
29
29
  sizeBytes: bytes.length,
30
+ // Thread metadata to presign too (not only commit) so the broker can apply quota exemption +
31
+ // the retention tag at presign time — needed for large recording segments (a near-full org's
32
+ // recording must not be rejected by the storage ceiling).
33
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
30
34
  });
31
35
  await this.broker.uploadBytes(presign.uploadUrl, presign.uploadHeaders, bytes);
32
36
  return this.broker.commitArtifact({
@@ -0,0 +1,4 @@
1
+ /** Discard the global HTTP connection pool so post-restore requests open fresh sockets. Never
2
+ * throws (a wake must not fail on pool hygiene) and never blocks (the dead pool drains in the
3
+ * background). Returns true if the dispatcher was swapped. */
4
+ export declare function resetHttpConnectionPool(): boolean;
@@ -0,0 +1,49 @@
1
+ // Reset the global HTTP connection pool after a snapshot restore.
2
+ //
3
+ // The North Star substrate freezes the whole VM (memory + every socket) and restores it later,
4
+ // possibly minutes or days on. Keep-alive TCP sockets do NOT survive that: undici pools them, but
5
+ // on restore the frozen socket state is stale and the peer — the per-host egress proxy (a CONNECT
6
+ // tunnel) and, through it, the broker — long since closed its end. undici would REUSE such a socket
7
+ // for the next request, which then hangs (no bytes flow, no RST arrives on a half-dead tunnel) until
8
+ // the control client's 30s AbortSignal.timeout fires and aborts it. On a woken run's FIRST broker
9
+ // call (finalize) that abort crashes the run — the "socket is dead but never reset" gap the control
10
+ // client's timeout only papered over (see runner_control_client.ts).
11
+ //
12
+ // The fix is to discard the dead pool on resume. Swapping in a fresh EnvHttpProxyAgent — which
13
+ // re-reads the unchanged HTTP(S)_PROXY env, so proxying is preserved — means every post-wake request
14
+ // opens a NEW socket instead of reusing a frozen one. Node's global `fetch` reads the same global
15
+ // dispatcher, so this covers the control client, inference, artifacts, and any author fetch alike.
16
+ import { EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from "undici";
17
+ import { createLogger } from "./support/index.js";
18
+ const log = createLogger("http_pool_reset");
19
+ /** Discard the global HTTP connection pool so post-restore requests open fresh sockets. Never
20
+ * throws (a wake must not fail on pool hygiene) and never blocks (the dead pool drains in the
21
+ * background). Returns true if the dispatcher was swapped. */
22
+ export function resetHttpConnectionPool() {
23
+ try {
24
+ const prev = getGlobalDispatcher();
25
+ setGlobalDispatcher(new EnvHttpProxyAgent());
26
+ // Drain the old pool's dead sockets in the background; never block the wake on it.
27
+ const drain = async () => {
28
+ try {
29
+ await prev?.close?.();
30
+ }
31
+ catch {
32
+ try {
33
+ await prev?.destroy?.(new Error("http pool reset on wake"));
34
+ }
35
+ catch {
36
+ /* already gone */
37
+ }
38
+ }
39
+ };
40
+ void drain();
41
+ return true;
42
+ }
43
+ catch (err) {
44
+ log.warn("http_pool_reset_failed", {
45
+ error: err instanceof Error ? err.message : String(err),
46
+ });
47
+ return false;
48
+ }
49
+ }
@@ -3,6 +3,7 @@ import type { Run } from "./wire/run.js";
3
3
  import { type IdentityRelay } from "./identity_relay.js";
4
4
  import type { ByoInferenceProvider } from "../contract.js";
5
5
  import { type BrowserBackend } from "./browser_session.js";
6
+ import { type CaptureBackend } from "./screen_capture.js";
6
7
  import { type ProgramWorkerDeps } from "./program_worker.js";
7
8
  /** Constructed primitives the pure assembly consumes (real ones in main(), fakes in tests). The
8
9
  * runner holds NO platform credential — only the per-run control-plane handle (run token + broker
@@ -37,6 +38,9 @@ export interface WorkerRuntime {
37
38
  * (Chromium + a pre-installed Playwright MCP + an X display; gated by BOARDWALK_BROWSER_TIER).
38
39
  * When absent, `computer.openBrowser()` fails with a clear "not available on this runner image". */
39
40
  browserBackend?: BrowserBackend;
41
+ /** Screen-capture backend (session recording + live-view frames), present ONLY when the runner IMAGE
42
+ * ships the desktop stack (ffmpeg + an X display) and recording isn't disabled. Absent ⇒ no capture. */
43
+ captureBackend?: CaptureBackend;
40
44
  }
41
45
  /** Durable events are deferred (durable event storage) — live fan-out via the broker only. */
42
46
  /** Synthesize the run's AuthContext. The run was already authorized at trigger time; the
@@ -38,9 +38,12 @@ 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
40
  import { reseedUserspaceCsprng } from "./uniqueness_reseed.js";
41
+ import { resetHttpConnectionPool } from "./http_pool_reset.js";
41
42
  import { WorkerWorkflowHost } from "./workflow_host.js";
42
43
  import { BrowserSessionManager } from "./browser_session.js";
43
44
  import { loadGuestBrowserConfig, makeGuestBrowserBackend, connectSessionMcp, } from "./browser_session_backend.js";
45
+ import { ScreenCapture } from "./screen_capture.js";
46
+ import { loadCaptureConfig, makeCaptureBackend } from "./screen_capture_backend.js";
44
47
  import { runProgramWorker, } from "./program_worker.js";
45
48
  import { RunnerControlClient } from "./runner_control_client.js";
46
49
  import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
@@ -196,6 +199,23 @@ export function assembleWorkerDeps(runtime) {
196
199
  nextId: () => newId(),
197
200
  })
198
201
  : undefined;
202
+ // Screen capture (session recording + live-view). Present only when the image ships the desktop
203
+ // stack (runtime.captureBackend set) AND the workflow hasn't opted out (`recording: false` in the
204
+ // manifest — SCREEN_CAPTURE §4.5; default on). Segments upload through the SAME broker artifact store
205
+ // as a `recording-segment` artifact (quota-exempt, 30-day retention — the broker applies that
206
+ // server-side off the metadata kind); live frames push up the broker's live-view channel while a
207
+ // viewer watches. (Live-view rides the same capture, so opting out of recording also stops live-view.)
208
+ const capture = runtime.captureBackend !== undefined && manifest.recording !== false
209
+ ? new ScreenCapture({
210
+ backend: runtime.captureBackend,
211
+ writeArtifact: (name, contentType, base64, metadata) => artifactStore
212
+ .write({ name, contentType, body: base64, encoding: "base64", metadata })
213
+ .then((res) => ({ id: res.id })),
214
+ publishLiveFrames: (frames) => broker.publishLiveView(frames),
215
+ liveViewWanted: () => broker.liveViewWanted(),
216
+ now: () => Date.now(),
217
+ })
218
+ : undefined;
199
219
  // Backend for the engine's host-backed built-in tools (webfetch/web_search/artifacts): web_search
200
220
  // + artifact read-back go through the broker (no Tavily/S3 creds here); webfetch is an in-process
201
221
  // fetch already gated by the worker's egress proxy.
@@ -362,6 +382,9 @@ export function assembleWorkerDeps(runtime) {
362
382
  freezeWallMs = Date.now();
363
383
  await activeFlusher?.flushNow();
364
384
  await workspaceStore?.persist();
385
+ // The recorder never spans a snapshot: finalize + upload the in-flight segment before the
386
+ // freeze (SCREEN_CAPTURE §4.3). Bounded internally, so a slow upload delays, not blocks, it.
387
+ await capture?.stopAndFlush();
365
388
  },
366
389
  // On wake: reseed the userspace CSPRNG (clause 3 — a suspend snapshot restored more than
367
390
  // once, e.g. a re-dispatch retry, would otherwise repeat its post-wake `crypto.*` draws),
@@ -371,6 +394,10 @@ export function assembleWorkerDeps(runtime) {
371
394
  // BEFORE the seam resolves, so no woken author code draws from the stale (pre-suspend) DRBG.
372
395
  onAfterWake: (wake) => {
373
396
  reseedUserspaceCsprng();
397
+ // Keep-alive sockets (to the egress proxy + through it to the broker) do NOT survive the
398
+ // freeze; discard the connection pool so the first post-wake broker call (finalize) opens a
399
+ // fresh socket instead of hanging on a stale one until its 30s timeout crashes the run.
400
+ resetHttpConnectionPool();
374
401
  broker.swapRunToken(wake.run_token);
375
402
  redactor.record(wake.run_token);
376
403
  if (wake.api_token !== undefined && wake.api_token !== "") {
@@ -378,6 +405,8 @@ export function assembleWorkerDeps(runtime) {
378
405
  redactor.record(wake.api_token);
379
406
  }
380
407
  activeFlusher?.excludeIdle(wake.wall_clock_ms - freezeWallMs);
408
+ // Resume capture in a fresh segment epoch (a suspend/resume boundary is a segment boundary).
409
+ void capture?.startFresh();
381
410
  },
382
411
  });
383
412
  }
@@ -407,6 +436,8 @@ export function assembleWorkerDeps(runtime) {
407
436
  // The orchestrator reaps every still-open browser session on terminal (kill Chromium + its
408
437
  // Playwright MCP) so no browser process leaks past the run.
409
438
  ...(browserSessions !== undefined ? { browserSessions } : {}),
439
+ // The orchestrator starts capture before the program runs and flushes it on terminal.
440
+ ...(capture !== undefined ? { capture } : {}),
410
441
  });
411
442
  };
412
443
  return {
@@ -618,6 +649,10 @@ export async function main() {
618
649
  // absent ⇒ `computer.openBrowser()` fails clearly. Read here so env-reading stays out of the pure wiring.
619
650
  const guestBrowserConfig = loadGuestBrowserConfig(process.env);
620
651
  const browserBackend = guestBrowserConfig !== null ? makeGuestBrowserBackend(guestBrowserConfig) : undefined;
652
+ // Screen capture (recording + live-view): present only when the image ships the desktop stack
653
+ // (ffmpeg + a display) and recording isn't disabled. Read here so env-reading stays out of the wiring.
654
+ const captureConfig = loadCaptureConfig(process.env);
655
+ const captureBackend = captureConfig !== null ? makeCaptureBackend(captureConfig) : undefined;
621
656
  const deps = assembleWorkerDeps({
622
657
  workerId: process.env.WORKER_ID ?? `worker-${runId}`,
623
658
  workspaceRoot: process.env.WORKSPACE_ROOT ?? "/workspace",
@@ -627,6 +662,7 @@ export async function main() {
627
662
  ...(byoProviders.length > 0 ? { byoProviders } : {}),
628
663
  ...(freezeRelay !== undefined ? { freezeRelay } : {}),
629
664
  ...(browserBackend !== undefined ? { browserBackend } : {}),
665
+ ...(captureBackend !== undefined ? { captureBackend } : {}),
630
666
  });
631
667
  // The only thing to drain is the batched telemetry buffer — the runner opens no database, cache, or queue.
632
668
  const cleanup = async () => {
@@ -103,6 +103,13 @@ export type ProgramHostBuilder = (run: Run, manifest: WorkflowManifest, signal:
103
103
  browserSessions?: {
104
104
  closeAll(): Promise<void>;
105
105
  };
106
+ /** Session recording + live-view capture (docs/SCREEN_CAPTURE.md). The orchestrator starts it before
107
+ * the program runs and flushes it on EVERY terminal path. Best-effort; absent without the desktop
108
+ * stack. */
109
+ capture?: {
110
+ start(): Promise<void>;
111
+ stopAndFlush(): Promise<void>;
112
+ };
106
113
  }>;
107
114
  /** Handle to a running per-session loop (metering or credit watch); `stop()` ends + drains it. */
108
115
  export interface RunSessionHandle {
@@ -112,6 +112,7 @@ export async function runProgramWorker(runId, deps) {
112
112
  }
113
113
  const { host, redactor, workspace, phases, activity, setProgramDir, lsp, suspendSignal } = built;
114
114
  const browserSessions = built.browserSessions;
115
+ const capture = built.capture;
115
116
  // Guarantee the /workspace sandbox dir exists for EVERY run (persist or not) so a program can write
116
117
  // to /workspace without a defensive mkdir. Runs before hydrate (whose extract targets the dir).
117
118
  // Best-effort — the image pre-creates the dir; a failure here would resurface at the program's write.
@@ -130,6 +131,16 @@ export async function runProgramWorker(runId, deps) {
130
131
  // self-hosted, or on a first run). Best-effort — never fails the run.
131
132
  if (workspace !== undefined)
132
133
  await workspace.hydrate();
134
+ // Start desktop capture (recording + live-view) once the run has identity + a workspace, before the
135
+ // program runs. Best-effort — a capture failure must never fail the run.
136
+ if (capture !== undefined) {
137
+ await capture.start().catch((err) => {
138
+ log.warn("screen_capture_start_failed", {
139
+ runId,
140
+ error: err instanceof Error ? err.message : String(err),
141
+ });
142
+ });
143
+ }
133
144
  // Token metering is PER-LEAF: each agent() leaf reports its own tokens + model to the broker, which
134
145
  // decides `billed_by_boardwalk` per model + meters usage to the platform (see leaf_executor `meterUsage`). A
135
146
  // workflow has no run-level model, so there is no run-level token metering here.
@@ -219,6 +230,16 @@ export async function runProgramWorker(runId, deps) {
219
230
  });
220
231
  });
221
232
  }
233
+ // Stop capture + flush the final recording segment on EVERY terminal path, before the workspace
234
+ // snapshot. Best-effort — a capture failure must not mask the run's outcome.
235
+ if (capture !== undefined) {
236
+ await capture.stopAndFlush().catch((err) => {
237
+ log.warn("screen_capture_flush_failed", {
238
+ runId,
239
+ error: err instanceof Error ? err.message : String(err),
240
+ });
241
+ });
242
+ }
222
243
  // Snapshot the final /workspace so the workflow's NEXT run hydrates it. Best-effort.
223
244
  if (workspace !== undefined) {
224
245
  await workspace.persist();
@@ -156,6 +156,13 @@ export declare class RunnerControlClient {
156
156
  /** Publish a batch of live agent-event frames (the SSE live-tail source) — the broker publishes
157
157
  * them to the run's Redis channel server-side, so the runner holds no Redis credential. */
158
158
  publishTelemetry(frames: string[]): Promise<void>;
159
+ /** Push a batch of encoded desktop frames (base64 JPEG) for the live-view surface — the broker
160
+ * republishes them to the run's live-view channel server-side (never durably stored; the session
161
+ * recording is the durable copy). See docs/SCREEN_CAPTURE.md §5. */
162
+ publishLiveView(frames: string[]): Promise<void>;
163
+ /** Is a browser currently watching this run's live-view? The capture loop polls this so it only
164
+ * captures + pushes frames while someone is attached (capture costs guest CPU + metered egress). */
165
+ liveViewWanted(): Promise<boolean>;
159
166
  /** Store a run artifact through the broker (which holds the S3 credential + neutralizes the served
160
167
  * content type server-side). Returns the catalog id + a signed download URL. */
161
168
  writeArtifact(input: ArtifactWriteInput): Promise<ArtifactWriteResult>;
@@ -283,6 +283,30 @@ export class RunnerControlClient {
283
283
  if (res.status !== 204)
284
284
  throw await brokerError(res, "telemetry");
285
285
  }
286
+ /** Push a batch of encoded desktop frames (base64 JPEG) for the live-view surface — the broker
287
+ * republishes them to the run's live-view channel server-side (never durably stored; the session
288
+ * recording is the durable copy). See docs/SCREEN_CAPTURE.md §5. */
289
+ async publishLiveView(frames) {
290
+ const res = await this.controlFetch(this.url("liveview"), {
291
+ method: "POST",
292
+ headers: this.headers(true),
293
+ body: JSON.stringify({ frames }),
294
+ });
295
+ if (res.status !== 204)
296
+ throw await brokerError(res, "liveview");
297
+ }
298
+ /** Is a browser currently watching this run's live-view? The capture loop polls this so it only
299
+ * captures + pushes frames while someone is attached (capture costs guest CPU + metered egress). */
300
+ async liveViewWanted() {
301
+ const res = await this.controlFetch(this.url("liveview/wanted"), {
302
+ method: "GET",
303
+ headers: this.headers(false),
304
+ });
305
+ if (res.status !== 200)
306
+ throw await brokerError(res, "liveview/wanted");
307
+ const body = (await res.json());
308
+ return body.wanted === true;
309
+ }
286
310
  /** Store a run artifact through the broker (which holds the S3 credential + neutralizes the served
287
311
  * content type server-side). Returns the catalog id + a signed download URL. */
288
312
  async writeArtifact(input) {
@@ -0,0 +1,74 @@
1
+ /** A completed recording segment surfaced by the backend (a rolled or finalized-on-stop MP4 file). */
2
+ export interface CaptureSegment {
3
+ /** Read the segment's bytes as base64 (for the artifact upload). */
4
+ read: () => Promise<string>;
5
+ /** Delete the segment file after it's uploaded (bulk bytes stay off the snapshot + guest disk). */
6
+ discard: () => Promise<void>;
7
+ /** Wall-clock bounds of the captured span (best-effort, the backend's clock). */
8
+ startedAtMs: number;
9
+ endedAtMs: number;
10
+ }
11
+ /** A running capture: emits completed segments and exposes the latest live frame. */
12
+ export interface CaptureSession {
13
+ /** Register the completed-segment callback (fires per rolled segment + the final one on stop). */
14
+ onSegment: (cb: (segment: CaptureSegment) => void) => void;
15
+ /** The most recent live frame (base64 JPEG), or null before the first frame is written. */
16
+ latestFrame: () => Promise<string | null>;
17
+ /** Stop ffmpeg, finalize + emit the last in-flight segment, then resolve. */
18
+ stop: () => Promise<void>;
19
+ }
20
+ /** The guest-coupled half: starts ffmpeg on the display. Production impl in screen_capture_backend.ts. */
21
+ export interface CaptureBackend {
22
+ /** Pixel dimensions of the captured display — stamped into segment metadata. */
23
+ readonly width: number;
24
+ readonly height: number;
25
+ /** Interval (ms) between live-frame pushes while a viewer is attached. */
26
+ readonly liveFrameIntervalMs: number;
27
+ /** How often (ms) to poll the broker for whether a viewer is attached. */
28
+ readonly wantedPollIntervalMs: number;
29
+ start: () => Promise<CaptureSession>;
30
+ }
31
+ /** Upload a recording segment as a run artifact (the broker holds the S3 credential). */
32
+ export type SegmentArtifactWriter = (name: string, contentType: string, base64: string, metadata: Record<string, unknown>) => Promise<{
33
+ id: string;
34
+ }>;
35
+ export interface ScreenCaptureDeps {
36
+ backend: CaptureBackend;
37
+ /** Store a completed segment as a `recording-segment` artifact. */
38
+ writeArtifact: SegmentArtifactWriter;
39
+ /** Push encoded live frames to the broker's live-view channel. */
40
+ publishLiveFrames: (frames: string[]) => Promise<void>;
41
+ /** Whether a browser is currently watching (gates the live push loop). */
42
+ liveViewWanted: () => Promise<boolean>;
43
+ /** Injected clock (deterministic in tests). */
44
+ now: () => number;
45
+ /** Bounds `stopAndFlush()` so a hung upload can't stall a suspend indefinitely. Default 20s. */
46
+ flushTimeoutMs?: number;
47
+ }
48
+ export declare class ScreenCapture {
49
+ private readonly deps;
50
+ private session;
51
+ /** Monotonic across the whole run, so segments stay contiguous across suspend/resume epochs. */
52
+ private segmentIndex;
53
+ /** Serializes segment uploads so `stopAndFlush()` can await the whole in-flight tail. */
54
+ private uploadTail;
55
+ private liveLoop;
56
+ constructor(deps: ScreenCaptureDeps);
57
+ /** Begin capturing. Idempotent-safe: a second call while running is a no-op. */
58
+ start(): Promise<void>;
59
+ /** Post-wake: start a fresh capture (new segment files), keeping the monotonic segment index. */
60
+ startFresh(): Promise<void>;
61
+ /**
62
+ * Pre-freeze / terminal: stop capture, finalize + upload the in-flight segment, and drain the upload
63
+ * tail — so the recorder never spans a snapshot and the last committed segment is always playable.
64
+ * Bounded by `flushTimeoutMs` so a stuck upload delays (never blocks) the suspend. Safe to call with
65
+ * no active session.
66
+ */
67
+ stopAndFlush(): Promise<void>;
68
+ /** Chain a segment upload onto the tail (serialized, best-effort). */
69
+ private enqueueSegment;
70
+ private uploadSegment;
71
+ /** Lazy live-view push: poll whether a viewer is attached; while attached, push the latest frame at
72
+ * the backend's cadence. Fully best-effort — a failed poll/push never disturbs the run. */
73
+ private startLiveLoop;
74
+ }
@@ -0,0 +1,152 @@
1
+ // Screen capture — the runtime backing for session recording + the desktop live-view feed
2
+ // (docs/SCREEN_CAPTURE.md §4, §5). ONE ffmpeg reads the guest display `:0` and produces two sinks:
3
+ //
4
+ // 1. Rolling fragmented-MP4 SEGMENTS → uploaded as `recording-segment` artifacts (the durable,
5
+ // scrub-able recording / DVR). Each segment is a standalone playable MP4.
6
+ // 2. A low-fps LIVE frame (base64 JPEG) → pushed up the broker's live-view channel WHILE a viewer is
7
+ // attached (lazy: the capture polls `liveViewWanted`, so a run with nobody watching pays nothing
8
+ // beyond the always-on recording).
9
+ //
10
+ // The process spawn + segment file management live behind the injected `CaptureBackend` seam
11
+ // (screen_capture_backend.ts, guest-coupled: ffmpeg + fs), so this orchestration — segment upload
12
+ // sequencing, the lazy live loop, and the freeze-flush contract — is unit-tested without a guest.
13
+ //
14
+ // Suspend/resume (North Star): the recorder NEVER spans a snapshot. `stopAndFlush()` runs in the
15
+ // worker's pre-freeze hook (finalize + upload the in-flight segment, then the VM freezes); `startFresh()`
16
+ // runs post-wake (a new segment epoch). So a suspend/resume boundary is always a segment boundary, and
17
+ // no encoder ever wakes to a wall clock days ahead of its stream.
18
+ import { createLogger } from "./support/index.js";
19
+ const log = createLogger("screen_capture");
20
+ const DEFAULT_FLUSH_TIMEOUT_MS = 20_000;
21
+ export class ScreenCapture {
22
+ deps;
23
+ session = null;
24
+ /** Monotonic across the whole run, so segments stay contiguous across suspend/resume epochs. */
25
+ segmentIndex = 0;
26
+ /** Serializes segment uploads so `stopAndFlush()` can await the whole in-flight tail. */
27
+ uploadTail = Promise.resolve();
28
+ liveLoop = null;
29
+ constructor(deps) {
30
+ this.deps = deps;
31
+ }
32
+ /** Begin capturing. Idempotent-safe: a second call while running is a no-op. */
33
+ async start() {
34
+ if (this.session !== null)
35
+ return;
36
+ const session = await this.deps.backend.start();
37
+ this.session = session;
38
+ session.onSegment((segment) => this.enqueueSegment(segment));
39
+ this.startLiveLoop(session);
40
+ }
41
+ /** Post-wake: start a fresh capture (new segment files), keeping the monotonic segment index. */
42
+ async startFresh() {
43
+ await this.start();
44
+ }
45
+ /**
46
+ * Pre-freeze / terminal: stop capture, finalize + upload the in-flight segment, and drain the upload
47
+ * tail — so the recorder never spans a snapshot and the last committed segment is always playable.
48
+ * Bounded by `flushTimeoutMs` so a stuck upload delays (never blocks) the suspend. Safe to call with
49
+ * no active session.
50
+ */
51
+ async stopAndFlush() {
52
+ const session = this.session;
53
+ if (session === null)
54
+ return;
55
+ this.session = null;
56
+ this.liveLoop?.stop();
57
+ this.liveLoop = null;
58
+ const flush = (async () => {
59
+ try {
60
+ await session.stop(); // finalizes the last segment → enqueued via onSegment
61
+ }
62
+ catch (err) {
63
+ log.warn("screen_capture_stop_failed", { error: errMsg(err) });
64
+ }
65
+ await this.uploadTail; // drain all queued segment uploads (incl. the final one)
66
+ })();
67
+ const timeoutMs = this.deps.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
68
+ let timer;
69
+ const bound = new Promise((resolve) => {
70
+ timer = setTimeout(() => {
71
+ log.warn("screen_capture_flush_timeout", { timeoutMs });
72
+ resolve();
73
+ }, timeoutMs);
74
+ });
75
+ try {
76
+ await Promise.race([flush, bound]);
77
+ }
78
+ finally {
79
+ if (timer !== undefined)
80
+ clearTimeout(timer);
81
+ }
82
+ }
83
+ /** Chain a segment upload onto the tail (serialized, best-effort). */
84
+ enqueueSegment(segment) {
85
+ const index = this.segmentIndex++;
86
+ this.uploadTail = this.uploadTail.then(() => this.uploadSegment(segment, index));
87
+ }
88
+ async uploadSegment(segment, index) {
89
+ try {
90
+ const base64 = await segment.read();
91
+ await this.deps.writeArtifact(`recording-${String(index).padStart(5, "0")}.mp4`, "video/mp4", base64, {
92
+ kind: "recording-segment",
93
+ segment_index: index,
94
+ wall_start: segment.startedAtMs,
95
+ wall_end: segment.endedAtMs,
96
+ width: this.deps.backend.width,
97
+ height: this.deps.backend.height,
98
+ });
99
+ }
100
+ catch (err) {
101
+ // Recording is observability, not a ledger: a dropped segment must never fail the run.
102
+ log.warn("recording_segment_upload_failed", { index, error: errMsg(err) });
103
+ }
104
+ finally {
105
+ await segment.discard().catch(() => undefined);
106
+ }
107
+ }
108
+ /** Lazy live-view push: poll whether a viewer is attached; while attached, push the latest frame at
109
+ * the backend's cadence. Fully best-effort — a failed poll/push never disturbs the run. */
110
+ startLiveLoop(session) {
111
+ let stopped = false;
112
+ let wanted = false;
113
+ let sincePollMs = Number.POSITIVE_INFINITY; // force a poll on the first tick
114
+ const { liveFrameIntervalMs, wantedPollIntervalMs } = this.deps.backend;
115
+ const tick = async () => {
116
+ if (stopped)
117
+ return;
118
+ try {
119
+ if (sincePollMs >= wantedPollIntervalMs) {
120
+ wanted = await this.deps.liveViewWanted();
121
+ sincePollMs = 0;
122
+ }
123
+ if (wanted) {
124
+ const frame = await session.latestFrame();
125
+ if (frame !== null && frame.length > 0) {
126
+ await this.deps.publishLiveFrames([frame]);
127
+ }
128
+ }
129
+ }
130
+ catch (err) {
131
+ log.debug("live_view_tick_failed", { error: errMsg(err) });
132
+ }
133
+ sincePollMs += liveFrameIntervalMs;
134
+ if (!stopped) {
135
+ timer = setTimeout(() => void tick(), liveFrameIntervalMs);
136
+ timer.unref();
137
+ }
138
+ };
139
+ let timer = setTimeout(() => void tick(), liveFrameIntervalMs);
140
+ timer.unref();
141
+ this.liveLoop = {
142
+ stop: () => {
143
+ stopped = true;
144
+ if (timer !== undefined)
145
+ clearTimeout(timer);
146
+ },
147
+ };
148
+ }
149
+ }
150
+ function errMsg(err) {
151
+ return err instanceof Error ? err.message : String(err);
152
+ }
@@ -0,0 +1,19 @@
1
+ import type { CaptureBackend } from "./screen_capture.js";
2
+ export interface CaptureConfig {
3
+ /** X display to grab (DISPLAY, default ":0"). */
4
+ display: string;
5
+ /** Recording capture frame rate (fps). */
6
+ fps: number;
7
+ /** Screen dimensions (must match the ambient desktop — SCREEN_CAPTURE §1.3). */
8
+ width: number;
9
+ height: number;
10
+ /** Recording segment roll interval (seconds). A suspend/resume also forces a boundary. */
11
+ segmentSeconds: number;
12
+ /** Live-view push cadence (ms) — how often the latest frame is pushed while a viewer is attached. */
13
+ liveFrameIntervalMs: number;
14
+ /** How often (ms) the capture polls the broker for viewer presence. */
15
+ wantedPollIntervalMs: number;
16
+ }
17
+ /** Read the capture config from env, or null when the desktop stack is absent or recording is off. */
18
+ export declare function loadCaptureConfig(env: NodeJS.ProcessEnv): CaptureConfig | null;
19
+ export declare function makeCaptureBackend(cfg: CaptureConfig): CaptureBackend;
@@ -0,0 +1,212 @@
1
+ // The guest-coupled half of screen capture (screen_capture.ts's `CaptureBackend`): one ffmpeg reading
2
+ // the X display `:0` with two outputs — rolling MP4 segments (recording) and a single low-fps JPEG that
3
+ // is continuously overwritten (the live-view frame). See docs/SCREEN_CAPTURE.md §4.
4
+ //
5
+ // Only runs where the runner IMAGE ships the desktop stack (Xvfb + ffmpeg), gated by
6
+ // BOARDWALK_BROWSER_TIER=1 (the same "desktop present" signal the browser tier uses) + a
7
+ // BOARDWALK_RECORDING_ENABLED kill switch (default on). Off on Fargate / self-hosted images with no
8
+ // display, where `loadCaptureConfig` returns null and no capture is constructed.
9
+ //
10
+ // This layer is validated by a local ffmpeg smoke + the substrate E2E (it needs a real X display), the
11
+ // same way browser_session_backend.ts is — the unit tests cover screen_capture.ts's pure orchestration.
12
+ import { spawn } from "node:child_process";
13
+ import { mkdtemp, readdir, readFile, rm, unlink } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { browserTierEnabled } from "./browser_session_backend.js";
17
+ import { createLogger } from "./support/index.js";
18
+ const log = createLogger("screen_capture_backend");
19
+ const SEGMENT_PREFIX = "rec-";
20
+ const LIVE_FRAME_FILE = "live.jpg";
21
+ /** JPEG end-of-image marker — a complete frame ends with these bytes; used to skip a torn read. */
22
+ const JPEG_EOI = Buffer.from([0xff, 0xd9]);
23
+ function intFromEnv(raw, fallback) {
24
+ const n = Number(raw);
25
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
26
+ }
27
+ /** Read the capture config from env, or null when the desktop stack is absent or recording is off. */
28
+ export function loadCaptureConfig(env) {
29
+ // The desktop-present signal is the browser tier flag; without a display there is nothing to grab.
30
+ if (!browserTierEnabled(env))
31
+ return null;
32
+ // Kill switch (default on): BOARDWALK_RECORDING_ENABLED=0 disables recording + live-view capture.
33
+ if (env.BOARDWALK_RECORDING_ENABLED === "0")
34
+ return null;
35
+ return {
36
+ display: env.DISPLAY?.trim() || ":0",
37
+ fps: intFromEnv(env.BOARDWALK_RECORDING_FPS, 6),
38
+ width: intFromEnv(env.BOARDWALK_SCREEN_WIDTH, 1280),
39
+ height: intFromEnv(env.BOARDWALK_SCREEN_HEIGHT, 800),
40
+ segmentSeconds: intFromEnv(env.BOARDWALK_RECORDING_SEGMENT_SECONDS, 240),
41
+ liveFrameIntervalMs: intFromEnv(env.BOARDWALK_LIVEVIEW_FRAME_INTERVAL_MS, 1000),
42
+ wantedPollIntervalMs: intFromEnv(env.BOARDWALK_LIVEVIEW_WANTED_POLL_MS, 3000),
43
+ };
44
+ }
45
+ /** ffmpeg args: one x11grab input, two outputs (segmented MP4 + a single overwritten JPEG). */
46
+ function ffmpegArgs(cfg, dir) {
47
+ const liveFps = Math.max(1, Math.round(1000 / cfg.liveFrameIntervalMs));
48
+ return [
49
+ "-y",
50
+ "-loglevel",
51
+ "error",
52
+ "-f",
53
+ "x11grab",
54
+ "-framerate",
55
+ String(cfg.fps),
56
+ "-video_size",
57
+ `${String(cfg.width)}x${String(cfg.height)}`,
58
+ "-i",
59
+ cfg.display,
60
+ // Recording: H.264 MP4 segments, each a standalone playable file (docs/SCREEN_CAPTURE.md §4.2).
61
+ "-map",
62
+ "0:v",
63
+ "-c:v",
64
+ "libx264",
65
+ "-preset",
66
+ "veryfast",
67
+ "-crf",
68
+ "28",
69
+ "-pix_fmt",
70
+ "yuv420p",
71
+ "-g",
72
+ String(cfg.fps * 2),
73
+ "-f",
74
+ "segment",
75
+ "-segment_time",
76
+ String(cfg.segmentSeconds),
77
+ "-reset_timestamps",
78
+ "1",
79
+ "-segment_format",
80
+ "mp4",
81
+ join(dir, `${SEGMENT_PREFIX}%05d.mp4`),
82
+ // Live-view: a single low-fps JPEG, continuously overwritten (the latest frame).
83
+ "-map",
84
+ "0:v",
85
+ "-r",
86
+ String(liveFps),
87
+ "-q:v",
88
+ "6",
89
+ "-update",
90
+ "1",
91
+ join(dir, LIVE_FRAME_FILE),
92
+ ];
93
+ }
94
+ /** The recording-segment index from a `rec-00007.mp4` filename, or null if it doesn't match. */
95
+ function segmentIndexOf(name) {
96
+ const m = /^rec-(\d+)\.mp4$/.exec(name);
97
+ if (m === null || m[1] === undefined)
98
+ return null;
99
+ const n = Number(m[1]);
100
+ return Number.isFinite(n) ? n : null;
101
+ }
102
+ export function makeCaptureBackend(cfg) {
103
+ return {
104
+ width: cfg.width,
105
+ height: cfg.height,
106
+ liveFrameIntervalMs: cfg.liveFrameIntervalMs,
107
+ wantedPollIntervalMs: cfg.wantedPollIntervalMs,
108
+ async start() {
109
+ const dir = await mkdtemp(join(tmpdir(), "bw-capture-"));
110
+ const proc = spawn("ffmpeg", ffmpegArgs(cfg, dir), {
111
+ stdio: ["ignore", "ignore", "inherit"],
112
+ });
113
+ proc.once("error", (err) => log.error("ffmpeg_spawn_error", { error: err.message }));
114
+ let onSegmentCb = null;
115
+ const emitted = new Set();
116
+ // A ffmpeg segment is COMPLETE once the next-index file exists (or once ffmpeg exits). Poll the
117
+ // dir and emit every rec file below the current highest index that hasn't been emitted yet.
118
+ let lastEmitAtMs = Date.now();
119
+ const makeSegment = (index) => {
120
+ const path = join(dir, `${SEGMENT_PREFIX}${String(index).padStart(5, "0")}.mp4`);
121
+ const startedAtMs = lastEmitAtMs;
122
+ const endedAtMs = Date.now();
123
+ lastEmitAtMs = endedAtMs;
124
+ return {
125
+ startedAtMs,
126
+ endedAtMs,
127
+ read: async () => (await readFile(path)).toString("base64"),
128
+ discard: () => unlink(path).catch(() => undefined),
129
+ };
130
+ };
131
+ const sweep = async (includeHighest) => {
132
+ let names;
133
+ try {
134
+ names = await readdir(dir);
135
+ }
136
+ catch {
137
+ return;
138
+ }
139
+ const indices = names
140
+ .map(segmentIndexOf)
141
+ .filter((n) => n !== null)
142
+ .sort((a, b) => a - b);
143
+ if (indices.length === 0)
144
+ return;
145
+ const highest = indices[indices.length - 1] ?? 0;
146
+ for (const index of indices) {
147
+ // While running, the highest-index file is still being written — hold it back until the next
148
+ // one appears (or until stop, when `includeHighest` releases it).
149
+ if (!includeHighest && index === highest)
150
+ continue;
151
+ if (emitted.has(index))
152
+ continue;
153
+ emitted.add(index);
154
+ onSegmentCb?.(makeSegment(index));
155
+ }
156
+ };
157
+ const poll = setInterval(() => void sweep(false), 2000);
158
+ poll.unref();
159
+ return {
160
+ onSegment(cb) {
161
+ onSegmentCb = cb;
162
+ },
163
+ async latestFrame() {
164
+ try {
165
+ const bytes = await readFile(join(dir, LIVE_FRAME_FILE));
166
+ // Skip a torn read (ffmpeg mid-overwrite) — a complete JPEG ends with the EOI marker.
167
+ if (bytes.length < 2 || !bytes.subarray(-2).equals(JPEG_EOI))
168
+ return null;
169
+ return bytes.toString("base64");
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ },
175
+ async stop() {
176
+ clearInterval(poll);
177
+ await stopFfmpeg(proc);
178
+ // ffmpeg finalized the in-flight segment on exit — release every remaining segment, then
179
+ // clean up the live frame + the scratch dir (segment files are discarded post-upload).
180
+ await sweep(true);
181
+ await rm(join(dir, LIVE_FRAME_FILE)).catch(() => undefined);
182
+ // The dir itself is removed after uploads discard the segment files; a best-effort rm here
183
+ // clears anything left (empty dir / an un-uploaded torn segment) without blocking.
184
+ setTimeout(() => void rm(dir, { recursive: true, force: true }).catch(() => undefined), 30_000).unref();
185
+ },
186
+ };
187
+ },
188
+ };
189
+ }
190
+ /** SIGINT ffmpeg so it flushes the current segment's moov atom, then wait (bounded) for it to exit. */
191
+ async function stopFfmpeg(proc) {
192
+ if (proc.exitCode !== null || proc.signalCode !== null)
193
+ return;
194
+ await new Promise((resolve) => {
195
+ const done = () => {
196
+ clearTimeout(kill);
197
+ resolve();
198
+ };
199
+ proc.once("exit", done);
200
+ proc.kill("SIGINT");
201
+ // If ffmpeg doesn't exit promptly, SIGKILL it (the last segment may be lost — acceptable).
202
+ const kill = setTimeout(() => {
203
+ try {
204
+ proc.kill("SIGKILL");
205
+ }
206
+ catch {
207
+ // already gone
208
+ }
209
+ }, 5000);
210
+ kill.unref();
211
+ });
212
+ }
@@ -106,6 +106,10 @@ export interface ArtifactPresignInput {
106
106
  name: string;
107
107
  contentType: string;
108
108
  sizeBytes: number;
109
+ /** Passed through so the broker can decide storage-quota exemption + the retention tag at presign
110
+ * time (e.g. session-recording segments are quota-exempt and get a shorter retention). The catalog
111
+ * row's metadata is still set from the commit call; this is only for the presign-time decisions. */
112
+ metadata?: Record<string, unknown>;
109
113
  }
110
114
  /** The broker's presign response: where + how to PUT the bytes, and the chosen `s3Key` the worker
111
115
  * echoes back at commit. No catalog id / download URL yet — the row doesn't exist until the upload
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.1.14",
3
+ "version": "0.1.17",
4
4
  "description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -54,10 +54,11 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@boardwalk-labs/engine": "^0.1.34",
57
- "@boardwalk-labs/workflow": "^0.1.24",
57
+ "@boardwalk-labs/workflow": "^0.1.25",
58
58
  "@modelcontextprotocol/sdk": "^1.29.0",
59
59
  "node-gyp-build": "^4.8.4",
60
60
  "tar": "^7.5.16",
61
+ "undici": "^6.27.0",
61
62
  "zod": "^4.0.0"
62
63
  },
63
64
  "devDependencies": {