wb-browser-runtime 0.7.0 → 0.10.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
 
@@ -264,10 +264,17 @@ async function handleSlice(msg) {
264
264
  return;
265
265
  }
266
266
 
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;
267
+ // Restore-from-pause: when the Rust side resumes us after a
268
+ // `slice.paused` frame, `restore.state.verb_index` is the index of the
269
+ // verb that paused. We skip *past* it — the verb has no post-resume
270
+ // work (any payload from the operator is already in
271
+ // $WB_ARTIFACTS_DIR/pause_result.json, written by `wb resume` before
272
+ // it re-boots the sidecar). Skipping keeps pause verbs pure: their
273
+ // only job is "halt now," not "halt, then continue."
274
+ const startAt =
275
+ restore?.state?.verb_index !== undefined
276
+ ? Number(restore.state.verb_index) + 1
277
+ : 0;
271
278
 
272
279
  for (let i = startAt; i < verbs.length; i++) {
273
280
  if (Date.now() >= sliceDeadline) {
@@ -282,6 +289,32 @@ async function handleSlice(msg) {
282
289
  const verbStart = Date.now();
283
290
  try {
284
291
  const summary = await runVerb(session.page, v, i, sliceCtx, expand);
292
+ // Pause-sentinel escape hatch: a verb signals a mid-slice halt by
293
+ // returning `{ __pause: {...} }`. We translate that into a
294
+ // `slice.paused` frame (so the Rust side writes a pending
295
+ // descriptor and exits 42) and bail out of the verb loop without
296
+ // firing `slice.complete`. Non-pause verbs hand back a plain
297
+ // summary and the loop proceeds normally.
298
+ if (summary && typeof summary === "object" && summary.__pause) {
299
+ const pauseMeta = summary.__pause;
300
+ send({
301
+ type: "slice.paused",
302
+ reason: pauseMeta.reason || "slice.paused",
303
+ message: pauseMeta.message || "",
304
+ context_url: pauseMeta.context_url ?? null,
305
+ resume_on: pauseMeta.resume_on || "operator_click",
306
+ timeout: pauseMeta.timeout ?? null,
307
+ actions: pauseMeta.actions || [{ label: "Resume", value: null }],
308
+ verb: name,
309
+ verb_index: i,
310
+ // `sidecar_state` is forwarded verbatim into the Rust pending
311
+ // descriptor and handed back on resume. The verb can stash
312
+ // whatever it needs here; we always ensure verb_index is set
313
+ // so the dispatcher can compute startAt on re-entry.
314
+ sidecar_state: { ...(pauseMeta.sidecar_state || {}), verb_index: i },
315
+ });
316
+ return;
317
+ }
285
318
  send({
286
319
  type: "verb.complete",
287
320
  verb: name,
@@ -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.10.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
+ }