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 +14 -3
- package/bin/wb-browser-runtime.js +38 -5
- package/lib/providers/browser-use.js +1 -1
- package/package.json +18 -1
- package/verbs/announce_artifact.js +74 -0
- package/verbs/index.js +12 -1
- package/verbs/pause_for_human.js +60 -0
- package/verbs/wait_for_drop.js +235 -0
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|