wb-browser-runtime 0.6.0 → 0.7.0

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.
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env node
2
- // wb-browser-runtime — Browserbase + Playwright sidecar for `wb`.
2
+ // wb-browser-runtime — CDP + Playwright sidecar for `wb`.
3
3
  //
4
4
  // Speaks wb's line-framed JSON protocol on stdio (see ../README.md). Each
5
5
  // `browser` fenced block in a workbook arrives as one `slice` message; this
6
6
  // sidecar dispatches its verbs against a Playwright `Page` connected to a
7
- // Browserbase session via CDP.
7
+ // vendor-provided CDP endpoint.
8
+ //
9
+ // The vendor (Browserbase, browser-use, ...) is selected by WB_BROWSER_VENDOR
10
+ // and lives behind a provider in ../lib/providers/. Verbs, recording, session
11
+ // cache, and substitutions are all vendor-agnostic — they run against a
12
+ // Playwright Page regardless of whose chromium is on the other end.
8
13
  //
9
14
  // Sessions are cached by `session:` name across slices for the lifetime of
10
15
  // this process, so a runbook with multiple browser blocks against the same
11
- // vendor reuses one Browserbase session (and one logged-in browser context).
12
- //
13
- // Env required for real runs:
14
- // BROWSERBASE_API_KEY
15
- // BROWSERBASE_PROJECT_ID
16
+ // vendor reuses one session (and one logged-in browser context).
16
17
  //
17
18
  // Verb args support two substitutions, expanded recursively at dispatch time:
18
19
  // {{ env.NAME }} → process.env.NAME
@@ -24,742 +25,98 @@
24
25
 
25
26
  import readline from "node:readline";
26
27
  import { chromium } from "playwright-core";
27
- import { spawn, spawnSync } from "node:child_process";
28
- import { existsSync, readFileSync, promises as fsPromises } from "node:fs";
29
- import { randomUUID } from "node:crypto";
30
- import path from "node:path";
31
- import os from "node:os";
32
- import { fileURLToPath } from "node:url";
33
- import zlib from "node:zlib";
34
- import { promisify } from "node:util";
35
-
36
- const gzip = promisify(zlib.gzip);
37
-
38
- const SUPPORTS = [
39
- "goto",
40
- "fill",
41
- "click",
42
- "press",
43
- "wait_for",
44
- "screenshot",
45
- "extract",
46
- "assert",
47
- "eval",
48
- "save",
49
- ];
50
-
51
- const BB_BASE = "https://api.browserbase.com";
52
- const VERSION = "0.6.0";
53
-
54
- // --- Recording config -------------------------------------------------------
28
+ import { readFileSync } from "node:fs";
29
+ import { send, log } from "../lib/io.js";
30
+ import { resolveInside } from "../lib/util.js";
31
+ import { SessionManager } from "../lib/session-manager.js";
32
+ import {
33
+ RecordingManager,
34
+ loadRecordingConfig,
35
+ } from "../lib/recording-manager.js";
36
+ import { getProvider } from "../lib/providers/index.js";
37
+ import { SUPPORTS, runVerb, verbName } from "../verbs/index.js";
38
+
39
+ const VERSION = "0.7.0";
40
+ const provider = getProvider();
41
+ log(`[provider] ${provider.name}`);
42
+
43
+ // --- Recording --------------------------------------------------------------
55
44
  //
56
- // Feature is off unless WB_RECORDING_UPLOAD_URL is set. When enabled, every
57
- // session gets rrweb DOM-event capture and/or a CDP screencast video; both
58
- // artifacts are POSTed to the upload URL at session close.
59
- //
60
- // URL template supports `{run_id}` and `{kind}` placeholders, e.g.
61
- // https://host/api/runs/{run_id}/recording/{kind}
62
- // kind ∈ {"rrweb", "video"}. Auth: `Authorization: Bearer <SECRET>`.
63
-
64
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
65
- const RRWEB_VENDOR_PATH = path.join(
66
- __dirname,
67
- "..",
68
- "vendor",
69
- "rrweb-record.min.js",
70
- );
71
-
72
- function checkFfmpeg() {
73
- try {
74
- const res = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" });
75
- return res.status === 0;
76
- } catch {
77
- return false;
78
- }
79
- }
80
-
81
- function loadRecordingConfig() {
82
- const uploadUrl = (process.env.WB_RECORDING_UPLOAD_URL || "").trim();
83
- if (!uploadUrl) return { enabled: false, reason: "no-upload-url" };
84
- const secret = (process.env.WB_RECORDING_UPLOAD_SECRET || "").trim();
85
- if (!secret) {
86
- log(
87
- "[recording] WB_RECORDING_UPLOAD_URL is set but WB_RECORDING_UPLOAD_SECRET is empty — refusing to upload unauthenticated. Recording disabled.",
88
- );
89
- return { enabled: false, reason: "no-secret" };
90
- }
91
-
92
- const runId =
93
- (process.env.WB_RECORDING_RUN_ID || "").trim() ||
94
- (process.env.TRIGGER_RUN_ID || "").trim() ||
95
- `wb-${randomUUID()}`;
96
-
97
- // Clamp to ranges ffmpeg/libvpx-vp9 actually handles. Requesting fps=120
98
- // silently blew up memory; quality=0 produced unwatchable garbage. Clamp
99
- // + log so operators see the effective value.
100
- const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
101
- const rawFps =
102
- Number.parseInt(process.env.WB_RECORDING_SCREENCAST_FPS || "", 10) || 5;
103
- const rawQuality =
104
- Number.parseInt(process.env.WB_RECORDING_SCREENCAST_QUALITY || "", 10) ||
105
- 60;
106
- const fps = clamp(rawFps, 1, 30);
107
- const quality = clamp(rawQuality, 10, 95);
108
- if (fps !== rawFps) {
109
- log(`[recording] fps=${rawFps} clamped to ${fps} (valid range 1..30)`);
110
- }
111
- if (quality !== rawQuality) {
112
- log(
113
- `[recording] quality=${rawQuality} clamped to ${quality} (valid range 10..95)`,
114
- );
115
- }
116
-
117
- const rrwebRequested = process.env.WB_RECORDING_RRWEB !== "0";
118
- const videoRequested = process.env.WB_RECORDING_VIDEO !== "0";
119
-
120
- let rrwebSource = null;
121
- if (rrwebRequested) {
122
- if (!existsSync(RRWEB_VENDOR_PATH)) {
123
- log(
124
- `[recording] rrweb vendor file missing at ${RRWEB_VENDOR_PATH} — disabling rrweb capture`,
125
- );
126
- } else {
127
- rrwebSource = readFileSync(RRWEB_VENDOR_PATH, "utf8");
128
- }
129
- }
130
-
131
- const hasFfmpeg = videoRequested ? checkFfmpeg() : false;
132
- if (videoRequested && !hasFfmpeg) {
133
- log(
134
- "[recording] ffmpeg not found on $PATH — disabling video capture (rrweb will continue if enabled)",
135
- );
136
- }
137
-
138
- const kinds = {
139
- rrweb: rrwebRequested && !!rrwebSource,
140
- video: videoRequested && hasFfmpeg,
141
- };
142
-
143
- if (!kinds.rrweb && !kinds.video) {
144
- log("[recording] no usable kinds — recording disabled");
145
- return { enabled: false, reason: "all-kinds-disabled" };
146
- }
147
-
148
- const rrwebMaxEvents =
149
- Number.parseInt(process.env.WB_RECORDING_RRWEB_MAX_EVENTS || "", 10) ||
150
- 50_000;
45
+ // Feature is off unless WB_RECORDING_UPLOAD_URL is set. See
46
+ // runtimes/browser/lib/recording-manager.js for the full lifecycle.
151
47
 
152
- return {
153
- enabled: true,
154
- uploadUrl,
155
- secret,
156
- runId,
157
- fps,
158
- quality,
159
- kinds,
160
- rrwebSource,
161
- rrwebMaxEvents,
162
- };
163
- }
164
-
165
- const RECORDING = loadRecordingConfig();
166
- if (RECORDING.enabled) {
167
- const activeKinds = Object.entries(RECORDING.kinds)
168
- .filter(([, v]) => v)
169
- .map(([k]) => k)
170
- .join(",");
48
+ const recording = new RecordingManager(loadRecordingConfig());
49
+ if (recording.enabled) {
171
50
  log(
172
- `[recording] enabled run_id=${RECORDING.runId} kinds=${activeKinds} fps=${RECORDING.fps} quality=${RECORDING.quality}`,
51
+ `[recording] enabled run_id=${recording.runId} kinds=${recording.activeKinds.join(",")} fps=${recording.fps} quality=${recording.quality}`,
173
52
  );
174
53
  }
175
54
 
176
- function send(obj) {
177
- process.stdout.write(JSON.stringify(obj) + "\n");
178
- }
179
-
180
- function log(...args) {
181
- process.stderr.write(args.join(" ") + "\n");
182
- }
183
-
184
- // --- Browserbase REST -------------------------------------------------------
185
-
186
- async function bbCreateSession() {
187
- const apiKey = process.env.BROWSERBASE_API_KEY;
188
- const projectId = process.env.BROWSERBASE_PROJECT_ID;
189
- if (!apiKey || !projectId) {
190
- throw new Error(
191
- "BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID must be set",
192
- );
193
- }
194
-
195
- // Both flags opt-in per session. advancedStealth is Scale-plan-gated on
196
- // Browserbase's side; proxies adds residential-IP cost. Default off so a
197
- // misconfigured plan doesn't break unrelated runs (HN, Google Sheets, etc.);
198
- // flip per vendor when the target sits behind Cloudflare / similar bot
199
- // detection (e.g., Airbase).
200
- const envBool = (v) => v === "1" || (typeof v === "string" && v.toLowerCase() === "true");
201
- const advancedStealth = envBool(process.env.BROWSERBASE_ADVANCED_STEALTH);
202
- const proxies = envBool(process.env.BROWSERBASE_PROXIES);
203
-
204
- // keepAlive:false — slice lifetime is tied to wb process; on shutdown
205
- // we explicitly REQUEST_RELEASE so quota isn't burned by orphans.
206
- const body = { projectId, keepAlive: false };
207
- if (advancedStealth) {
208
- body.browserSettings = { advancedStealth: true };
209
- }
210
- if (proxies) {
211
- body.proxies = true;
212
- }
213
-
214
- log(`[bb] session create advancedStealth=${advancedStealth} proxies=${proxies}`);
215
-
216
- const res = await retryableFetch(
217
- `${BB_BASE}/v1/sessions`,
218
- {
219
- method: "POST",
220
- headers: {
221
- "X-BB-API-Key": apiKey,
222
- "Content-Type": "application/json",
223
- },
224
- body: JSON.stringify(body),
225
- },
226
- "bb.create",
227
- );
228
- if (!res.ok) {
229
- throw new Error(
230
- `Browserbase create failed (${res.status}): ${await safeText(res)}`,
231
- );
232
- }
233
- return await res.json();
234
- }
235
-
236
- async function bbGetLiveUrl(sessionId) {
237
- const apiKey = process.env.BROWSERBASE_API_KEY;
238
- const res = await retryableFetch(
239
- `${BB_BASE}/v1/sessions/${sessionId}/debug`,
240
- { headers: { "X-BB-API-Key": apiKey } },
241
- "bb.debug",
242
- );
243
- if (!res.ok) {
244
- throw new Error(
245
- `Browserbase debug fetch failed (${res.status}): ${await safeText(res)}`,
246
- );
247
- }
248
- const body = await res.json();
249
- return body.debuggerFullscreenUrl;
250
- }
251
-
252
- async function bbReleaseSession(sessionId) {
253
- const apiKey = process.env.BROWSERBASE_API_KEY;
254
- const projectId = process.env.BROWSERBASE_PROJECT_ID;
255
- try {
256
- await retryableFetch(
257
- `${BB_BASE}/v1/sessions/${sessionId}`,
258
- {
259
- method: "POST",
260
- headers: { "X-BB-API-Key": apiKey, "Content-Type": "application/json" },
261
- body: JSON.stringify({ projectId, status: "REQUEST_RELEASE" }),
262
- },
263
- "bb.release",
264
- );
265
- } catch (e) {
266
- log(`[shutdown] release session ${sessionId} failed: ${e.message}`);
267
- }
268
- }
269
-
270
- async function safeText(res) {
271
- try {
272
- return (await res.text()).slice(0, 200);
273
- } catch {
274
- return "<unreadable>";
275
- }
276
- }
277
-
278
- // Retry transient network + 5xx/429 failures with short exponential backoff.
279
- // Each attempt gets its own AbortController + timeout; caller-passed signals
280
- // are not plumbed through since we don't have a cancellation story above this
281
- // layer. Non-retryable statuses (4xx except 429) are returned immediately for
282
- // the caller to handle.
283
- async function retryableFetch(url, opts = {}, label, { timeoutMs = 30_000 } = {}) {
284
- const delays = [100, 500];
285
- let lastErr = null;
286
- let lastRes = null;
287
- for (let attempt = 0; attempt <= delays.length; attempt++) {
288
- if (attempt > 0) {
289
- await new Promise((r) => setTimeout(r, delays[attempt - 1]));
290
- const prev = lastRes
291
- ? `status=${lastRes.status}`
292
- : `err=${lastErr?.message || lastErr}`;
293
- log(`[retry] ${label} attempt ${attempt + 1}/3 (${prev})`);
294
- }
295
- const controller = new AbortController();
296
- const timer = setTimeout(() => controller.abort(), timeoutMs);
297
- try {
298
- const res = await fetch(url, { ...opts, signal: controller.signal });
299
- if (res.ok) return res;
300
- if (res.status === 429 || (res.status >= 500 && res.status < 600)) {
301
- lastRes = res;
302
- continue;
303
- }
304
- return res;
305
- } catch (e) {
306
- lastErr = e;
307
- continue;
308
- } finally {
309
- clearTimeout(timer);
310
- }
311
- }
312
- if (lastRes) return lastRes;
313
- throw lastErr;
314
- }
315
-
316
55
  // --- Session cache ----------------------------------------------------------
317
56
 
318
- const sessions = new Map(); // name -> { sid, browser, context, page, liveUrl, recording }
319
-
320
- async function ensureSession(name) {
321
- if (sessions.has(name)) return sessions.get(name);
322
-
323
- // Browserbase charges for the session the moment it's created; if anything
324
- // after this point throws (debug URL, CDP connect, newContext, recording
325
- // setup) we must release it explicitly or quota leaks until BB's idle
326
- // timeout.
327
- const created = await bbCreateSession();
328
- let browser = null;
329
- try {
330
- const liveUrl = await bbGetLiveUrl(created.id);
331
- browser = await chromium.connectOverCDP(created.connectUrl);
332
- const context = browser.contexts()[0] ?? (await browser.newContext());
333
- const page = context.pages()[0] ?? (await context.newPage());
334
-
335
- const info = {
336
- sid: created.id,
337
- browser,
338
- context,
339
- page,
340
- liveUrl,
341
- recording: null,
342
- };
343
- sessions.set(name, info);
344
-
345
- send({
346
- type: "slice.session_started",
347
- session: name,
348
- session_id: created.id,
349
- live_url: liveUrl,
350
- started_at: new Date().toISOString(),
351
- });
352
-
353
- await startRecording(info, name);
354
- return info;
355
- } catch (e) {
356
- if (browser) {
357
- try {
358
- await browser.close();
359
- } catch {}
360
- }
361
- sessions.delete(name);
362
- await bbReleaseSession(created.id);
363
- throw e;
364
- }
365
- }
366
-
367
- // --- Recording (rrweb + CDP screencast) ------------------------------------
368
- //
369
- // rrweb — vendored record bundle injected via context.addInitScript. Events
370
- // are emitted to window.__wbRrwebBuffer and flushed every 500ms (and
371
- // on beforeunload) to a sidecar-side buffer via exposeBinding. This
372
- // survives cross-origin navigations because the init script reruns on
373
- // every new document.
374
- // video — per-page CDPSession.startScreencast streams JPEG frames; each frame
375
- // is piped into a long-lived `ffmpeg` subprocess that encodes to VP9
376
- // WebM on disk. At session end we close the stdin, wait for ffmpeg to
377
- // exit, and read the file.
378
- //
379
- // Both artifacts are POSTed with Bearer auth to the upload URL. Failure is
380
- // soft — slice.recording.failed events are emitted but the run still succeeds.
381
-
382
- async function startRecording(info, sessionName) {
383
- if (!RECORDING.enabled) return;
384
- info.recording = {
385
- kinds: { ...RECORDING.kinds },
386
- rrwebEvents: [],
387
- rrwebDropped: 0,
388
- rrwebOverflowLogged: false,
389
- cdp: null,
390
- ffmpeg: null,
391
- ffmpegDone: null,
392
- videoPath: null,
393
- };
394
- const rec = info.recording;
395
-
396
- // Drop oldest events once the buffer exceeds the cap — keeps the tail of a
397
- // long run (usually the interesting bit) rather than failing the upload or
398
- // OOMing the sidecar. One warning per session so ops can spot it.
399
- const pushRrweb = (e) => {
400
- if (rec.rrwebEvents.length >= RECORDING.rrwebMaxEvents) {
401
- rec.rrwebEvents.shift();
402
- rec.rrwebDropped++;
403
- if (!rec.rrwebOverflowLogged) {
404
- rec.rrwebOverflowLogged = true;
405
- log(
406
- `[recording] rrweb buffer hit cap (${RECORDING.rrwebMaxEvents}); dropping oldest events`,
407
- );
408
- }
409
- }
410
- rec.rrwebEvents.push(e);
411
- };
412
-
413
- if (rec.kinds.rrweb) {
57
+ const sessions = new SessionManager();
58
+
59
+ async function ensureSession(name, { profile } = {}) {
60
+ return sessions.ensure(name, async () => {
61
+ // Vendors charge for the session the moment allocate() returns; if
62
+ // anything after this point throws (getLiveUrl, CDP connect, newContext,
63
+ // recording setup) we must release it explicitly or quota leaks until
64
+ // the vendor's idle timeout. SessionManager only caches a successful
65
+ // return, so on throw there's no half-populated entry to clean up here.
66
+ //
67
+ // Lifecycle timings attached to `slice.session_started` tell operators
68
+ // which step dominated when startup feels slow — usually connectOverCDP
69
+ // against a cold vendor region, but the live-URL fetch and
70
+ // newContext/newPage can each stall independently.
71
+ const t0 = Date.now();
72
+ const allocated = await provider.allocate({ profile, sessionName: name });
73
+ const tAllocated = Date.now();
74
+ let browser = null;
414
75
  try {
415
- await info.context.exposeBinding("__wbRrwebFlush", (_src, batch) => {
416
- if (Array.isArray(batch)) {
417
- for (const e of batch) pushRrweb(e);
418
- }
419
- });
420
- const bootstrap = `
421
- ;(function(){
422
- if (window.__wbRrwebActive) return;
423
- window.__wbRrwebActive = true;
424
- window.__wbRrwebBuffer = [];
425
- try {
426
- rrwebRecord({
427
- emit: function(event){ window.__wbRrwebBuffer.push(event); },
428
- sampling: { scroll: 150, media: 800, input: 'last' },
429
- maskAllInputs: true
430
- });
431
- } catch (e) { /* rrweb unavailable on this page (e.g. chrome://) */ }
432
- var flush = function(){
433
- var buf = window.__wbRrwebBuffer;
434
- if (buf && buf.length && typeof window.__wbRrwebFlush === 'function') {
435
- window.__wbRrwebBuffer = [];
436
- try { window.__wbRrwebFlush(buf); } catch (e) {}
437
- }
438
- };
439
- setInterval(flush, 500);
440
- window.addEventListener('beforeunload', flush);
441
- })();
442
- `;
443
- await info.context.addInitScript({
444
- content: RECORDING.rrwebSource + "\n" + bootstrap,
445
- });
446
- } catch (e) {
447
- log(`[recording] rrweb setup failed: ${e.message}`);
448
- rec.kinds.rrweb = false;
449
- }
450
- }
76
+ const liveUrl = await provider.getLiveUrl(allocated);
77
+ browser = await chromium.connectOverCDP(allocated.cdpUrl);
78
+ const tConnected = Date.now();
79
+ const context = browser.contexts()[0] ?? (await browser.newContext());
80
+ const page = context.pages()[0] ?? (await context.newPage());
81
+ const tPageReady = Date.now();
82
+
83
+ const info = {
84
+ sid: allocated.sid,
85
+ browser,
86
+ context,
87
+ page,
88
+ liveUrl,
89
+ recording: null,
90
+ };
451
91
 
452
- if (rec.kinds.video) {
453
- try {
454
- const outPath = path.join(
455
- os.tmpdir(),
456
- `wb-video-${sanitize(sessionName)}-${Date.now()}-${process.pid}.webm`,
457
- );
458
- rec.videoPath = outPath;
459
- const ff = spawn(
460
- "ffmpeg",
461
- [
462
- "-hide_banner",
463
- "-loglevel",
464
- "warning",
465
- "-y",
466
- "-f",
467
- "image2pipe",
468
- "-vcodec",
469
- "mjpeg",
470
- "-framerate",
471
- String(RECORDING.fps),
472
- "-i",
473
- "pipe:0",
474
- "-c:v",
475
- "libvpx-vp9",
476
- "-b:v",
477
- "1M",
478
- "-deadline",
479
- "realtime",
480
- "-pix_fmt",
481
- "yuv420p",
482
- outPath,
483
- ],
484
- { stdio: ["pipe", "ignore", "pipe"] },
485
- );
486
- ff.stderr.on("data", (d) => {
487
- const s = d.toString().trim();
488
- if (s) log(`[ffmpeg] ${s.slice(0, 240)}`);
489
- });
490
- // Broken pipe on shutdown is normal — swallow it so it doesn't crash the
491
- // node process via the default 'error' handler.
492
- ff.stdin.on("error", (e) => {
493
- if (e.code !== "EPIPE") log(`[ffmpeg stdin] ${e.message}`);
494
- });
495
- rec.ffmpeg = ff;
496
- rec.ffmpegDone = new Promise((resolve) => {
497
- ff.on("close", (code) => resolve(code));
498
- });
499
-
500
- const cdp = await info.context.newCDPSession(info.page);
501
- rec.cdp = cdp;
502
- // Dedup identical consecutive frames. CDP emits repeats when nothing
503
- // changed on screen; encoding them as distinct frames bloats the WebM
504
- // and mis-paces playback. Compare the base64 string directly — it's
505
- // cheaper than hashing and equivalent for exact equality.
506
- let lastFrameData = null;
507
- let dedupCount = 0;
508
- let dedupLogged = false;
509
-
510
- cdp.on("Page.screencastFrame", async (frame) => {
511
- try {
512
- if (ff.stdin.writable && !ff.killed) {
513
- if (frame.data === lastFrameData) {
514
- dedupCount++;
515
- if (!dedupLogged && dedupCount >= 100) {
516
- dedupLogged = true;
517
- log(
518
- `[recording] dedup active (${dedupCount} duplicate frames skipped so far)`,
519
- );
520
- }
521
- // Still ack — Chrome needs it to keep streaming.
522
- await cdp.send("Page.screencastFrameAck", {
523
- sessionId: frame.sessionId,
524
- });
525
- return;
526
- }
527
- lastFrameData = frame.data;
528
- const buf = Buffer.from(frame.data, "base64");
529
- const ok = ff.stdin.write(buf);
530
- // Backpressure: if ffmpeg's stdin buffer is full, wait for drain
531
- // before acking so Chrome slows frame production instead of
532
- // piling JPEG frames in Node heap. 5s fail-open so a wedged
533
- // ffmpeg can't stall the protocol indefinitely.
534
- if (!ok) {
535
- await new Promise((resolve) => {
536
- let fired = false;
537
- const done = () => {
538
- if (fired) return;
539
- fired = true;
540
- ff.stdin.off("drain", done);
541
- ff.stdin.off("close", done);
542
- ff.stdin.off("error", done);
543
- clearTimeout(timer);
544
- resolve();
545
- };
546
- const timer = setTimeout(done, 5000);
547
- ff.stdin.once("drain", done);
548
- ff.stdin.once("close", done);
549
- ff.stdin.once("error", done);
550
- });
551
- }
552
- }
553
- // Must ack each frame or Chrome stops streaming.
554
- await cdp.send("Page.screencastFrameAck", {
555
- sessionId: frame.sessionId,
556
- });
557
- } catch {
558
- // Session tearing down — safe to ignore.
559
- }
560
- });
561
- await cdp.send("Page.startScreencast", {
562
- format: "jpeg",
563
- quality: RECORDING.quality,
564
- everyNthFrame: 1,
92
+ send({
93
+ type: "slice.session_started",
94
+ session: name,
95
+ session_id: allocated.sid,
96
+ live_url: liveUrl,
97
+ vendor: provider.name,
98
+ started_at: new Date().toISOString(),
99
+ timings: {
100
+ allocate_ms: tAllocated - t0,
101
+ connect_ms: tConnected - tAllocated,
102
+ page_ready_ms: tPageReady - tConnected,
103
+ total_ms: tPageReady - t0,
104
+ },
565
105
  });
566
- } catch (e) {
567
- log(`[recording] video setup failed: ${e.message}`);
568
- rec.kinds.video = false;
569
- if (rec.ffmpeg) {
570
- try {
571
- rec.ffmpeg.kill();
572
- } catch {}
573
- }
574
- }
575
- }
576
-
577
- const active = Object.entries(rec.kinds)
578
- .filter(([, v]) => v)
579
- .map(([k]) => k);
580
- if (active.length) {
581
- send({
582
- type: "slice.recording.started",
583
- session: sessionName,
584
- run_id: RECORDING.runId,
585
- kinds: active,
586
- });
587
- }
588
- }
589
106
 
590
- async function flushRecording(info, sessionName) {
591
- if (!info.recording) return;
592
- const rec = info.recording;
593
-
594
- let rrwebBody = null;
595
- if (rec.kinds.rrweb) {
596
- try {
597
- const tail = await info.page.evaluate(() => {
598
- if (!Array.isArray(window.__wbRrwebBuffer)) return [];
599
- const out = window.__wbRrwebBuffer;
600
- window.__wbRrwebBuffer = [];
601
- return out;
602
- });
603
- if (Array.isArray(tail)) {
604
- for (const e of tail) {
605
- if (rec.rrwebEvents.length >= RECORDING.rrwebMaxEvents) {
606
- rec.rrwebEvents.shift();
607
- rec.rrwebDropped++;
608
- }
609
- rec.rrwebEvents.push(e);
610
- }
611
- }
107
+ await recording.start(info, name);
108
+ return info;
612
109
  } catch (e) {
613
- log(`[recording] rrweb final drain failed: ${e.message}`);
614
- }
615
- if (rec.rrwebEvents.length > 0) {
616
- try {
617
- const json = JSON.stringify({
618
- run_id: RECORDING.runId,
619
- session: sessionName,
620
- event_count: rec.rrwebEvents.length,
621
- dropped: rec.rrwebDropped,
622
- events: rec.rrwebEvents,
623
- });
624
- rrwebBody = await gzip(Buffer.from(json, "utf8"));
625
- } catch (e) {
626
- log(`[recording] rrweb gzip failed: ${e.message}`);
627
- }
628
- }
629
- }
630
-
631
- let videoBody = null;
632
- let videoFailure = null;
633
- if (rec.kinds.video && rec.cdp && rec.ffmpeg) {
634
- try {
635
- await rec.cdp.send("Page.stopScreencast");
636
- } catch {
637
- // Browser may already be tearing down.
638
- }
639
- const timeoutMs =
640
- Number.parseInt(process.env.WB_RECORDING_FFMPEG_TIMEOUT_MS || "", 10) ||
641
- 30_000;
642
- try {
643
- rec.ffmpeg.stdin.end();
644
- const settled = await Promise.race([
645
- rec.ffmpegDone,
646
- new Promise((r) =>
647
- setTimeout(() => r({ __timeout: true }), timeoutMs),
648
- ),
649
- ]);
650
- if (settled && typeof settled === "object" && settled.__timeout) {
651
- log(`[recording] ffmpeg did not exit within ${timeoutMs}ms; killing`);
110
+ if (browser) {
652
111
  try {
653
- rec.ffmpeg.kill("SIGKILL");
112
+ await browser.close();
654
113
  } catch {}
655
- videoFailure = `ffmpeg_timeout_${timeoutMs}ms`;
656
- } else if (typeof settled === "number" && settled !== 0) {
657
- // ff.on('close') resolves with the exit code — non-zero means ffmpeg
658
- // produced a corrupt/partial webm that we should not upload.
659
- videoFailure = `ffmpeg_exit_code_${settled}`;
660
- log(`[recording] ffmpeg exited with code ${settled}`);
661
- }
662
- if (!videoFailure && rec.videoPath && existsSync(rec.videoPath)) {
663
- videoBody = await fsPromises.readFile(rec.videoPath);
664
114
  }
665
- if (rec.videoPath && existsSync(rec.videoPath)) {
666
- try {
667
- await fsPromises.unlink(rec.videoPath);
668
- } catch {}
669
- }
670
- } catch (e) {
671
- videoFailure = `finalize_error: ${e.message}`;
672
- log(`[recording] video finalize failed: ${e.message}`);
115
+ await provider.release(allocated.sid);
116
+ throw e;
673
117
  }
674
- }
675
-
676
- const uploads = [];
677
- if (rrwebBody) {
678
- uploads.push(
679
- uploadArtifact(
680
- "rrweb",
681
- rrwebBody,
682
- "application/json+gzip",
683
- sessionName,
684
- { event_count: rec.rrwebEvents.length },
685
- ),
686
- );
687
- }
688
- if (videoBody) {
689
- uploads.push(
690
- uploadArtifact("video", videoBody, "video/webm", sessionName, {
691
- fps: RECORDING.fps,
692
- }),
693
- );
694
- } else if (videoFailure) {
695
- // Surface a terminal recording failure to the callback stream so the
696
- // consumer knows the video was lost rather than silently missing.
697
- send({
698
- type: "slice.recording.failed",
699
- session: sessionName,
700
- run_id: RECORDING.runId,
701
- kind: "video",
702
- reason: videoFailure,
703
- });
704
- }
705
- await Promise.allSettled(uploads);
706
- }
707
-
708
- async function uploadArtifact(kind, body, contentType, sessionName, extra) {
709
- const url = RECORDING.uploadUrl
710
- .replace("{run_id}", encodeURIComponent(RECORDING.runId))
711
- .replace("{kind}", encodeURIComponent(kind));
712
- try {
713
- const res = await retryableFetch(
714
- url,
715
- {
716
- method: "POST",
717
- headers: {
718
- Authorization: `Bearer ${RECORDING.secret}`,
719
- "Content-Type": contentType,
720
- "X-WB-Run-Id": RECORDING.runId,
721
- "X-WB-Recording-Kind": kind,
722
- "X-WB-Session": sessionName,
723
- },
724
- body,
725
- },
726
- `upload.${kind}`,
727
- { timeoutMs: 30_000 },
728
- );
729
- if (!res.ok) {
730
- send({
731
- type: "slice.recording.failed",
732
- session: sessionName,
733
- run_id: RECORDING.runId,
734
- kind,
735
- status: res.status,
736
- reason: (await safeText(res)) || res.statusText || "upload rejected",
737
- });
738
- return;
739
- }
740
- send({
741
- type: "slice.recording.uploaded",
742
- session: sessionName,
743
- run_id: RECORDING.runId,
744
- kind,
745
- bytes: body.length,
746
- ...(extra || {}),
747
- });
748
- } catch (e) {
749
- send({
750
- type: "slice.recording.failed",
751
- session: sessionName,
752
- run_id: RECORDING.runId,
753
- kind,
754
- reason: e.name === "AbortError" ? "timeout" : e.message,
755
- });
756
- }
757
- }
758
-
759
- function sanitize(s) {
760
- return String(s || "default").replace(/[^A-Za-z0-9_-]+/g, "_");
118
+ });
761
119
  }
762
-
763
120
  // --- {{ env.X }} / {{ artifacts.X }} substitution --------------------------
764
121
 
765
122
  const ENV_RE = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
@@ -767,14 +124,6 @@ const ENV_RE = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
767
124
  // exotic would invite path traversal once composed with WB_ARTIFACTS_DIR.
768
125
  const ARTIFACT_RE = /\{\{\s*artifacts\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/g;
769
126
 
770
- function resolveInside(dir, candidate) {
771
- const resolvedDir = path.resolve(dir);
772
- const resolved = path.resolve(resolvedDir, candidate);
773
- const rel = path.relative(resolvedDir, resolved);
774
- if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return null;
775
- return resolved;
776
- }
777
-
778
127
  // Resolved once at module load. `warn` matches historical behavior
779
128
  // (log + empty string, runbook continues). `error` throws so a missing OTP
780
129
  // or env var fails the slice instead of silently sending an empty value
@@ -871,245 +220,6 @@ function scrubSecrets(msg, secrets) {
871
220
  return out;
872
221
  }
873
222
 
874
- // --- Verb dispatch ----------------------------------------------------------
875
-
876
- function verbName(verb) {
877
- if (!verb || typeof verb !== "object") return String(verb);
878
- return Object.keys(verb)[0] || "verb";
879
- }
880
-
881
- // Most verbs accept either a bare string ("goto: https://...") or a structured
882
- // object ("goto: { url: ..., wait_until: ... }"). This pulls the canonical
883
- // field out of either shape.
884
- function arg(value, primaryKey) {
885
- if (typeof value === "string") return { [primaryKey]: value };
886
- if (value && typeof value === "object") return value;
887
- return {};
888
- }
889
-
890
- async function runVerb(page, verb, index, ctx) {
891
- const name = verbName(verb);
892
- const raw = verb[name];
893
- const a = expand(
894
- arg(raw, defaultKey(name)),
895
- ctx?.secrets,
896
- ctx?.artifactCache,
897
- );
898
-
899
- switch (name) {
900
- case "goto": {
901
- const url = a.url ?? "";
902
- const waitUntil = a.wait_until ?? "domcontentloaded";
903
- await page.goto(url, { waitUntil, timeout: a.timeout ?? 30_000 });
904
- return `→ ${page.url()}`;
905
- }
906
- case "fill": {
907
- // Don't echo the value into the summary — could be a credential.
908
- await page.fill(a.selector, String(a.value ?? ""), {
909
- timeout: a.timeout ?? 10_000,
910
- });
911
- return `${a.selector} = «${redact(a.value)}»`;
912
- }
913
- case "click": {
914
- await page.click(a.selector, { timeout: a.timeout ?? 10_000 });
915
- return `${a.selector}`;
916
- }
917
- case "press": {
918
- const target = a.selector ?? "body";
919
- await page.press(target, a.key, { timeout: a.timeout ?? 5_000 });
920
- return `${target} ⌨ ${a.key}`;
921
- }
922
- case "wait_for": {
923
- const selector = a.selector;
924
- const state = a.state ?? "visible";
925
- await page.waitForSelector(selector, {
926
- state,
927
- timeout: a.timeout ?? 15_000,
928
- });
929
- return `${selector} (${state})`;
930
- }
931
- case "screenshot": {
932
- // Always resolve inside $WB_ARTIFACTS_DIR (or cwd when unset). Absolute
933
- // paths and traversals are rejected — screenshots are controlled by
934
- // runbook authors whose content we don't want to grant arbitrary-write.
935
- const requested = a.path ?? `screenshot-${Date.now()}.png`;
936
- const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim() || ".";
937
- if (path.isAbsolute(requested)) {
938
- throw new Error(
939
- `screenshot: absolute paths are not allowed (got ${requested})`,
940
- );
941
- }
942
- const full = resolveInside(artifactsDir, requested);
943
- if (!full) {
944
- throw new Error(
945
- `screenshot: path escapes artifacts dir (got ${requested})`,
946
- );
947
- }
948
- await fsPromises.mkdir(path.dirname(full), { recursive: true });
949
- // Write to a unique .tmp first, then atomically rename, so a crash
950
- // mid-capture can't leave a truncated PNG that's already been announced
951
- // via slice.artifact_saved and uploaded to R2.
952
- const tmp = `${full}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
953
- try {
954
- await page.screenshot({ path: tmp, fullPage: !!a.full_page });
955
- await fsPromises.rename(tmp, full);
956
- } catch (e) {
957
- try {
958
- await fsPromises.unlink(tmp);
959
- } catch {}
960
- throw e;
961
- }
962
- return `→ ${requested}`;
963
- }
964
- case "extract": {
965
- // Pull structured rows out of the page. Each `field` entry is either:
966
- // string — CSS selector relative to row, take textContent
967
- // { selector, attr } — CSS selector relative to row, take attribute
968
- // { selector, text: true } — explicit textContent (default)
969
- const rowSelector = a.selector;
970
- const fields = a.fields ?? {};
971
- const items = await page.$$eval(
972
- rowSelector,
973
- (rows, fieldSpec) =>
974
- rows.map((row) => {
975
- const out = {};
976
- for (const [name, spec] of Object.entries(fieldSpec)) {
977
- const sel = typeof spec === "string" ? spec : spec.selector;
978
- const attr = typeof spec === "string" ? null : spec.attr ?? null;
979
- const el = sel ? row.querySelector(sel) : row;
980
- if (!el) {
981
- out[name] = null;
982
- continue;
983
- }
984
- out[name] = attr
985
- ? el.getAttribute(attr)
986
- : (el.textContent || "").trim();
987
- }
988
- return out;
989
- }),
990
- fields,
991
- );
992
- // Emit as JSON to stdout so wb captures it in step.complete.stdout.
993
- // Pretty-printed for readability when a runbook surfaces the output.
994
- console.log(JSON.stringify(items, null, 2));
995
- if (ctx) ctx.lastResult = items;
996
- return `${rowSelector} → ${items.length} rows`;
997
- }
998
- case "assert": {
999
- const sel = a.selector;
1000
- const handle = await page.$(sel);
1001
- if (!handle) throw new Error(`assert: selector not found: ${sel}`);
1002
- if (a.text_contains) {
1003
- const txt = (await handle.textContent()) ?? "";
1004
- if (!txt.includes(a.text_contains)) {
1005
- throw new Error(
1006
- `assert: text "${a.text_contains}" not in ${sel} (got "${txt.slice(0, 80)}")`,
1007
- );
1008
- }
1009
- }
1010
- if (a.url_contains && !page.url().includes(a.url_contains)) {
1011
- throw new Error(
1012
- `assert: url does not contain "${a.url_contains}" (got ${page.url()})`,
1013
- );
1014
- }
1015
- return `${sel}`;
1016
- }
1017
- case "eval": {
1018
- // Run arbitrary JS in the page; result is JSON-serialized to stdout.
1019
- const result = await page.evaluate(a.script);
1020
- console.log(JSON.stringify(result, null, 2));
1021
- if (ctx) ctx.lastResult = result;
1022
- return `script ran`;
1023
- }
1024
- case "save": {
1025
- // Persist a JSON artifact into $WB_ARTIFACTS_DIR so later cells can read
1026
- // it and wb can upload it. Captures the previous verb's output unless
1027
- // the author provides an explicit `value:`.
1028
- const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim();
1029
- if (!artifactsDir) {
1030
- throw new Error(
1031
- "save: $WB_ARTIFACTS_DIR is not set — run this workbook via `wb run` (wb exports the dir for you)",
1032
- );
1033
- }
1034
- const explicitValue = a.value !== undefined;
1035
- const payload = explicitValue ? a.value : ctx?.lastResult;
1036
- if (payload === undefined) {
1037
- throw new Error(
1038
- "save: no value provided and no prior extract/eval result to capture",
1039
- );
1040
- }
1041
- const name =
1042
- typeof a.name === "string" && a.name.trim().length > 0
1043
- ? sanitizeArtifactName(a.name)
1044
- : autoArtifactName(ctx?.blockIndex ?? index);
1045
- const filename = name.endsWith(".json") ? name : `${name}.json`;
1046
- const full = path.join(artifactsDir, filename);
1047
- await fsPromises.mkdir(artifactsDir, { recursive: true });
1048
- // Atomic write: serialize to .tmp, then rename. Announce the artifact
1049
- // AFTER rename so a partial write can never be seen by wb's uploader.
1050
- const serialized = JSON.stringify(payload, null, 2);
1051
- const tmp = `${full}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
1052
- try {
1053
- await fsPromises.writeFile(tmp, serialized, "utf8");
1054
- await fsPromises.rename(tmp, full);
1055
- } catch (e) {
1056
- try {
1057
- await fsPromises.unlink(tmp);
1058
- } catch {}
1059
- throw e;
1060
- }
1061
- send({
1062
- type: "slice.artifact_saved",
1063
- filename,
1064
- path: full,
1065
- bytes: Buffer.byteLength(serialized),
1066
- });
1067
- return `→ ${filename}`;
1068
- }
1069
- default:
1070
- throw new Error(`unsupported verb: ${name}`);
1071
- }
1072
- }
1073
-
1074
- function sanitizeArtifactName(s) {
1075
- // Keep author-chosen names readable but safe as filenames. Drop anything
1076
- // that could escape the artifacts dir (slashes, NULs, etc.).
1077
- return String(s).replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 200);
1078
- }
1079
-
1080
- function autoArtifactName(blockIndex) {
1081
- const rand = randomUUID().replace(/-/g, "").slice(0, 8);
1082
- const n = Number.isFinite(blockIndex) ? blockIndex : 0;
1083
- return `cell-${n}-${rand}`;
1084
- }
1085
-
1086
- function defaultKey(name) {
1087
- switch (name) {
1088
- case "goto":
1089
- return "url";
1090
- case "click":
1091
- case "wait_for":
1092
- case "assert":
1093
- return "selector";
1094
- case "screenshot":
1095
- return "path";
1096
- case "press":
1097
- return "key";
1098
- case "eval":
1099
- return "script";
1100
- case "save":
1101
- return "name";
1102
- default:
1103
- return "value";
1104
- }
1105
- }
1106
-
1107
- function redact(value) {
1108
- if (typeof value !== "string") return "";
1109
- if (value.length <= 4) return "***";
1110
- return `${value.slice(0, 2)}***`;
1111
- }
1112
-
1113
223
  // --- Slice handler ----------------------------------------------------------
1114
224
 
1115
225
  async function handleSlice(msg) {
@@ -1127,6 +237,15 @@ async function handleSlice(msg) {
1127
237
  // slices is seen on the next slice's first read.
1128
238
  artifactCache: new Map(),
1129
239
  };
240
+ // Per-slice wall-clock cap. Rust's SLICE_EVENT_TIMEOUT is per-event (resets
241
+ // on every verb.complete), so a chain of 25 × 15s wait_fors that each emit
242
+ // a frame never trips it — the sidecar just runs for 375s while the Rust
243
+ // parent assumes progress. Cap aggregate slice time so we terminate cleanly
244
+ // instead. Default 120s; operators who legitimately need longer can bump
245
+ // via WB_SLICE_DEADLINE_MS.
246
+ const sliceDeadlineMs =
247
+ Number.parseInt(process.env.WB_SLICE_DEADLINE_MS || "", 10) || 120_000;
248
+ const sliceDeadline = Date.now() + sliceDeadlineMs;
1130
249
  // Top-level guard: any unhandled error must emit slice.failed so the Rust
1131
250
  // side sees a terminal frame instead of waiting out SLICE_EVENT_TIMEOUT.
1132
251
  try {
@@ -1136,7 +255,7 @@ async function handleSlice(msg) {
1136
255
 
1137
256
  let session;
1138
257
  try {
1139
- session = await ensureSession(sessionName);
258
+ session = await ensureSession(sessionName, { profile: msg.profile });
1140
259
  } catch (e) {
1141
260
  send({
1142
261
  type: "slice.failed",
@@ -1151,23 +270,34 @@ async function handleSlice(msg) {
1151
270
  const startAt = restore?.state?.verb_index ?? 0;
1152
271
 
1153
272
  for (let i = startAt; i < verbs.length; i++) {
273
+ if (Date.now() >= sliceDeadline) {
274
+ send({
275
+ type: "slice.failed",
276
+ error: `slice exceeded deadline (${sliceDeadlineMs}ms); aborted before verb index ${i} of ${verbs.length}`,
277
+ });
278
+ return;
279
+ }
1154
280
  const v = verbs[i];
1155
281
  const name = verbName(v);
282
+ const verbStart = Date.now();
1156
283
  try {
1157
- const summary = await runVerb(session.page, v, i, sliceCtx);
284
+ const summary = await runVerb(session.page, v, i, sliceCtx, expand);
1158
285
  send({
1159
286
  type: "verb.complete",
1160
287
  verb: name,
1161
288
  verb_index: i,
1162
289
  summary,
290
+ duration_ms: Date.now() - verbStart,
1163
291
  });
1164
292
  } catch (e) {
293
+ const duration_ms = Date.now() - verbStart;
1165
294
  const clean = scrubSecrets(e.message, sliceCtx.secrets);
1166
295
  send({
1167
296
  type: "verb.failed",
1168
297
  verb: name,
1169
298
  verb_index: i,
1170
299
  error: clean,
300
+ duration_ms,
1171
301
  });
1172
302
  send({
1173
303
  type: "slice.failed",
@@ -1198,9 +328,19 @@ async function shutdown() {
1198
328
  // live page.evaluate() and CDP screencast needs a live CDPSession.
1199
329
  for (const [name, info] of sessions) {
1200
330
  try {
1201
- await flushRecording(info, name);
331
+ await recording.flush(info, name);
1202
332
  } catch (e) {
1203
333
  log(`[shutdown] flush recording ${name}: ${e.message}`);
334
+ // Unhandled flush error → consumer would otherwise see neither an
335
+ // uploaded nor a failed event and have to infer loss from absence.
336
+ try {
337
+ send({
338
+ type: "slice.recording.failed",
339
+ session: name,
340
+ run_id: recording.runId,
341
+ reason: `finalize_error: ${e.message}`,
342
+ });
343
+ } catch {}
1204
344
  }
1205
345
  }
1206
346
  for (const [name, info] of sessions) {
@@ -1210,10 +350,10 @@ async function shutdown() {
1210
350
  log(`[shutdown] close ${name}: ${e.message}`);
1211
351
  }
1212
352
  }
1213
- // Ask Browserbase to release sessions explicitly so quota isn't held by
353
+ // Ask the vendor to release sessions explicitly so quota isn't held by
1214
354
  // orphans waiting for their idle timeout.
1215
355
  await Promise.all(
1216
- Array.from(sessions.values()).map((s) => bbReleaseSession(s.sid)),
356
+ Array.from(sessions.values()).map((s) => provider.release(s.sid)),
1217
357
  );
1218
358
  process.exit(0);
1219
359
  }
@@ -1222,21 +362,35 @@ async function shutdown() {
1222
362
 
1223
363
  const rl = readline.createInterface({ input: process.stdin, terminal: false });
1224
364
 
1225
- // Serialize incoming messages Playwright operations are async and we don't
1226
- // want concurrent slice handlers stomping on the shared page.
1227
- let chain = Promise.resolve();
1228
- function enqueue(fn, kind) {
1229
- chain = chain.then(fn).catch((e) => {
1230
- log(`[loop] ${e.stack || e.message}`);
1231
- // Last-resort terminal frame so a bug in the handler can never strand
1232
- // the Rust parent waiting for a slice to finish.
1233
- if (kind === "slice") {
365
+ // Per-session dispatch: slices against the same session name serialize
366
+ // (shared Playwright page), slices against different names run in parallel.
367
+ // SessionManager owns the chain map + the in-flight-create dedup that makes
368
+ // this safe — two concurrent slices for "vendor-a" share one provider.allocate
369
+ // instead of racing to create two vendor sessions.
370
+ function dispatchSlice(msg) {
371
+ const sessionName = msg.session || "default";
372
+ return sessions
373
+ .enqueueOn(sessionName, () => handleSlice(msg))
374
+ .catch((e) => {
375
+ // handleSlice has its own top-level guard that emits slice.failed;
376
+ // this is the last-resort net for a bug that throws past that guard,
377
+ // so the Rust parent never strands waiting on SLICE_EVENT_TIMEOUT.
378
+ log(`[loop] ${e.stack || e.message}`);
1234
379
  try {
1235
380
  send({ type: "slice.failed", error: `sidecar loop error: ${e.message}` });
1236
381
  } catch {}
1237
- }
1238
- });
1239
- return chain;
382
+ });
383
+ }
384
+
385
+ // Shutdown drains all pending per-session work, then tears down. Guarded
386
+ // against repeat entries via `shuttingDown` inside shutdown() itself.
387
+ async function drainAndShutdown() {
388
+ try {
389
+ await sessions.drainAll();
390
+ } catch (e) {
391
+ log(`[shutdown] drain failed: ${e.message}`);
392
+ }
393
+ await shutdown();
1240
394
  }
1241
395
 
1242
396
  rl.on("line", (line) => {
@@ -1261,10 +415,10 @@ rl.on("line", (line) => {
1261
415
  });
1262
416
  break;
1263
417
  case "slice":
1264
- enqueue(() => handleSlice(msg), "slice");
418
+ dispatchSlice(msg);
1265
419
  break;
1266
420
  case "shutdown":
1267
- enqueue(shutdown);
421
+ drainAndShutdown();
1268
422
  break;
1269
423
  default:
1270
424
  log(`[warn] unknown message type: ${msg.type}`);
@@ -1273,7 +427,7 @@ rl.on("line", (line) => {
1273
427
 
1274
428
  rl.on("close", () => {
1275
429
  // stdin closed — drain pending work then exit.
1276
- enqueue(shutdown);
430
+ drainAndShutdown();
1277
431
  });
1278
432
 
1279
433
  // If the Rust parent SIGTERMs us (timeout, abort, crash), Node's default is
@@ -1282,7 +436,7 @@ rl.on("close", () => {
1282
436
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
1283
437
  process.on(sig, () => {
1284
438
  log(`[shutdown] received ${sig}`);
1285
- enqueue(shutdown);
439
+ drainAndShutdown();
1286
440
  });
1287
441
  }
1288
442