wb-browser-runtime 0.6.1 → 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/verbs/save.js ADDED
@@ -0,0 +1,55 @@
1
+ import path from "node:path";
2
+ import { promises as fsPromises } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { send } from "../lib/io.js";
5
+ import { sanitizeArtifactName, autoArtifactName } from "../lib/util.js";
6
+
7
+ export default {
8
+ name: "save",
9
+ primaryKey: "name",
10
+ async execute(_page, args, ctx) {
11
+ // Persist a JSON artifact into $WB_ARTIFACTS_DIR so later cells can read
12
+ // it and wb can upload it. Captures the previous verb's output unless
13
+ // the author provides an explicit `value:`.
14
+ const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim();
15
+ if (!artifactsDir) {
16
+ throw new Error(
17
+ "save: $WB_ARTIFACTS_DIR is not set — run this workbook via `wb run` (wb exports the dir for you)",
18
+ );
19
+ }
20
+ const explicitValue = args.value !== undefined;
21
+ const payload = explicitValue ? args.value : ctx?.lastResult;
22
+ if (payload === undefined) {
23
+ throw new Error(
24
+ "save: no value provided and no prior extract/eval result to capture",
25
+ );
26
+ }
27
+ const name =
28
+ typeof args.name === "string" && args.name.trim().length > 0
29
+ ? sanitizeArtifactName(args.name)
30
+ : autoArtifactName(ctx?.blockIndex ?? ctx?.index ?? 0);
31
+ const filename = name.endsWith(".json") ? name : `${name}.json`;
32
+ const full = path.join(artifactsDir, filename);
33
+ await fsPromises.mkdir(artifactsDir, { recursive: true });
34
+ // Atomic write: serialize to .tmp, then rename. Announce the artifact
35
+ // AFTER rename so a partial write can never be seen by wb's uploader.
36
+ const serialized = JSON.stringify(payload, null, 2);
37
+ const tmp = `${full}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
38
+ try {
39
+ await fsPromises.writeFile(tmp, serialized, "utf8");
40
+ await fsPromises.rename(tmp, full);
41
+ } catch (e) {
42
+ try {
43
+ await fsPromises.unlink(tmp);
44
+ } catch {}
45
+ throw e;
46
+ }
47
+ send({
48
+ type: "slice.artifact_saved",
49
+ filename,
50
+ path: full,
51
+ bytes: Buffer.byteLength(serialized),
52
+ });
53
+ return `→ ${filename}`;
54
+ },
55
+ };
@@ -0,0 +1,48 @@
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
+ export default {
7
+ name: "screenshot",
8
+ primaryKey: "path",
9
+ async execute(page, args) {
10
+ // Always resolve inside $WB_ARTIFACTS_DIR (or cwd when unset). Absolute
11
+ // paths and traversals are rejected — screenshots are controlled by
12
+ // runbook authors whose content we don't want to grant arbitrary-write.
13
+ const requested = args.path ?? `screenshot-${Date.now()}.png`;
14
+ const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim() || ".";
15
+ if (path.isAbsolute(requested)) {
16
+ throw new Error(
17
+ `screenshot: absolute paths are not allowed (got ${requested})`,
18
+ );
19
+ }
20
+ const full = resolveInside(artifactsDir, requested);
21
+ if (!full) {
22
+ throw new Error(
23
+ `screenshot: path escapes artifacts dir (got ${requested})`,
24
+ );
25
+ }
26
+ await fsPromises.mkdir(path.dirname(full), { recursive: true });
27
+ // Atomic write via tmp + rename so a crash mid-capture can't leave a
28
+ // truncated PNG that's already been announced via slice.artifact_saved
29
+ // and uploaded to R2. We capture to a Buffer (with `type` derived from
30
+ // the requested extension) and write it ourselves — passing a `.tmp`
31
+ // path directly to Playwright fails because it infers format from the
32
+ // file extension and rejects unknown ones.
33
+ const ext = path.extname(full).toLowerCase();
34
+ const type = ext === ".jpg" || ext === ".jpeg" ? "jpeg" : "png";
35
+ const tmp = `${full}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
36
+ try {
37
+ const buf = await page.screenshot({ type, fullPage: !!args.full_page });
38
+ await fsPromises.writeFile(tmp, buf);
39
+ await fsPromises.rename(tmp, full);
40
+ } catch (e) {
41
+ try {
42
+ await fsPromises.unlink(tmp);
43
+ } catch {}
44
+ throw e;
45
+ }
46
+ return `→ ${requested}`;
47
+ },
48
+ };
@@ -0,0 +1,13 @@
1
+ export default {
2
+ name: "wait_for",
3
+ primaryKey: "selector",
4
+ async execute(page, args) {
5
+ const selector = args.selector;
6
+ const state = args.state ?? "visible";
7
+ await page.waitForSelector(selector, {
8
+ state,
9
+ timeout: args.timeout ?? 15_000,
10
+ });
11
+ return `${selector} (${state})`;
12
+ },
13
+ };
@@ -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
+ }