wb-browser-runtime 0.7.0 → 0.11.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.
package/README.md CHANGED
@@ -11,10 +11,19 @@ name across slices for the lifetime of the sidecar process so a runbook with
11
11
  multiple browser blocks against the same vendor reuses one logged-in browser
12
12
  context.
13
13
 
14
- ## Install (local dev)
14
+ ## Install
15
+
16
+ From npm:
17
+
18
+ ```bash
19
+ npm install -g wb-browser-runtime
20
+ ```
21
+
22
+ From source (local dev):
15
23
 
16
24
  ```bash
17
- cd runtimes/browser
25
+ git clone https://github.com/workbooks-dev/wb-browser-runtime.git
26
+ cd wb-browser-runtime
18
27
  npm install # installs playwright-core
19
28
  npm link # exposes `wb-browser-runtime` on $PATH
20
29
  ```
@@ -42,7 +51,9 @@ at sidecar boot; there is no per-slice override.
42
51
  is 60 minutes; unused time is refunded if the session ran less than an hour.
43
52
 
44
53
  Profile (auth state) is selected per-runbook via the `profile_id:` field on a
45
- `browser` block — see "Profiles" below.
54
+ `browser` block — see "Profiles" below. `BROWSER_USE_PROFILE_ID` is read as a
55
+ default when the browser block omits `profile_id:`; a per-runbook `profile_id:`
56
+ always wins over the env var.
46
57
 
47
58
  ## Profiles
48
59
 
@@ -36,7 +36,7 @@ import {
36
36
  import { getProvider } from "../lib/providers/index.js";
37
37
  import { SUPPORTS, runVerb, verbName } from "../verbs/index.js";
38
38
 
39
- const VERSION = "0.7.0";
39
+ const VERSION = "0.8.0";
40
40
  const provider = getProvider();
41
41
  log(`[provider] ${provider.name}`);
42
42
 
@@ -56,7 +56,7 @@ if (recording.enabled) {
56
56
 
57
57
  const sessions = new SessionManager();
58
58
 
59
- async function ensureSession(name, { profile } = {}) {
59
+ async function ensureSession(name, { profile, restoreSession } = {}) {
60
60
  return sessions.ensure(name, async () => {
61
61
  // Vendors charge for the session the moment allocate() returns; if
62
62
  // anything after this point throws (getLiveUrl, CDP connect, newContext,
@@ -69,11 +69,22 @@ async function ensureSession(name, { profile } = {}) {
69
69
  // against a cold vendor region, but the live-URL fetch and
70
70
  // newContext/newPage can each stall independently.
71
71
  const t0 = Date.now();
72
- const allocated = await provider.allocate({ profile, sessionName: name });
72
+ const restored =
73
+ restoreSession &&
74
+ restoreSession.vendor === provider.name &&
75
+ restoreSession.cdpUrl;
76
+ const allocated = restored
77
+ ? {
78
+ sid: restoreSession.sid,
79
+ cdpUrl: restoreSession.cdpUrl,
80
+ _liveUrl: restoreSession.liveUrl ?? null,
81
+ _restored: true,
82
+ }
83
+ : await provider.allocate({ profile, sessionName: name });
73
84
  const tAllocated = Date.now();
74
85
  let browser = null;
75
86
  try {
76
- const liveUrl = await provider.getLiveUrl(allocated);
87
+ const liveUrl = allocated._liveUrl ?? (await provider.getLiveUrl(allocated));
77
88
  browser = await chromium.connectOverCDP(allocated.cdpUrl);
78
89
  const tConnected = Date.now();
79
90
  const context = browser.contexts()[0] ?? (await browser.newContext());
@@ -82,6 +93,8 @@ async function ensureSession(name, { profile } = {}) {
82
93
 
83
94
  const info = {
84
95
  sid: allocated.sid,
96
+ cdpUrl: allocated.cdpUrl,
97
+ vendor: provider.name,
85
98
  browser,
86
99
  context,
87
100
  page,
@@ -95,6 +108,7 @@ async function ensureSession(name, { profile } = {}) {
95
108
  session_id: allocated.sid,
96
109
  live_url: liveUrl,
97
110
  vendor: provider.name,
111
+ restored: Boolean(restored),
98
112
  started_at: new Date().toISOString(),
99
113
  timings: {
100
114
  allocate_ms: tAllocated - t0,
@@ -107,12 +121,12 @@ async function ensureSession(name, { profile } = {}) {
107
121
  await recording.start(info, name);
108
122
  return info;
109
123
  } catch (e) {
110
- if (browser) {
124
+ if (browser && !allocated._restored) {
111
125
  try {
112
126
  await browser.close();
113
127
  } catch {}
114
128
  }
115
- await provider.release(allocated.sid);
129
+ if (!allocated._restored) await provider.release(allocated.sid);
116
130
  throw e;
117
131
  }
118
132
  });
@@ -252,10 +266,14 @@ async function handleSlice(msg) {
252
266
  const verbs = Array.isArray(msg.verbs) ? msg.verbs : [];
253
267
  const sessionName = msg.session || "default";
254
268
  const restore = msg.restore || null;
269
+ const restoreSession = restore?.state?.session || null;
255
270
 
256
271
  let session;
257
272
  try {
258
- session = await ensureSession(sessionName, { profile: msg.profile });
273
+ session = await ensureSession(sessionName, {
274
+ profile: msg.profile,
275
+ restoreSession,
276
+ });
259
277
  } catch (e) {
260
278
  send({
261
279
  type: "slice.failed",
@@ -264,10 +282,17 @@ async function handleSlice(msg) {
264
282
  return;
265
283
  }
266
284
 
267
- // Restore-from-pause is not implemented yet (no pause verb wired here).
268
- // The sidecar protocol leaves room for it; when wait_for_mfa lands, this
269
- // is where we'd jump to verbs[restore.state.verb_index].
270
- const startAt = restore?.state?.verb_index ?? 0;
285
+ // Restore-from-pause: when the Rust side resumes us after a
286
+ // `slice.paused` frame, `restore.state.verb_index` is the index of the
287
+ // verb that paused. We skip *past* it — the verb has no post-resume
288
+ // work (any payload from the operator is already in
289
+ // $WB_ARTIFACTS_DIR/pause_result.json, written by `wb resume` before
290
+ // it re-boots the sidecar). Skipping keeps pause verbs pure: their
291
+ // only job is "halt now," not "halt, then continue."
292
+ const startAt =
293
+ restore?.state?.verb_index !== undefined
294
+ ? Number(restore.state.verb_index) + 1
295
+ : 0;
271
296
 
272
297
  for (let i = startAt; i < verbs.length; i++) {
273
298
  if (Date.now() >= sliceDeadline) {
@@ -282,6 +307,42 @@ async function handleSlice(msg) {
282
307
  const verbStart = Date.now();
283
308
  try {
284
309
  const summary = await runVerb(session.page, v, i, sliceCtx, expand);
310
+ // Pause-sentinel escape hatch: a verb signals a mid-slice halt by
311
+ // returning `{ __pause: {...} }`. We translate that into a
312
+ // `slice.paused` frame (so the Rust side writes a pending
313
+ // descriptor and exits 42) and bail out of the verb loop without
314
+ // firing `slice.complete`. Non-pause verbs hand back a plain
315
+ // summary and the loop proceeds normally.
316
+ if (summary && typeof summary === "object" && summary.__pause) {
317
+ const pauseMeta = summary.__pause;
318
+ send({
319
+ type: "slice.paused",
320
+ reason: pauseMeta.reason || "slice.paused",
321
+ message: pauseMeta.message || "",
322
+ context_url: pauseMeta.context_url ?? null,
323
+ resume_on: pauseMeta.resume_on || "operator_click",
324
+ timeout: pauseMeta.timeout ?? null,
325
+ actions: pauseMeta.actions || [{ label: "Resume", value: null }],
326
+ verb: name,
327
+ verb_index: i,
328
+ // `sidecar_state` is forwarded verbatim into the Rust pending
329
+ // descriptor and handed back on resume. The verb can stash
330
+ // whatever it needs here; we always ensure verb_index is set
331
+ // so the dispatcher can compute startAt on re-entry.
332
+ sidecar_state: {
333
+ ...(pauseMeta.sidecar_state || {}),
334
+ verb_index: i,
335
+ session: {
336
+ vendor: session.vendor,
337
+ name: sessionName,
338
+ sid: session.sid,
339
+ cdpUrl: session.cdpUrl,
340
+ liveUrl: session.liveUrl,
341
+ },
342
+ },
343
+ });
344
+ return;
345
+ }
285
346
  send({
286
347
  type: "verb.complete",
287
348
  verb: name,
@@ -358,6 +419,32 @@ async function shutdown() {
358
419
  process.exit(0);
359
420
  }
360
421
 
422
+ async function suspend() {
423
+ if (shuttingDown) return;
424
+ shuttingDown = true;
425
+ // Flush recordings while CDP is still connected, but intentionally leave
426
+ // browser contexts and vendor sessions open. The operator needs the live
427
+ // inspector after wb exits 42, and wb resume reconnects using the persisted
428
+ // cdpUrl/liveUrl in sidecar_state.
429
+ for (const [name, info] of sessions) {
430
+ try {
431
+ await recording.flush(info, name);
432
+ } catch (e) {
433
+ log(`[suspend] flush recording ${name}: ${e.message}`);
434
+ try {
435
+ send({
436
+ type: "slice.recording.failed",
437
+ session: name,
438
+ run_id: recording.runId,
439
+ reason: `suspend_finalize_error: ${e.message}`,
440
+ });
441
+ } catch {}
442
+ }
443
+ }
444
+ log("[suspend] leaving browser session alive for external resume");
445
+ process.exit(0);
446
+ }
447
+
361
448
  // --- Main loop --------------------------------------------------------------
362
449
 
363
450
  const rl = readline.createInterface({ input: process.stdin, terminal: false });
@@ -393,6 +480,15 @@ async function drainAndShutdown() {
393
480
  await shutdown();
394
481
  }
395
482
 
483
+ async function drainAndSuspend() {
484
+ try {
485
+ await sessions.drainAll();
486
+ } catch (e) {
487
+ log(`[suspend] drain failed: ${e.message}`);
488
+ }
489
+ await suspend();
490
+ }
491
+
396
492
  rl.on("line", (line) => {
397
493
  const trimmed = line.trim();
398
494
  if (!trimmed) return;
@@ -420,6 +516,9 @@ rl.on("line", (line) => {
420
516
  case "shutdown":
421
517
  drainAndShutdown();
422
518
  break;
519
+ case "suspend":
520
+ drainAndSuspend();
521
+ break;
423
522
  default:
424
523
  log(`[warn] unknown message type: ${msg.type}`);
425
524
  }
@@ -38,7 +38,7 @@ export function createBrowserUseProvider() {
38
38
  // bootstrap, baked into the runbook frontmatter by whatever generates
39
39
  // it (UI editor, codegen, hand-authored). The slice envelope carries
40
40
  // it through; this provider just forwards.
41
- const profileId = profile ?? null;
41
+ const profileId = profile ?? process.env.BROWSER_USE_PROFILE_ID ?? null;
42
42
 
43
43
  const body = {};
44
44
  if (profileId) body.profileId = profileId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wb-browser-runtime",
3
- "version": "0.7.0",
3
+ "version": "0.11.0",
4
4
  "description": "Browser sidecar runtime for wb — Playwright over CDP (Browserbase, browser-use) via the wb-sidecar/1 line-framed JSON protocol.",
5
5
  "bin": {
6
6
  "wb-browser-runtime": "bin/wb-browser-runtime.js"
@@ -21,5 +21,22 @@
21
21
  "verbs",
22
22
  "vendor",
23
23
  "README.md"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/workbooks-dev/wb-browser-runtime.git"
28
+ },
29
+ "homepage": "https://github.com/workbooks-dev/wb-browser-runtime#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/workbooks-dev/wb-browser-runtime/issues"
32
+ },
33
+ "license": "MIT",
34
+ "keywords": [
35
+ "wb",
36
+ "workbooks",
37
+ "playwright",
38
+ "browser-automation",
39
+ "browserbase",
40
+ "cdp"
24
41
  ]
25
42
  }
@@ -0,0 +1,74 @@
1
+ import path from "node:path";
2
+ import { promises as fsPromises } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { resolveInside } from "../lib/util.js";
5
+
6
+ // Ergonomic shorthand for attaching a human-readable label to an artifact
7
+ // already in (or about-to-be-in) $WB_ARTIFACTS_DIR. Writes a sidecar JSON
8
+ // at `<path>.meta.json`; the next Artifacts::sync() pass picks it up and
9
+ // attaches the label to the step.artifact_saved callback event. No upload,
10
+ // no network — just filesystem metadata.
11
+ //
12
+ // Usage:
13
+ // - save:
14
+ // path: statement.csv
15
+ // - announce_artifact:
16
+ // path: statement.csv
17
+ // label: "April HSBC statement"
18
+ // description: "Reconciled balance export"
19
+ //
20
+ // The target artifact need not exist yet — callers may pre-write the
21
+ // sidecar in an earlier block and drop the artifact later. The sidecar
22
+ // only takes effect once the artifact is visible to sync().
23
+ export default {
24
+ name: "announce_artifact",
25
+ primaryKey: "path",
26
+ async execute(_page, args) {
27
+ const rawPath = typeof args.path === "string" ? args.path.trim() : "";
28
+ if (!rawPath) {
29
+ throw new Error("announce_artifact: `path` is required");
30
+ }
31
+ const label = typeof args.label === "string" ? args.label : "";
32
+ if (!label) {
33
+ throw new Error("announce_artifact: `label` is required");
34
+ }
35
+ const description =
36
+ typeof args.description === "string" ? args.description : undefined;
37
+
38
+ const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim();
39
+ if (!artifactsDir) {
40
+ throw new Error(
41
+ "announce_artifact: $WB_ARTIFACTS_DIR is not set — run this workbook via `wb run` (wb exports the dir for you)",
42
+ );
43
+ }
44
+ if (path.isAbsolute(rawPath)) {
45
+ throw new Error(
46
+ `announce_artifact: absolute paths are not allowed (got ${rawPath})`,
47
+ );
48
+ }
49
+ const full = resolveInside(artifactsDir, rawPath);
50
+ if (!full) {
51
+ throw new Error(
52
+ `announce_artifact: path escapes artifacts dir (got ${rawPath})`,
53
+ );
54
+ }
55
+
56
+ const payload = { label };
57
+ if (description !== undefined) payload.description = description;
58
+ const serialized = JSON.stringify(payload, null, 2);
59
+
60
+ const sidecar = `${full}.meta.json`;
61
+ await fsPromises.mkdir(path.dirname(sidecar), { recursive: true });
62
+ const tmp = `${sidecar}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
63
+ try {
64
+ await fsPromises.writeFile(tmp, serialized, "utf8");
65
+ await fsPromises.rename(tmp, sidecar);
66
+ } catch (e) {
67
+ try {
68
+ await fsPromises.unlink(tmp);
69
+ } catch {}
70
+ throw e;
71
+ }
72
+ return `→ ${rawPath} (labelled)`;
73
+ },
74
+ };
package/verbs/index.js CHANGED
@@ -18,6 +18,9 @@ import extractVerb from "./extract.js";
18
18
  import assertVerb from "./assert.js";
19
19
  import evalVerb from "./eval.js";
20
20
  import saveVerb from "./save.js";
21
+ import pauseForHumanVerb from "./pause_for_human.js";
22
+ import waitForDropVerb from "./wait_for_drop.js";
23
+ import announceArtifactVerb from "./announce_artifact.js";
21
24
 
22
25
  const VERBS = [
23
26
  gotoVerb,
@@ -30,6 +33,9 @@ const VERBS = [
30
33
  assertVerb,
31
34
  evalVerb,
32
35
  saveVerb,
36
+ pauseForHumanVerb,
37
+ waitForDropVerb,
38
+ announceArtifactVerb,
33
39
  ];
34
40
 
35
41
  export const VERB_REGISTRY = Object.fromEntries(VERBS.map((v) => [v.name, v]));
@@ -66,5 +72,10 @@ export async function runVerb(page, verb, index, ctx, expand) {
66
72
  ctx?.secrets,
67
73
  ctx?.artifactCache,
68
74
  );
69
- return handler.execute(page, args, { ...ctx, index });
75
+ // Mutate-in-place so writes a verb makes to ctx (e.g. eval setting
76
+ // ctx.lastResult for a later save to pick up) survive to the next verb.
77
+ // The spread-copy pattern that used to live here silently dropped those
78
+ // writes, which broke the documented eval → save pattern.
79
+ ctx.index = index;
80
+ return handler.execute(page, args, ctx);
70
81
  }
@@ -0,0 +1,60 @@
1
+ // pause_for_human — generalized operator-handoff pause.
2
+ //
3
+ // Unlike side-effecting verbs (click, fill, goto) which return a summary
4
+ // and let the slice loop continue, pause_for_human returns a sentinel
5
+ // `{ __pause: {...} }` shape. The dispatcher in ../bin/wb-browser-runtime.js
6
+ // inspects that shape and emits a `slice.paused` frame instead of a
7
+ // `verb.complete`, which flips the Rust side into its pause-and-exit-42 path
8
+ // (pending descriptor written, checkpoint marked paused, process exits).
9
+ //
10
+ // On `wb resume`, the slice re-enters at verb_index + 1 (the verb that
11
+ // paused is skipped — it has no post-resume work). If the pause carried
12
+ // `actions`, the operator's choice is written to
13
+ // `$WB_ARTIFACTS_DIR/pause_result.json` by the Rust side before the sidecar
14
+ // boots again, so downstream bash/python cells can branch on it via a
15
+ // plain file read.
16
+
17
+ const VALID_RESUME_MODES = ["operator_click", "poll", "timeout"];
18
+
19
+ export default {
20
+ name: "pause_for_human",
21
+ primaryKey: "message",
22
+ async execute(_page, args, ctx) {
23
+ const resumeOn = args.resume_on || "operator_click";
24
+ if (!VALID_RESUME_MODES.includes(resumeOn)) {
25
+ throw new Error(
26
+ `pause_for_human: resume_on must be one of ${VALID_RESUME_MODES.join("|")}, got "${resumeOn}"`,
27
+ );
28
+ }
29
+ // Default action: a single "Resume" button. Authors who want
30
+ // branching on operator choice provide their own `actions` list.
31
+ const actions =
32
+ Array.isArray(args.actions) && args.actions.length > 0
33
+ ? args.actions
34
+ : [{ label: "Resume", value: null }];
35
+
36
+ // Validate action entries early so malformed YAML doesn't reach the
37
+ // run page as a broken button set.
38
+ for (const a of actions) {
39
+ if (!a || typeof a !== "object" || typeof a.label !== "string") {
40
+ throw new Error(
41
+ `pause_for_human: each action must be { label: string, value?: any }; got ${JSON.stringify(a)}`,
42
+ );
43
+ }
44
+ }
45
+
46
+ return {
47
+ __pause: {
48
+ reason: "pause_for_human",
49
+ message: args.message || "",
50
+ context_url: args.context_url || null,
51
+ resume_on: resumeOn,
52
+ timeout: args.timeout || null,
53
+ actions,
54
+ // Dispatcher wraps this with verb_index at emit time so `wb resume`
55
+ // knows where to re-enter.
56
+ sidecar_state: {},
57
+ },
58
+ };
59
+ },
60
+ };
@@ -0,0 +1,235 @@
1
+ // wait_for_drop — wait for files to land in a Google Drive folder.
2
+ //
3
+ // Unlike `pause_for_human`, which hands control back to the Rust side via the
4
+ // `__pause` sentinel + `slice.paused` + exit 42, `wait_for_drop` is *in-slice*:
5
+ // it keeps the sidecar alive through a poll loop, never triggering the
6
+ // pause-and-resume lifecycle. The verb returns a summary when the folder's
7
+ // contents satisfy the predicate, throws on timeout.
8
+ //
9
+ // Trade-off: the run's `wb` process must stay up for the whole wait. No
10
+ // resume-after-crash, no operator-click resume (a follow-up feature). What we
11
+ // get in exchange: simple UX for the runbook author, no out-of-band plumbing.
12
+ //
13
+ // The operator sees progress because we emit a `slice.drop_poll` event on
14
+ // each poll cycle — wb forwards these as `step.drop_poll` callbacks, and the
15
+ // per-event watchdog (SLICE_EVENT_TIMEOUT, resets on every emitted frame)
16
+ // stays alive through the wait.
17
+ //
18
+ // The Paracord relay's Google Drive connector handles auth + transport. We
19
+ // never touch Google's APIs directly — the bearer token is for the relay,
20
+ // not Google. When the relay is down we get a 502 `provider_not_connected`
21
+ // response, which we surface verbatim so the operator sees the actual
22
+ // failure mode.
23
+
24
+ import { send } from "../lib/io.js";
25
+ import { writeFileSync, mkdirSync } from "node:fs";
26
+ import { resolveInside } from "../lib/util.js";
27
+
28
+ // Poll-budget guardrails. The spec worst case (10s poll × 30min timeout =
29
+ // 180 relay calls per run) is inside typical per-key quotas; bumping timeout
30
+ // past an hour without extending poll_every would start to matter. These are
31
+ // hard caps: runbooks that exceed them should re-think the approach.
32
+ const MAX_TIMEOUT_MS = 4 * 60 * 60 * 1000; // 4h
33
+ const MIN_POLL_MS = 2_000; // 2s — relay rate-limit breathing room
34
+
35
+ const FOLDER_URL_RE =
36
+ /https:\/\/drive\.google\.com\/drive\/(?:u\/\d+\/)?folders\/([A-Za-z0-9_-]+)/;
37
+
38
+ function extractFolderId(folderUrl) {
39
+ if (!folderUrl || typeof folderUrl !== "string") {
40
+ throw new Error("wait_for_drop: folder_url is required");
41
+ }
42
+ const m = folderUrl.match(FOLDER_URL_RE);
43
+ if (!m) {
44
+ throw new Error(
45
+ `wait_for_drop: folder_url does not look like a Drive folder URL: ${folderUrl}`,
46
+ );
47
+ }
48
+ return m[1];
49
+ }
50
+
51
+ // "30s" | "5m" | "1h" | bare integer (seconds). Mirrors wb's Rust-side parser
52
+ // closely enough that authors don't need to context-switch between the two.
53
+ function parseDurationMs(s, fallbackMs) {
54
+ if (s == null) return fallbackMs;
55
+ if (typeof s === "number") return s * 1000;
56
+ const m = String(s).trim().match(/^(\d+)\s*([smh]?)$/i);
57
+ if (!m) {
58
+ throw new Error(`wait_for_drop: invalid duration "${s}" (use 30s, 5m, 1h)`);
59
+ }
60
+ const n = Number.parseInt(m[1], 10);
61
+ const unit = (m[2] || "s").toLowerCase();
62
+ const mult = unit === "h" ? 3600 : unit === "m" ? 60 : 1;
63
+ return n * mult * 1000;
64
+ }
65
+
66
+ // Shell-style glob → RegExp. Only `*` + `?` are supported; anything fancier
67
+ // is a smell in a filename pattern.
68
+ function globToRegex(pattern) {
69
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
70
+ const re = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
71
+ return new RegExp(`^${re}$`);
72
+ }
73
+
74
+ function predicateMatches(files, expect, pattern) {
75
+ if (expect === "at_least_one_file") {
76
+ return files.length > 0;
77
+ }
78
+ if (expect === "filename_matches") {
79
+ if (!pattern) {
80
+ throw new Error(
81
+ "wait_for_drop: filename_matches requires filename_pattern",
82
+ );
83
+ }
84
+ const re = globToRegex(pattern);
85
+ return files.some((f) => re.test(f.name || ""));
86
+ }
87
+ throw new Error(
88
+ `wait_for_drop: expect must be "at_least_one_file" or "filename_matches", got "${expect}"`,
89
+ );
90
+ }
91
+
92
+ async function pollFolder(relayBase, apiKey, folderId) {
93
+ const q = encodeURIComponent(`'${folderId}' in parents and trashed = false`);
94
+ const url = `${relayBase.replace(/\/$/, "")}/google_drive/drive/v3/files?q=${q}&fields=files(id,name,mimeType,modifiedTime,size)`;
95
+ const resp = await fetch(url, {
96
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
97
+ });
98
+ if (!resp.ok) {
99
+ const body = await resp.text().catch(() => "");
100
+ throw new Error(
101
+ `wait_for_drop: relay returned ${resp.status} — ${body.slice(0, 200)}`,
102
+ );
103
+ }
104
+ const data = await resp.json().catch(() => ({}));
105
+ return Array.isArray(data.files) ? data.files : [];
106
+ }
107
+
108
+ function writeBindArtifact(name, files) {
109
+ const dir = (process.env.WB_ARTIFACTS_DIR || "").trim();
110
+ if (!dir) {
111
+ // Non-fatal; downstream cells might not need the list. Matches the
112
+ // warn-and-continue posture of other artifact-writing verbs.
113
+ console.log(
114
+ `[wait_for_drop] WB_ARTIFACTS_DIR not set; skipping bind_artifact write`,
115
+ );
116
+ return null;
117
+ }
118
+ mkdirSync(dir, { recursive: true });
119
+ const target = resolveInside(dir, `${name}.json`);
120
+ if (!target) {
121
+ throw new Error(
122
+ `wait_for_drop: invalid bind_artifact name "${name}" (resolves outside artifacts dir)`,
123
+ );
124
+ }
125
+ writeFileSync(target, JSON.stringify({ files }, null, 2), "utf8");
126
+ return target;
127
+ }
128
+
129
+ export default {
130
+ name: "wait_for_drop",
131
+ primaryKey: "folder_url",
132
+ async execute(_page, args, ctx) {
133
+ const apiKey = (process.env.PARACORD_RELAY_API_KEY || "").trim();
134
+ if (!apiKey) {
135
+ throw new Error(
136
+ "wait_for_drop: PARACORD_RELAY_API_KEY is required (Google Drive connector access)",
137
+ );
138
+ }
139
+ const relayBase = (process.env.PARACORD_RELAY_URL || "").trim();
140
+ if (!relayBase) {
141
+ throw new Error(
142
+ "wait_for_drop: PARACORD_RELAY_URL is required (base URL of the Paracord relay)",
143
+ );
144
+ }
145
+
146
+ const folderId = extractFolderId(args.folder_url);
147
+ const expect = args.expect || "at_least_one_file";
148
+ const pattern = args.filename_pattern || null;
149
+ if (expect !== "at_least_one_file" && expect !== "filename_matches") {
150
+ throw new Error(
151
+ `wait_for_drop: expect must be "at_least_one_file" or "filename_matches", got "${expect}"`,
152
+ );
153
+ }
154
+ if (expect === "filename_matches" && !pattern) {
155
+ throw new Error(
156
+ "wait_for_drop: filename_matches requires filename_pattern (e.g. '*.pdf')",
157
+ );
158
+ }
159
+ const pollMs = Math.max(
160
+ MIN_POLL_MS,
161
+ parseDurationMs(args.poll_every, 10_000),
162
+ );
163
+ const timeoutMs = Math.min(
164
+ MAX_TIMEOUT_MS,
165
+ parseDurationMs(args.timeout, 30 * 60 * 1000),
166
+ );
167
+ const bindName = args.bind_artifact || "dropped_files";
168
+ const message =
169
+ args.message || "Waiting for files to land in the Drive folder";
170
+
171
+ const startedAt = Date.now();
172
+ const deadline = startedAt + timeoutMs;
173
+
174
+ // Announce the wait — gives the run page a frame to pin the widget on,
175
+ // distinct from the per-poll heartbeats below.
176
+ send({
177
+ type: "slice.drop_waiting",
178
+ verb: "wait_for_drop",
179
+ verb_index: ctx.index,
180
+ folder_url: args.folder_url,
181
+ message,
182
+ expect,
183
+ filename_pattern: pattern,
184
+ poll_every_ms: pollMs,
185
+ timeout_ms: timeoutMs,
186
+ });
187
+
188
+ let pollCount = 0;
189
+ while (Date.now() < deadline) {
190
+ pollCount++;
191
+ let files;
192
+ try {
193
+ files = await pollFolder(relayBase, apiKey, folderId);
194
+ } catch (e) {
195
+ // Surface poll errors as a heartbeat with `error:` set, then keep
196
+ // polling. A transient relay blip shouldn't abort a 30-min wait,
197
+ // but the operator should see the dashboard flag the degradation.
198
+ send({
199
+ type: "slice.drop_poll",
200
+ verb_index: ctx.index,
201
+ poll: pollCount,
202
+ error: String(e.message || e),
203
+ });
204
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
205
+ continue;
206
+ }
207
+
208
+ const matched = predicateMatches(files, expect, pattern);
209
+ send({
210
+ type: "slice.drop_poll",
211
+ verb_index: ctx.index,
212
+ poll: pollCount,
213
+ file_count: files.length,
214
+ matched,
215
+ });
216
+
217
+ if (matched) {
218
+ const written = writeBindArtifact(bindName, files);
219
+ return `wait_for_drop: matched after ${pollCount} poll(s) (${files.length} file${files.length === 1 ? "" : "s"})${written ? ` → ${bindName}.json` : ""}`;
220
+ }
221
+
222
+ const remaining = deadline - Date.now();
223
+ if (remaining <= 0) break;
224
+ await sleep(Math.min(pollMs, remaining));
225
+ }
226
+
227
+ throw new Error(
228
+ `wait_for_drop: timed out after ${Math.round(timeoutMs / 1000)}s and ${pollCount} poll(s) — no files matching ${expect}${pattern ? ` (${pattern})` : ""} appeared`,
229
+ );
230
+ },
231
+ };
232
+
233
+ function sleep(ms) {
234
+ return new Promise((resolve) => setTimeout(resolve, ms));
235
+ }