@melaya/runner 1.1.68 → 1.1.70

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.
@@ -24,6 +24,9 @@
24
24
  // POST /browser/act -> {ok, result, tree, generation} |
25
25
  // {ok:false, error:{code, message}}
26
26
  // POST /browser/run -> {ok, result, console[], tree}
27
+ // POST /browser/fast -> {ok, result: text} (browser_fast: the
28
+ // server's fast executor drives this run
29
+ // over a pump, see browserFast.ts)
27
30
  //
28
31
  // Every act is ACT-AND-OBSERVE: it returns a fresh snapshot so the model
29
32
  // never acts twice on a stale world.
@@ -41,6 +44,7 @@ import { runSandboxedScript } from "./codeWorker.js";
41
44
  import { classifyControlEffect, writeGatedInMode } from "./publishGate.js";
42
45
  import { applyUploadResize, parseUploadResize, resolveDataUrlSource, resolveHandleSource, resolvePathSource, resolveUrlSource, UploadSourceError, } from "./uploadFetch.js";
43
46
  import { unlink } from "node:fs/promises";
47
+ import { StableRefs, clampHitlMode, execFastCommand, postServerJson, runFastPump, translateStepRefs, } from "./browserFast.js";
44
48
  // A wall-clock around a CDP command. Playwright's CDPSession.send has NO default
45
49
  // timeout, so a wedged/busy renderer means the promise NEVER settles (not a
46
50
  // rejection - no catch fires) and the lease op-chain queues every later op
@@ -812,6 +816,10 @@ export async function startBrowserBridge(opts) {
812
816
  }
813
817
  return rec;
814
818
  }
819
+ // The last snapshot's rendered nodes per lease, for fast mode (browserFast.ts),
820
+ // which renders its own screen with stable refs from them.
821
+ const fastSnapItems = new WeakMap();
822
+ const fastStableRefs = new WeakMap();
815
823
  async function captureSnapshot(rec, lease, scope) {
816
824
  const page = lease.page;
817
825
  const generation = sessions.beginSnapshot(lease);
@@ -874,10 +882,17 @@ export async function startBrowserBridge(opts) {
874
882
  .sort((a, b) => a.order - b.order);
875
883
  // Bind refs and render one line per node.
876
884
  const lines = [];
885
+ const fastItems = [];
877
886
  let refNo = 0;
878
887
  for (const n of candidates) {
879
888
  refNo += 1;
880
889
  const ref = `@e${refNo}`;
890
+ fastItems.push({
891
+ ref, frameKey: n.frameKey, backendNodeId: n.backendNodeId, role: n.role, name: n.name,
892
+ aria: n.aria, domId: n.domId, href: n.href, click: n.click, edit: n.edit, covered: n.covered,
893
+ l: n.l, t: n.t, r: n.r, b: n.b,
894
+ ...(n.edit && n.value ? { value: n.value } : {}),
895
+ });
881
896
  sessions.bindRef(lease, {
882
897
  ref,
883
898
  targetRef: lease.ref,
@@ -895,6 +910,7 @@ export async function startBrowserBridge(opts) {
895
910
  const href = n.href ? ` @${n.href.slice(0, 100)}` : "";
896
911
  lines.push(`${ref} <${n.role}>${flags}${name}${aria}${domId}${href} <${Math.round(n.l)},${Math.round(n.t)},${Math.round(n.r)},${Math.round(n.b)}>`);
897
912
  }
913
+ fastSnapItems.set(lease, { generation, items: fastItems });
898
914
  const url = page.url();
899
915
  const title = await page.title().catch(() => "");
900
916
  const header = `url=${url} title=${title.slice(0, 120)} gen=${generation} nodes=${lines.length}/${total}`;
@@ -1048,7 +1064,19 @@ export async function startBrowserBridge(opts) {
1048
1064
  }
1049
1065
  }
1050
1066
  catch { /* unknown -> leave uncovered (conservative) */ }
1067
+ // A field's value, for fast mode's check that typed text
1068
+ // landed. Never a secret field's (password, one-time code,
1069
+ // card number): those are left out entirely.
1070
+ let value = "";
1071
+ if (edit) {
1072
+ const type = attr(dom.doc, dom.nodeIdx, "type").toLowerCase();
1073
+ const ac = attr(dom.doc, dom.nodeIdx, "autocomplete").toLowerCase();
1074
+ const secret = type === "password" || /(^|\s)(current-password|new-password|one-time-code|cc-number|cc-csc)(\s|$)/.test(ac);
1075
+ if (!secret)
1076
+ value = String(n.value?.value ?? "").slice(0, 400);
1077
+ }
1051
1078
  out.push({
1079
+ ...(value ? { value } : {}),
1052
1080
  frameKey,
1053
1081
  backendNodeId: backendId,
1054
1082
  role: role || "node",
@@ -1728,6 +1756,25 @@ export async function startBrowserBridge(opts) {
1728
1756
  const value = String(args.value ?? "");
1729
1757
  const cdp = await getCdp(rec, page);
1730
1758
  const resolved = await cdpRace(cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId }), "DOM.resolveNode");
1759
+ if (args.match_label) {
1760
+ // Fast mode names an option by what the dropdown SHOWS (its text)
1761
+ // or by its value; pick the one that matches, and say so when none
1762
+ // does instead of setting a value the <select> does not have.
1763
+ const r = await cdpRace(cdp.send("Runtime.callFunctionOn", {
1764
+ objectId: resolved.object.objectId,
1765
+ functionDeclaration: "function(v){ var w = String(v).replace(/\\s+/g,' ').trim().toLowerCase(); var opts = this.options ? Array.prototype.slice.call(this.options) : [];" +
1766
+ " var hit = opts.find(function(o){ return String(o.value).trim().toLowerCase() === w; }) ||" +
1767
+ " opts.find(function(o){ return String(o.text).replace(/\\s+/g,' ').trim().toLowerCase() === w; });" +
1768
+ " if (!hit) return null; this.value = hit.value; this.dispatchEvent(new Event('input',{bubbles:true}));" +
1769
+ " this.dispatchEvent(new Event('change',{bubbles:true})); return hit.value; }",
1770
+ arguments: [{ value }],
1771
+ returnByValue: true,
1772
+ }), "Runtime.callFunctionOn");
1773
+ if (r.result?.value === null || r.result?.value === undefined) {
1774
+ throw new BridgeError("act_args_invalid", `the dropdown has no option "${value.slice(0, 80)}"`);
1775
+ }
1776
+ return { selected: String(r.result.value) };
1777
+ }
1731
1778
  await cdpRace(cdp.send("Runtime.callFunctionOn", {
1732
1779
  objectId: resolved.object.objectId,
1733
1780
  functionDeclaration: `function(v){ this.value = v; this.dispatchEvent(new Event('input',{bubbles:true})); this.dispatchEvent(new Event('change',{bubbles:true})); }`,
@@ -2039,6 +2086,132 @@ export async function startBrowserBridge(opts) {
2039
2086
  },
2040
2087
  };
2041
2088
  }
2089
+ // -- Fast mode (browser_fast) on this browser -----------------------------
2090
+ //
2091
+ // The server runs the fast executor (fastRun.ts) and sends this bridge one
2092
+ // command at a time over a pump the bridge itself opens (browserFast.ts).
2093
+ // Every command runs through performAct on THIS run's registration, so the
2094
+ // grant's effect ceiling and origin policy, the takeover pause and the
2095
+ // publish gate apply exactly as for the per-step tools.
2096
+ const FAST_CONTROL_TIMEOUT_MS = 195_000; // browser.py holds the call 200 s; the server answers by 170 s
2097
+ const FAST_PUMP_TIMEOUT_MS = 45_000; // the server long-polls 25 s
2098
+ function fastStable(lease) {
2099
+ let s = fastStableRefs.get(lease);
2100
+ if (!s) {
2101
+ s = new StableRefs();
2102
+ fastStableRefs.set(lease, s);
2103
+ }
2104
+ return s;
2105
+ }
2106
+ function currentBindings(lease) {
2107
+ return [...lease.refs.values()].filter((b) => b.snapshotGeneration === lease.snapshotGeneration && b.documentGeneration === lease.documentGeneration);
2108
+ }
2109
+ function fastOps(reg) {
2110
+ return {
2111
+ state: () => ({
2112
+ cancelled: reg.cancelled,
2113
+ expired: Date.now() / 1000 > reg.spec.grant.exp + 30,
2114
+ paused: isActPaused(reg),
2115
+ }),
2116
+ observe: async () => {
2117
+ const { rec, lease } = await getLease(reg);
2118
+ const snap = await captureSnapshot(rec, lease);
2119
+ const kept = fastSnapItems.get(lease);
2120
+ return {
2121
+ url: snap.url, title: snap.title, generation: snap.generation,
2122
+ documentGeneration: lease.documentGeneration,
2123
+ items: kept && kept.generation === snap.generation ? kept.items : [],
2124
+ };
2125
+ },
2126
+ bindings: async () => {
2127
+ const { lease } = await getLease(reg);
2128
+ return { documentGeneration: lease.documentGeneration, refs: currentBindings(lease) };
2129
+ },
2130
+ act: async (args) => {
2131
+ const result = await performAct(reg, args);
2132
+ if (args.kind !== "get_text") {
2133
+ // The same settle actAndObserve gives an act before the page is read.
2134
+ const { lease } = await getLease(reg);
2135
+ await lease.page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => { });
2136
+ await lease.page.waitForTimeout(ACT_SETTLE_MS);
2137
+ }
2138
+ return result;
2139
+ },
2140
+ gateSubmit: async (mode) => {
2141
+ const { lease } = await getLease(reg);
2142
+ // Exactly the gate performAct's press_key Enter raises (same digest),
2143
+ // so the approved card is the one that later lets the Enter through.
2144
+ await enforcePublishGate(reg, lease.page, "press_key", "focused field", "press_key:Enter", mode, "publish");
2145
+ },
2146
+ location: async () => {
2147
+ const { lease } = await getLease(reg);
2148
+ return { url: lease.page.url(), title: (await lease.page.title().catch(() => "")).slice(0, 200) };
2149
+ },
2150
+ wait: async (ms) => {
2151
+ const { lease } = await getLease(reg);
2152
+ await lease.page.waitForTimeout(Math.max(0, Math.min(5_000, ms)));
2153
+ },
2154
+ stableRefs: async () => fastStable((await getLease(reg)).lease),
2155
+ };
2156
+ }
2157
+ async function handleFast(reg, payload) {
2158
+ const serverBase = String(reg.spec.serverBase || "").replace(/\/$/, "");
2159
+ const authHeader = String(reg.spec.runAuthHeader || "");
2160
+ if (!serverBase || !authHeader) {
2161
+ throw new BridgeError("source_unavailable", "fast mode needs this run's server connection, which it was registered without; use the per-step browser_* tools.");
2162
+ }
2163
+ const mode = clampHitlMode(reg.spec.hitlMode, payload["hitl_mode"]);
2164
+ const control = async (body) => {
2165
+ const { status, json } = await postServerJson(serverBase, "/api/v1/browser/runner/fast", authHeader, body, FAST_CONTROL_TIMEOUT_MS);
2166
+ if (status >= 200 && status < 300 && typeof json.result === "string")
2167
+ return json.result;
2168
+ const err = (json.error && typeof json.error === "object" ? json.error : {});
2169
+ if (status === 404 && !err.code) {
2170
+ throw new BridgeError("fast_unavailable", "this Melaya server does not run fast mode on the runner's browser yet; use the per-step browser_* tools (or browser_batch).");
2171
+ }
2172
+ throw new BridgeError(String(err.code || "fast_failed"), String(err.message || `fast mode failed (HTTP ${status})`).slice(0, 600));
2173
+ };
2174
+ const resumeId = typeof payload["fast_run_id"] === "string" ? String(payload["fast_run_id"]).trim() : "";
2175
+ if (resumeId)
2176
+ return control({ op: "resume", fast_run_id: resumeId, ...(payload["stop"] === true ? { stop: true } : {}) });
2177
+ const { run_id: _r, pipeline_name: _p, hitl_mode: _h, fast_run_id: _f, stop: _s, ...args } = payload;
2178
+ // Steps may quote the @eN of the caller's last browser_get_screen_tree:
2179
+ // turn them into stable refs while that snapshot is still the current one.
2180
+ try {
2181
+ const { lease } = await getLease(reg);
2182
+ const toStable = fastStable(lease).assign(lease.documentGeneration, currentBindings(lease));
2183
+ args.steps = translateStepRefs(args.steps, (r) => toStable.get(r) ?? null);
2184
+ }
2185
+ catch { /* no session yet: the run's own first read decides */ }
2186
+ const fastId = randomBytes(18).toString("base64url");
2187
+ const ops = fastOps(reg);
2188
+ void runFastPump({
2189
+ fastId,
2190
+ post: async (body) => {
2191
+ const { status, json } = await postServerJson(serverBase, "/api/v1/browser/runner/fast/pump", authHeader, body, FAST_PUMP_TIMEOUT_MS);
2192
+ // Refused or unknown: nothing more will come for this run.
2193
+ if (status === 401 || status === 403 || status === 404 || status === 410)
2194
+ return { end: true };
2195
+ if (status < 200 || status >= 300)
2196
+ throw new Error(`pump HTTP ${status}`);
2197
+ return json;
2198
+ },
2199
+ exec: (cmd) => execFastCommand(ops, cmd, mode),
2200
+ isCancelled: () => reg.cancelled,
2201
+ log,
2202
+ }).catch((e) => log(`[fast] pump stopped: ${e?.message || e}`));
2203
+ const grant = reg.spec.grant;
2204
+ return control({
2205
+ op: "start",
2206
+ fast_id: fastId,
2207
+ run_id: reg.spec.runId,
2208
+ session_id: grant.browserSession,
2209
+ jti: grant.jti,
2210
+ target_ref: grant.target.ref,
2211
+ hitl_mode: mode,
2212
+ args,
2213
+ });
2214
+ }
2042
2215
  // Post an (unchanged) heartbeat frame at least this often so the viewer's
2043
2216
  // frame watchdog never trips on a STATIC page. Without it, a page that is not
2044
2217
  // animating (the agent is reasoning, or the page is idle) produced ZERO frames
@@ -2330,6 +2503,11 @@ export async function startBrowserBridge(opts) {
2330
2503
  const out = await actAndObserve(reg, payload);
2331
2504
  return respond(200, out);
2332
2505
  }
2506
+ case "/browser/fast": {
2507
+ // browser_fast (browser.py): the server's fast executor drives this
2508
+ // run's browser through a pump the bridge opens (handleFast).
2509
+ return respond(200, { ok: true, result: await handleFast(reg, payload) });
2510
+ }
2333
2511
  case "/browser/run": {
2334
2512
  if (!reg.spec.codeMode) {
2335
2513
  return fail(403, "code_mode_disabled", "code mode is not enabled for this run (Phase 2 capability)");
@@ -0,0 +1,144 @@
1
+ export declare const STABLE_REF_PREFIX = "r";
2
+ export interface RefBinding {
3
+ ref: string;
4
+ frameKey: string;
5
+ backendNodeId: number;
6
+ role: string;
7
+ name: string;
8
+ snapshotGeneration?: number;
9
+ documentGeneration?: number;
10
+ }
11
+ export declare class StableRefs {
12
+ private documentGeneration;
13
+ private next;
14
+ private byKey;
15
+ private byNum;
16
+ private static keyOf;
17
+ private sync;
18
+ /** Stable ref for every binding of the current snapshot (snapshot ref ->
19
+ * stable ref). Records each node's latest role and label. */
20
+ assign(documentGeneration: number, bindings: Iterable<RefBinding>): Map<string, string>;
21
+ /** Stable ref of a binding already numbered (no new number). */
22
+ stableOf(documentGeneration: number, b: {
23
+ frameKey: string;
24
+ backendNodeId: number;
25
+ }): string | null;
26
+ /** The current snapshot's ref for a stable ref, or null when the element is
27
+ * gone, the document changed, or the node no longer carries the role and
28
+ * label it was read with. Never a guess. */
29
+ resolve(stableRef: string, documentGeneration: number, current: Iterable<RefBinding>): string | null;
30
+ }
31
+ export declare function isStableRef(ref: unknown): boolean;
32
+ /** One rendered node of the bridge's last snapshot (captureSnapshot keeps
33
+ * them per lease for this module). */
34
+ export interface SnapItem extends RefBinding {
35
+ aria: string;
36
+ domId: string;
37
+ href: string;
38
+ click: boolean;
39
+ edit: boolean;
40
+ covered: boolean;
41
+ l: number;
42
+ t: number;
43
+ r: number;
44
+ b: number;
45
+ /** Editable fields only, never a secret field's. */
46
+ value?: string;
47
+ }
48
+ /** The screen in the line format the server's parseBrowserTree reads:
49
+ * `r@e12 <role> [click] [edit] [COVERED] "name" ="value" ~aria #id @href <l,t,r,b>`.
50
+ * A COVERED element (something else is painted over its centre) is not
51
+ * offered as clickable: a click there lands on whatever covers it. */
52
+ export declare function renderFastLines(items: SnapItem[], toStable: Map<string, string>): string[];
53
+ /** The LLM usually read the page with browser_get_screen_tree before planning,
54
+ * so its steps may quote that snapshot's @eN (a target's `ref`, a list's
55
+ * `in`). The run's first read takes a new snapshot, which would orphan them:
56
+ * translate them to stable refs now, while that snapshot is still current.
57
+ * A ref that no longer resolves is dropped; the step falls back to its label. */
58
+ export declare function translateStepRefs(steps: unknown, toStable: (snapshotRef: string) => string | null): unknown;
59
+ /** The run's autonomy: the registered (server-clamped) mode, which a caller
60
+ * holding the bridge token may TIGHTEN but never loosen (same rule as the
61
+ * bridge's publish gate). No registered mode: the caller's, default safe. */
62
+ export declare function clampHitlMode(specMode: unknown, bodyMode: unknown): "safe" | "payments_only" | "autonomous";
63
+ export interface FastObservation {
64
+ url: string;
65
+ title: string;
66
+ generation: number;
67
+ documentGeneration: number;
68
+ items: SnapItem[];
69
+ }
70
+ /** What the bridge gives a fast session. Every write goes through act, which
71
+ * is the bridge's performAct for this run's registration. */
72
+ export interface FastBridgeOps {
73
+ /** The run is torn down, its grant expired, or the user took over. */
74
+ state(): {
75
+ cancelled: boolean;
76
+ expired: boolean;
77
+ paused: boolean;
78
+ };
79
+ /** Take a snapshot (the bridge binds its @eN refs) and return its nodes. */
80
+ observe(): Promise<FastObservation>;
81
+ /** The current snapshot's bindings, for resolving a stable ref. */
82
+ bindings(): Promise<{
83
+ documentGeneration: number;
84
+ refs: RefBinding[];
85
+ }>;
86
+ /** One act through performAct. Throws {code, message} on refusal. */
87
+ act(args: Record<string, unknown>): Promise<unknown>;
88
+ /** Stage (or correlate) the approval a submitting Enter needs, BEFORE
89
+ * anything is typed. Throws approval_required while it is pending. */
90
+ gateSubmit(mode: string): Promise<void>;
91
+ /** URL and title of the controlled tab. */
92
+ location(): Promise<{
93
+ url: string;
94
+ title: string;
95
+ }>;
96
+ wait(ms: number): Promise<void>;
97
+ /** Stable refs of this lease (kept across fast runs, per document). */
98
+ stableRefs(): Promise<StableRefs>;
99
+ }
100
+ export interface FastCommand {
101
+ seq: number;
102
+ kind: string;
103
+ body: Record<string, unknown>;
104
+ }
105
+ export type FastOutcome = {
106
+ ok: true;
107
+ data: Record<string, unknown>;
108
+ } | {
109
+ ok: false;
110
+ code: string;
111
+ message: string;
112
+ };
113
+ export declare class FastCommandError extends Error {
114
+ code: string;
115
+ constructor(code: string, message: string);
116
+ }
117
+ /** Run one command the server's executor sent, under this run's grant and
118
+ * autonomy (`mode`, clamped on this side: the server cannot loosen it). */
119
+ export declare function execFastCommand(ops: FastBridgeOps, cmd: FastCommand, mode: string): Promise<FastOutcome>;
120
+ export interface PumpReply {
121
+ command?: FastCommand;
122
+ end?: boolean;
123
+ idle?: boolean;
124
+ }
125
+ /** Pull the server executor's commands and run them, one at a time, until the
126
+ * server ends the run or this run is torn down. A command is executed once:
127
+ * its outcome is carried by the next poll, resent unchanged if that poll
128
+ * fails, and the server ignores an outcome it already has. Nothing is ever
129
+ * re-run here. */
130
+ export declare function runFastPump(o: {
131
+ fastId: string;
132
+ post: (body: Record<string, unknown>) => Promise<PumpReply>;
133
+ exec: (cmd: FastCommand) => Promise<FastOutcome>;
134
+ isCancelled: () => boolean;
135
+ log?: (msg: string) => void;
136
+ /** Consecutive failed polls before giving up (the server's own command
137
+ * deadline then reports the step as unknown). */
138
+ maxFailures?: number;
139
+ sleep?: (ms: number) => Promise<void>;
140
+ }): Promise<void>;
141
+ export declare function postServerJson(serverBase: string, path: string, authHeader: string, body: Record<string, unknown>, timeoutMs: number): Promise<{
142
+ status: number;
143
+ json: Record<string, unknown>;
144
+ }>;