@boardwalk-labs/runner 0.1.13 → 0.1.16

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({
@@ -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
@@ -41,6 +41,8 @@ import { reseedUserspaceCsprng } from "./uniqueness_reseed.js";
41
41
  import { WorkerWorkflowHost } from "./workflow_host.js";
42
42
  import { BrowserSessionManager } from "./browser_session.js";
43
43
  import { loadGuestBrowserConfig, makeGuestBrowserBackend, connectSessionMcp, } from "./browser_session_backend.js";
44
+ import { ScreenCapture } from "./screen_capture.js";
45
+ import { loadCaptureConfig, makeCaptureBackend } from "./screen_capture_backend.js";
44
46
  import { runProgramWorker, } from "./program_worker.js";
45
47
  import { RunnerControlClient } from "./runner_control_client.js";
46
48
  import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
@@ -196,6 +198,23 @@ export function assembleWorkerDeps(runtime) {
196
198
  nextId: () => newId(),
197
199
  })
198
200
  : undefined;
201
+ // Screen capture (session recording + live-view). Present only when the image ships the desktop
202
+ // stack (runtime.captureBackend set) AND the workflow hasn't opted out (`recording: false` in the
203
+ // manifest — SCREEN_CAPTURE §4.5; default on). Segments upload through the SAME broker artifact store
204
+ // as a `recording-segment` artifact (quota-exempt, 30-day retention — the broker applies that
205
+ // server-side off the metadata kind); live frames push up the broker's live-view channel while a
206
+ // viewer watches. (Live-view rides the same capture, so opting out of recording also stops live-view.)
207
+ const capture = runtime.captureBackend !== undefined && manifest.recording !== false
208
+ ? new ScreenCapture({
209
+ backend: runtime.captureBackend,
210
+ writeArtifact: (name, contentType, base64, metadata) => artifactStore
211
+ .write({ name, contentType, body: base64, encoding: "base64", metadata })
212
+ .then((res) => ({ id: res.id })),
213
+ publishLiveFrames: (frames) => broker.publishLiveView(frames),
214
+ liveViewWanted: () => broker.liveViewWanted(),
215
+ now: () => Date.now(),
216
+ })
217
+ : undefined;
199
218
  // Backend for the engine's host-backed built-in tools (webfetch/web_search/artifacts): web_search
200
219
  // + artifact read-back go through the broker (no Tavily/S3 creds here); webfetch is an in-process
201
220
  // fetch already gated by the worker's egress proxy.
@@ -362,6 +381,9 @@ export function assembleWorkerDeps(runtime) {
362
381
  freezeWallMs = Date.now();
363
382
  await activeFlusher?.flushNow();
364
383
  await workspaceStore?.persist();
384
+ // The recorder never spans a snapshot: finalize + upload the in-flight segment before the
385
+ // freeze (SCREEN_CAPTURE §4.3). Bounded internally, so a slow upload delays, not blocks, it.
386
+ await capture?.stopAndFlush();
365
387
  },
366
388
  // On wake: reseed the userspace CSPRNG (clause 3 — a suspend snapshot restored more than
367
389
  // once, e.g. a re-dispatch retry, would otherwise repeat its post-wake `crypto.*` draws),
@@ -378,6 +400,8 @@ export function assembleWorkerDeps(runtime) {
378
400
  redactor.record(wake.api_token);
379
401
  }
380
402
  activeFlusher?.excludeIdle(wake.wall_clock_ms - freezeWallMs);
403
+ // Resume capture in a fresh segment epoch (a suspend/resume boundary is a segment boundary).
404
+ void capture?.startFresh();
381
405
  },
382
406
  });
383
407
  }
@@ -407,6 +431,8 @@ export function assembleWorkerDeps(runtime) {
407
431
  // The orchestrator reaps every still-open browser session on terminal (kill Chromium + its
408
432
  // Playwright MCP) so no browser process leaks past the run.
409
433
  ...(browserSessions !== undefined ? { browserSessions } : {}),
434
+ // The orchestrator starts capture before the program runs and flushes it on terminal.
435
+ ...(capture !== undefined ? { capture } : {}),
410
436
  });
411
437
  };
412
438
  return {
@@ -618,6 +644,10 @@ export async function main() {
618
644
  // absent ⇒ `computer.openBrowser()` fails clearly. Read here so env-reading stays out of the pure wiring.
619
645
  const guestBrowserConfig = loadGuestBrowserConfig(process.env);
620
646
  const browserBackend = guestBrowserConfig !== null ? makeGuestBrowserBackend(guestBrowserConfig) : undefined;
647
+ // Screen capture (recording + live-view): present only when the image ships the desktop stack
648
+ // (ffmpeg + a display) and recording isn't disabled. Read here so env-reading stays out of the wiring.
649
+ const captureConfig = loadCaptureConfig(process.env);
650
+ const captureBackend = captureConfig !== null ? makeCaptureBackend(captureConfig) : undefined;
621
651
  const deps = assembleWorkerDeps({
622
652
  workerId: process.env.WORKER_ID ?? `worker-${runId}`,
623
653
  workspaceRoot: process.env.WORKSPACE_ROOT ?? "/workspace",
@@ -627,6 +657,7 @@ export async function main() {
627
657
  ...(byoProviders.length > 0 ? { byoProviders } : {}),
628
658
  ...(freezeRelay !== undefined ? { freezeRelay } : {}),
629
659
  ...(browserBackend !== undefined ? { browserBackend } : {}),
660
+ ...(captureBackend !== undefined ? { captureBackend } : {}),
630
661
  });
631
662
  // The only thing to drain is the batched telemetry buffer — the runner opens no database, cache, or queue.
632
663
  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
@@ -47,8 +47,9 @@ export type InferenceFrame = {
47
47
  } | {
48
48
  kind: "ping";
49
49
  };
50
- /** Serialize the request body. (Binary message content multimodal image/doc bytes — is not yet
51
- * supported on this path; v0 agent flows are text + JSON tool results.) */
50
+ /** Serialize the request body. Multimodal image content rides transparently: message content parts
51
+ * (incl. `{ type: "image" }`) are plain JSON here the broker's engine adapters render them per
52
+ * provider (@boardwalk-labs/engine ≥ 0.1.32). */
52
53
  export declare function serializeInferenceRequest(req: InferenceProxyRequest): string;
53
54
  /** Parse + minimally validate the request body (the runner is semi-trusted; fail closed on shape).
54
55
  * The conversation is shape-trusted past the array check — it was built by the engine loop on the
@@ -31,8 +31,9 @@ export function runnerInferencePath(runId) {
31
31
  /** Response content type — newline-delimited JSON frames. */
32
32
  export const INFERENCE_NDJSON_CONTENT_TYPE = "application/x-ndjson";
33
33
  // ---- request serialize / parse ----
34
- /** Serialize the request body. (Binary message content multimodal image/doc bytes — is not yet
35
- * supported on this path; v0 agent flows are text + JSON tool results.) */
34
+ /** Serialize the request body. Multimodal image content rides transparently: message content parts
35
+ * (incl. `{ type: "image" }`) are plain JSON here the broker's engine adapters render them per
36
+ * provider (@boardwalk-labs/engine ≥ 0.1.32). */
36
37
  export function serializeInferenceRequest(req) {
37
38
  return JSON.stringify(req);
38
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
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": {
@@ -53,8 +53,8 @@
53
53
  "prebuild": "prebuildify --napi --strip -t 24.0.0"
54
54
  },
55
55
  "dependencies": {
56
- "@boardwalk-labs/engine": "^0.1.31",
57
- "@boardwalk-labs/workflow": "^0.1.23",
56
+ "@boardwalk-labs/engine": "^0.1.34",
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",