@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.
- package/dist/runtime/broker_artifact_store.js +4 -0
- package/dist/runtime/http_pool_reset.d.ts +4 -0
- package/dist/runtime/http_pool_reset.js +49 -0
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +36 -0
- package/dist/runtime/program_worker.d.ts +7 -0
- package/dist/runtime/program_worker.js +21 -0
- package/dist/runtime/runner_control_client.d.ts +7 -0
- package/dist/runtime/runner_control_client.js +24 -0
- package/dist/runtime/screen_capture.d.ts +74 -0
- package/dist/runtime/screen_capture.js +152 -0
- package/dist/runtime/screen_capture_backend.d.ts +19 -0
- package/dist/runtime/screen_capture_backend.js +212 -0
- package/dist/runtime/tools/artifacts.d.ts +4 -0
- package/package.json +3 -2
|
@@ -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
|
+
}
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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
|
package/dist/runtime/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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": {
|