browserzap 0.5.5 → 0.6.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/CHANGELOG.md +31 -0
- package/bridge-server/background-runs.js +91 -4
- package/bridge-server/cli-run.js +2 -0
- package/bridge-server/limits.js +27 -1
- package/bridge-server/mcp-server.js +25 -9
- package/bridge-server/panel.js +14 -3
- package/bridge-server/resolvers.js +287 -0
- package/bridge-server/tools.js +43 -4
- package/bridge-server/workflows.js +298 -11
- package/extension/background.js +1 -1
- package/extension/engine.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/sidepanel.css +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
A replay now waits for what the step is actually blocked on, and retries that step instead of going back.
|
|
6
|
+
|
|
7
|
+
- **The resolver engine.** When a step fails, or a click lands but changes nothing, BrowserZap works out what it is
|
|
8
|
+
waiting for — a slow request, a spinner, a target that is not usable yet, a target that has not appeared — waits for
|
|
9
|
+
exactly that, then retries the **same step**. Up to twice per step and 45 seconds of waiting, inside the run's own
|
|
10
|
+
limit. A failed step is never replayed from step 1 unless that is where it failed.
|
|
11
|
+
- **Clicks are still never repeated blindly.** A consequential click (submit, pay, delete, or one that needed your
|
|
12
|
+
approval) is never retried. A click is retried only when it provably did not happen, or when it was reversible,
|
|
13
|
+
changed nothing at all **and started no request**. A click that started a request is never clicked again.
|
|
14
|
+
- **New wait conditions:** an element is usable (visible, enabled, uncovered and not moving), a spinner or "Loading…"
|
|
15
|
+
is gone, the page's network is quiet, or a specific request has answered. A 4xx/5xx answers at once instead of
|
|
16
|
+
waiting. A dashboard that refreshes itself every few seconds no longer looks busy forever, and analytics traffic is
|
|
17
|
+
ignored.
|
|
18
|
+
- **Learns each step.** After three runs a step's wait budget comes from what it usually needs; a blocker it hit in
|
|
19
|
+
at least 60 % of its **last 10 runs** is waited for *before* acting, and one that stops happening stops being waited
|
|
20
|
+
for, so a site that was fixed is not slowed down; a wait that never helps is skipped there. You can override with
|
|
21
|
+
`patience` (`fast`/`normal`/`patient`) or `maxWaitMs` on a step. The saved workflow is never changed automatically;
|
|
22
|
+
hints appear in the run result.
|
|
23
|
+
- **Remembers what a step produced.** The recorder saves the dialog that appeared, the request the step started
|
|
24
|
+
(method plus a path with ids replaced by `*`) and how long the page took to settle. On replay a step is done when
|
|
25
|
+
that is there, waiting at most the settle the recording measured (or your `maxWaitMs`) — a bounded check that ends
|
|
26
|
+
as soon as the evidence appears, never a pause. Never a value, a query string or page text.
|
|
27
|
+
- **Re-run failed resumes at the step that failed** when the page has not moved since the last verified step;
|
|
28
|
+
otherwise at the nearest earlier page load. It first checks, with a short bounded wait, that the failed step's
|
|
29
|
+
target is really on the page now — a page that moved goes back to the page load, and a workflow with no page load
|
|
30
|
+
of its own fails that set with `RESUME_UNVERIFIED` rather than acting on a page nobody checked. The batch summary
|
|
31
|
+
says how many sets resumed.
|
|
32
|
+
- No new tools and no new permission: still 34 tools, and the bridge stays loopback-only and authenticated.
|
|
33
|
+
|
|
3
34
|
## 0.5.5 (2026-10-30)
|
|
4
35
|
|
|
5
36
|
Comment mode, debugging tools, files, Edge and WSL, a much better doctor, and the Chrome Web Store build. Known items to review are listed in docs/REVIEW_LATER.md (upload approval visibility, a file the person picks themselves, a download re-test, wider Comment screenshots, a calmer side panel).
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
'use strict';
|
|
14
14
|
|
|
15
15
|
const crypto = require('crypto');
|
|
16
|
+
const { LIMITS } = require('./limits');
|
|
17
|
+
const { resolveParams, substitute } = require('./workflows');
|
|
16
18
|
|
|
17
19
|
/** Finished runs kept for workflow_status and rerunFailed. */
|
|
18
20
|
const MAX_KEPT = 10;
|
|
@@ -25,6 +27,85 @@ class RunError extends Error {
|
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** Steps that only read the page: a resume may start at one without a probe. */
|
|
31
|
+
const RESUME_WITHOUT_PROBE = new Set(['wait_for_state', 'page_inspect', 'screenshot_capture', 'page_console', 'page_network']);
|
|
32
|
+
|
|
33
|
+
/** The nearest `browser_navigate` at or before `index`; -1 when there is none. */
|
|
34
|
+
function navigationAnchor(wf, index) {
|
|
35
|
+
let found = -1;
|
|
36
|
+
for (let i = 0; i <= index && Array.isArray(wf?.steps) && i < wf.steps.length; i++) {
|
|
37
|
+
if (wf.steps[i].tool === 'browser_navigate') found = i;
|
|
38
|
+
}
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where a set that did not complete may resume (0.6.0). It stays at the failed
|
|
44
|
+
* step when the page has not navigated since the last verified step; otherwise
|
|
45
|
+
* it starts at the nearest navigation anchor at or before the failure. Never
|
|
46
|
+
* step 1 unless that is the anchor.
|
|
47
|
+
*/
|
|
48
|
+
function resumeAtFor(wf, run) {
|
|
49
|
+
if (!wf || !run || !Number.isInteger(run.stoppedAt)) return 0;
|
|
50
|
+
const checkpoint = Number.isInteger(run.checkpoint) ? run.checkpoint : -1;
|
|
51
|
+
const lastNavigation = navigationAnchor(wf, run.stoppedAt);
|
|
52
|
+
if (checkpoint >= 0 && checkpoint >= lastNavigation) return run.stoppedAt;
|
|
53
|
+
if (lastNavigation >= 0) return lastNavigation;
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A resume: where to start (`at`), the navigation anchor to fall back to when
|
|
59
|
+
* the page has moved (`anchor`, -1 when the workflow has none) and the failed
|
|
60
|
+
* step's own target (`target`), which is what a resume at that step must find
|
|
61
|
+
* actionable before it runs (A6-09). Values are not substituted here: the
|
|
62
|
+
* caller resolves them per parameter set.
|
|
63
|
+
*/
|
|
64
|
+
function resumePlan(wf, run) {
|
|
65
|
+
const at = resumeAtFor(wf, run);
|
|
66
|
+
const anchor = Number.isInteger(run?.stoppedAt) ? navigationAnchor(wf, run.stoppedAt) : -1;
|
|
67
|
+
const target = wf?.steps?.[at]?.args?.target;
|
|
68
|
+
return { at, anchor, target: at > 0 && target && typeof target === 'object' ? target : null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Prove the page is still where a resume at the failed step expects it.
|
|
73
|
+
*
|
|
74
|
+
* A real `wait_for_state { actionable: <the step's target> }` through the
|
|
75
|
+
* normal tool path, bounded by LIMITS.RESUME_PROBE_MS (a poll that ends as
|
|
76
|
+
* soon as the target is ready, never a pause). When it does not hold, the set
|
|
77
|
+
* starts at its navigation anchor instead. With no anchor there is nothing
|
|
78
|
+
* safe to start from, so it throws RESUME_UNVERIFIED: a set is never resumed
|
|
79
|
+
* with a real click on a page nobody checked.
|
|
80
|
+
*
|
|
81
|
+
* Returns the step to start at.
|
|
82
|
+
*/
|
|
83
|
+
async function probeResume({ wf, plan, params = {}, execute, allowedOrigins = null, timeoutMs = LIMITS.RESUME_PROBE_MS }) {
|
|
84
|
+
const at = plan?.at ?? 0;
|
|
85
|
+
if (!(at > 0) || !execute) return at;
|
|
86
|
+
const tool = wf?.steps?.[at]?.tool;
|
|
87
|
+
let reason;
|
|
88
|
+
if (plan?.target) {
|
|
89
|
+
const target = substitute(plan.target, resolveParams(wf, params));
|
|
90
|
+
const env = await execute('wait_for_state', { actionable: target, timeoutMs }, { allowedOrigins, index: at });
|
|
91
|
+
if (env.ok) return at;
|
|
92
|
+
reason = env.error?.code ?? 'the probe did not hold';
|
|
93
|
+
} else if (tool === 'browser_navigate' || RESUME_WITHOUT_PROBE.has(tool)) {
|
|
94
|
+
return at;
|
|
95
|
+
} else {
|
|
96
|
+
// A key press with no target (Enter, say) acts on whatever has focus:
|
|
97
|
+
// there is nothing to probe, so it is never the first thing a resume does.
|
|
98
|
+
reason = 'the step has no target to check';
|
|
99
|
+
}
|
|
100
|
+
if (plan.anchor >= 0) return plan.anchor;
|
|
101
|
+
throw new RunError(
|
|
102
|
+
'RESUME_UNVERIFIED',
|
|
103
|
+
`The page is not where step ${at + 1} of "${wf.name}" expects it (${reason}), ` +
|
|
104
|
+
'and the workflow has no earlier page load to go back to, so nothing was run.',
|
|
105
|
+
'Open the page the workflow starts on, or save it with a browser_navigate first, then run it from the start.',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
28
109
|
/**
|
|
29
110
|
* @param {object} deps
|
|
30
111
|
* @param {(job: object) => Promise<object>} deps.execute runs the batch for a job; resolves with the bulk result
|
|
@@ -43,7 +124,7 @@ function createRunRegistry({ execute, onFinish = () => {}, now = Date.now }) {
|
|
|
43
124
|
* Start a batch. `paramSets` and `labels` are kept in memory only.
|
|
44
125
|
* Throws RunError(TAB_BUSY) while another run is going.
|
|
45
126
|
*/
|
|
46
|
-
function start({ wf, paramSets, labels = [], onError = 'continue' }) {
|
|
127
|
+
function start({ wf, paramSets, labels = [], onError = 'continue', resume = [] }) {
|
|
47
128
|
const running = active();
|
|
48
129
|
if (running) {
|
|
49
130
|
throw new RunError('TAB_BUSY', `"${running.workflow}" is still running in the background (${running.runId}).`,
|
|
@@ -57,6 +138,7 @@ function createRunRegistry({ execute, onFinish = () => {}, now = Date.now }) {
|
|
|
57
138
|
total: paramSets.length,
|
|
58
139
|
paramSets,
|
|
59
140
|
labels,
|
|
141
|
+
resume,
|
|
60
142
|
wf,
|
|
61
143
|
onError,
|
|
62
144
|
state: 'running',
|
|
@@ -130,15 +212,19 @@ function createRunRegistry({ execute, onFinish = () => {}, now = Date.now }) {
|
|
|
130
212
|
throw new RunError('TAB_BUSY', `"${job.workflow}" (${runId}) is still running.`, 'Wait for it to finish, or cancel it.');
|
|
131
213
|
}
|
|
132
214
|
const runs = job.result?.evidence?.runs ?? [];
|
|
133
|
-
const finished = new Map(runs.map((r) => [r.index, r
|
|
215
|
+
const finished = new Map(runs.map((r) => [r.index, r]));
|
|
134
216
|
const indexes = [];
|
|
135
|
-
for (let i = 0; i < job.total; i++) if (finished.get(i) !== true) indexes.push(i);
|
|
217
|
+
for (let i = 0; i < job.total; i++) if (finished.get(i)?.ok !== true) indexes.push(i);
|
|
136
218
|
return {
|
|
137
219
|
job,
|
|
138
220
|
indexes,
|
|
139
221
|
paramSets: indexes.map((i) => job.paramSets[i]),
|
|
140
222
|
// A set keeps its name from the first run ("#3" stays "#3").
|
|
141
223
|
labels: indexes.map((i) => job.labels[i] ?? `#${i + 1}`),
|
|
224
|
+
// Where each set may resume (0.6.0): the failed step, or the nearest
|
|
225
|
+
// navigation anchor when the page has moved since the last verified step.
|
|
226
|
+
// A resume at the failed step is probed first (probeResume, A6-09).
|
|
227
|
+
resume: indexes.map((i) => resumePlan(job.wf, finished.get(i))),
|
|
142
228
|
};
|
|
143
229
|
}
|
|
144
230
|
|
|
@@ -194,6 +280,7 @@ function compactBatch(result) {
|
|
|
194
280
|
needAttention: ev.needAttention ?? attention.length,
|
|
195
281
|
notRun: ev.notRun ?? 0,
|
|
196
282
|
skippedSteps: (ev.runs ?? []).reduce((n, r) => n + (r.skipped?.length ?? 0), 0),
|
|
283
|
+
resolvedSteps: (ev.runs ?? []).reduce((n, r) => n + (r.resolvedSteps ?? 0), 0),
|
|
197
284
|
recovered: (ev.runs ?? []).filter((r) => r.recoveries?.length).length,
|
|
198
285
|
elapsedMs: ev.elapsedMs ?? null,
|
|
199
286
|
report: ev.report ?? null,
|
|
@@ -201,4 +288,4 @@ function compactBatch(result) {
|
|
|
201
288
|
};
|
|
202
289
|
}
|
|
203
290
|
|
|
204
|
-
module.exports = { createRunRegistry, compactBatch, RunError };
|
|
291
|
+
module.exports = { createRunRegistry, compactBatch, resumeAtFor, resumePlan, navigationAnchor, probeResume, RunError };
|
package/bridge-server/cli-run.js
CHANGED
|
@@ -75,6 +75,8 @@ function describe(env) {
|
|
|
75
75
|
`${f?.likelyCause ? `, ${f.likelyCause}` : ''}${f?.screenshot ? `\n screenshot: ${f.screenshot}` : ''}`);
|
|
76
76
|
}
|
|
77
77
|
if (env.evidence?.report) lines.push(`Report: ${env.evidence.report} (browserzap --runs)`);
|
|
78
|
+
const resolved = runs.reduce((n, r) => n + (r.resolvedSteps ?? 0), 0);
|
|
79
|
+
if (resolved) lines.push(` ${resolved} step(s) resolved in place (a wait, then the same step again).`);
|
|
78
80
|
if (!runs.length && !env.ok && env.nextAction) lines.push(env.nextAction);
|
|
79
81
|
return lines.join('\n');
|
|
80
82
|
}
|
package/bridge-server/limits.js
CHANGED
|
@@ -26,6 +26,13 @@ const LIMITS = Object.freeze({
|
|
|
26
26
|
SETTLE_QUIET_MS: 500,
|
|
27
27
|
/** How long that settle waits for a busy page (it costs nothing on a quiet one). */
|
|
28
28
|
SETTLE_MAX_MS: 5000,
|
|
29
|
+
/** A wait whose condition makes no progress for this long ends early: a page that is stuck does not get better. */
|
|
30
|
+
STALL_MS: 3000,
|
|
31
|
+
/**
|
|
32
|
+
* Before "Re-run failed" resumes at the step that failed, that step's target
|
|
33
|
+
* must be actionable within this. A bounded probe, not a pause (A6-09).
|
|
34
|
+
*/
|
|
35
|
+
RESUME_PROBE_MS: 3000,
|
|
29
36
|
/** Longest per-step pacing a person may set on a saved workflow step. */
|
|
30
37
|
MAX_STEP_PACING_MS: 30000,
|
|
31
38
|
/** A tab with no agent action for this long is detached, so Chrome's "debugging" bar goes away (D-35). */
|
|
@@ -60,6 +67,16 @@ const STATUS_DEADLINE_MS = 4_000;
|
|
|
60
67
|
*/
|
|
61
68
|
const RUN_DEADLINE_MS = 600_000;
|
|
62
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Resolver engine bounds (`bridge-server/resolvers.js`, 0.6.0, D-37).
|
|
72
|
+
* A step may spend at most MAX_STEP_RESOLVE_MS resolving blockers in place,
|
|
73
|
+
* across at most MAX_RESOLVER_ATTEMPTS attempts, and a reversible click with
|
|
74
|
+
* no effect at all is repeated at most MAX_INPLACE_CLICK_RETRIES times (AU-08).
|
|
75
|
+
*/
|
|
76
|
+
const MAX_STEP_RESOLVE_MS = 45_000;
|
|
77
|
+
const MAX_RESOLVER_ATTEMPTS = 2;
|
|
78
|
+
const MAX_INPLACE_CLICK_RETRIES = 2;
|
|
79
|
+
|
|
63
80
|
/**
|
|
64
81
|
* How long the caller waits for the extension to answer one call: the sum of
|
|
65
82
|
* every wait the call can do, plus a margin, and never less than
|
|
@@ -97,4 +114,13 @@ function callDeadlineMs(tool, args = {}) {
|
|
|
97
114
|
return Math.max(CALL_DEADLINE_MS, waits + DEADLINE_MARGIN_MS);
|
|
98
115
|
}
|
|
99
116
|
|
|
100
|
-
module.exports = {
|
|
117
|
+
module.exports = {
|
|
118
|
+
LIMITS,
|
|
119
|
+
CALL_DEADLINE_MS,
|
|
120
|
+
STATUS_DEADLINE_MS,
|
|
121
|
+
RUN_DEADLINE_MS,
|
|
122
|
+
MAX_STEP_RESOLVE_MS,
|
|
123
|
+
MAX_RESOLVER_ATTEMPTS,
|
|
124
|
+
MAX_INPLACE_CLICK_RETRIES,
|
|
125
|
+
callDeadlineMs,
|
|
126
|
+
};
|
|
@@ -41,7 +41,7 @@ const workflows = require('./workflows');
|
|
|
41
41
|
const screenshots = require('./screenshots');
|
|
42
42
|
const feedback = require('./feedback');
|
|
43
43
|
const downloads = require('./downloads');
|
|
44
|
-
const { createRunRegistry, compactBatch, RunError } = require('./background-runs');
|
|
44
|
+
const { createRunRegistry, compactBatch, probeResume, RunError } = require('./background-runs');
|
|
45
45
|
|
|
46
46
|
const { WorkflowError, Recorder } = workflows;
|
|
47
47
|
|
|
@@ -929,7 +929,7 @@ async function runTool(requestId, traceId, args, progress) {
|
|
|
929
929
|
* redaction as screenshot_capture).
|
|
930
930
|
*/
|
|
931
931
|
async function runBatchOrOnce({
|
|
932
|
-
wf, params, paramSets, labels, onError, startAt = 0, approvalId, signal,
|
|
932
|
+
wf, params, paramSets, labels, onError, startAt = 0, approvalId, signal, resume = [],
|
|
933
933
|
onCall = () => {}, onStep = () => {}, onSet = () => {}, onSetDone = () => {},
|
|
934
934
|
}) {
|
|
935
935
|
const execute = async (tool, stepArgs, { allowedOrigins }) => {
|
|
@@ -946,14 +946,14 @@ async function runBatchOrOnce({
|
|
|
946
946
|
const { env } = await executeTool('screenshot_capture', {}, { allowedOrigins: ALLOWED_ORIGINS });
|
|
947
947
|
return env.ok ? env.screenshotPath ?? null : null;
|
|
948
948
|
};
|
|
949
|
-
const runOnce = async (setParams, first) => {
|
|
949
|
+
const runOnce = async (setParams, first, setStartAt = 0) => {
|
|
950
950
|
const startedAt = new Date().toISOString();
|
|
951
951
|
const history = workflows.runHistory(wf);
|
|
952
952
|
const result = await workflows.run(wf, {
|
|
953
953
|
history,
|
|
954
954
|
onStep,
|
|
955
955
|
params: setParams,
|
|
956
|
-
startAt: first ? startAt : 0,
|
|
956
|
+
startAt: setStartAt || (first ? startAt : 0),
|
|
957
957
|
approvalId: first ? approvalId : undefined,
|
|
958
958
|
execute,
|
|
959
959
|
capture,
|
|
@@ -964,7 +964,7 @@ async function runBatchOrOnce({
|
|
|
964
964
|
service.recordRun({ wf, result, params: setParams, startedAt });
|
|
965
965
|
return result;
|
|
966
966
|
};
|
|
967
|
-
if (paramSets === undefined) return runOnce(params, true);
|
|
967
|
+
if (paramSets === undefined) return runOnce(params, true, startAt);
|
|
968
968
|
let first = true;
|
|
969
969
|
return pro.runBulk({
|
|
970
970
|
wf,
|
|
@@ -972,9 +972,22 @@ async function runBatchOrOnce({
|
|
|
972
972
|
labels,
|
|
973
973
|
onError,
|
|
974
974
|
signal,
|
|
975
|
+
resume,
|
|
976
|
+
// A resume at the failed step is probed first, as a real wait_for_state
|
|
977
|
+
// through executeTool; the page has moved, so the set starts at the
|
|
978
|
+
// navigation anchor instead (A6-09). With no anchor it fails without acting.
|
|
979
|
+
resumeStart: ({ plan, params: setParams }) => probeResume({
|
|
980
|
+
wf, plan, params: setParams, execute, allowedOrigins: ALLOWED_ORIGINS,
|
|
981
|
+
}),
|
|
975
982
|
onSet,
|
|
976
983
|
onSetDone,
|
|
977
|
-
|
|
984
|
+
// A re-run of the sets that failed resumes at the step that failed where
|
|
985
|
+
// the page allows it (0.6.0), instead of starting every set at step 1.
|
|
986
|
+
runOnce: (setParams, setStartAt) => {
|
|
987
|
+
const r = runOnce(setParams, first, setStartAt ?? 0);
|
|
988
|
+
first = false;
|
|
989
|
+
return r;
|
|
990
|
+
},
|
|
978
991
|
});
|
|
979
992
|
}
|
|
980
993
|
|
|
@@ -985,6 +998,7 @@ const backgroundRuns = createRunRegistry({
|
|
|
985
998
|
paramSets: job.paramSets,
|
|
986
999
|
labels: job.labels,
|
|
987
1000
|
onError: job.onError,
|
|
1001
|
+
resume: job.resume,
|
|
988
1002
|
signal: job.controller.signal,
|
|
989
1003
|
onCall: (c) => { job.call = c; },
|
|
990
1004
|
onStep: ({ index }) => backgroundRuns.progress(job, { stepIndex: index }),
|
|
@@ -1040,14 +1054,16 @@ function statusOfRuns(traceId, args) {
|
|
|
1040
1054
|
}
|
|
1041
1055
|
|
|
1042
1056
|
function rerunFailedTool(traceId, args) {
|
|
1043
|
-
const { job: previous, paramSets, labels } = backgroundRuns.failedSets(args.rerunFailed);
|
|
1057
|
+
const { job: previous, paramSets, labels, resume } = backgroundRuns.failedSets(args.rerunFailed);
|
|
1044
1058
|
if (previous.workflow !== args.name) {
|
|
1045
1059
|
return errorEnvelope(traceId, 'INVALID_ARGUMENT', `${previous.runId} ran "${previous.workflow}", not "${args.name}".`);
|
|
1046
1060
|
}
|
|
1047
1061
|
if (paramSets.length === 0) return okEnvelope(traceId, `Every set of ${previous.runId} completed; nothing to run again.`);
|
|
1048
|
-
const job = backgroundRuns.start({ wf: workflows.load(previous.workflow), paramSets, labels, onError: 'continue' });
|
|
1062
|
+
const job = backgroundRuns.start({ wf: workflows.load(previous.workflow), paramSets, labels, onError: 'continue', resume });
|
|
1063
|
+
const resumed = resume.filter((plan) => plan?.at > 0).length;
|
|
1049
1064
|
return okEnvelope(traceId,
|
|
1050
|
-
`Running the ${paramSets.length} set(s) of ${previous.runId} that did not complete
|
|
1065
|
+
`Running the ${paramSets.length} set(s) of ${previous.runId} that did not complete` +
|
|
1066
|
+
`${resumed ? `, ${resumed} from the step that failed` : ''}, in the background (${job.runId}).`,
|
|
1051
1067
|
{ runId: job.runId }, { paramSets: paramSets.length, rerunOf: previous.runId });
|
|
1052
1068
|
}
|
|
1053
1069
|
|
package/bridge-server/panel.js
CHANGED
|
@@ -38,6 +38,7 @@ const { CALL_DEADLINE_MS } = require('./limits');
|
|
|
38
38
|
const { createWorkflowService, checkStep, stepLogEntry } = require('./workflow-service');
|
|
39
39
|
const { createSessionLog, describeResult } = require('./session-log');
|
|
40
40
|
const { parseParamSets } = require('./param-sets');
|
|
41
|
+
const { resumePlan, probeResume } = require('./background-runs');
|
|
41
42
|
const screenshots = require('./screenshots');
|
|
42
43
|
const feedback = require('./feedback');
|
|
43
44
|
|
|
@@ -218,7 +219,13 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
218
219
|
if (ok) current.batch.done += 1;
|
|
219
220
|
else current.batch.failed += 1;
|
|
220
221
|
},
|
|
221
|
-
|
|
222
|
+
// "Re-run failed" probes the failed step's target before resuming there,
|
|
223
|
+
// as a real wait_for_state; a page that has moved starts at the
|
|
224
|
+
// navigation anchor, and with no anchor the set fails without acting (A6-09).
|
|
225
|
+
resumeStart: ({ plan, params: setParams }) => probeResume({
|
|
226
|
+
wf, plan, params: setParams, execute, allowedOrigins: wf.origins,
|
|
227
|
+
}),
|
|
228
|
+
runOnce: (setParams, setStartAt) => runOnce(setParams, setStartAt ?? 0),
|
|
222
229
|
});
|
|
223
230
|
const runs = result.evidence?.runs ?? [];
|
|
224
231
|
lastBatch = {
|
|
@@ -226,6 +233,9 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
226
233
|
paramSets: batch.paramSets,
|
|
227
234
|
labels: batch.labels,
|
|
228
235
|
unfinished: batch.paramSets.map((_, i) => i).filter((i) => !runs.some((r) => r.index === i && r.ok)),
|
|
236
|
+
// Where each set may resume when the person presses "Re-run failed" (0.6.0):
|
|
237
|
+
// the plan a probe proves before it is used (A6-09).
|
|
238
|
+
resumeByIndex: Object.fromEntries(runs.filter((r) => !r.ok).map((r) => [r.index, resumePlan(wf, r)])),
|
|
229
239
|
};
|
|
230
240
|
// What the person needs to find each problem: step, cause, screenshot. Never values.
|
|
231
241
|
const attention = runs.filter((r) => !r.ok).map((r) => ({
|
|
@@ -241,7 +251,7 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
241
251
|
status: result.ok ? 'completed' : controller.signal.aborted ? 'cancelled' : 'failed',
|
|
242
252
|
summary: result.summary,
|
|
243
253
|
code: result.error?.code ?? null,
|
|
244
|
-
nextAction: lastBatch.unfinished.length ? `${lastBatch.unfinished.length} set(s) did not complete. "Re-run failed" runs just those, from the
|
|
254
|
+
nextAction: lastBatch.unfinished.length ? `${lastBatch.unfinished.length} set(s) did not complete. "Re-run failed" runs just those, from the step that failed where the page allows it.` : null,
|
|
245
255
|
canContinueFrom: null,
|
|
246
256
|
batch: { ...current.batch, attention, report: result.evidence?.report ?? null, canRerun: lastBatch.unfinished.length > 0 },
|
|
247
257
|
finishedAt: new Date().toISOString(),
|
|
@@ -396,7 +406,8 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
396
406
|
const wf = workflows.load(lastBatch.workflow);
|
|
397
407
|
const paramSets = lastBatch.unfinished.map((i) => lastBatch.paramSets[i]);
|
|
398
408
|
const labels = lastBatch.unfinished.map((i) => lastBatch.labels[i] ?? `#${i + 1}`);
|
|
399
|
-
|
|
409
|
+
const resume = lastBatch.unfinished.map((i) => lastBatch.resumeByIndex?.[i] ?? null);
|
|
410
|
+
runWorkflow(wf, {}, 0, profile, { paramSets, labels, resume }).catch((err) => log('panel_run_error', { message: err.message }));
|
|
400
411
|
return { ok: true, data: { runId: current.runId, sets: paramSets.length } };
|
|
401
412
|
}
|
|
402
413
|
case 'run':
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { LIMITS } = require('./limits');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolver engine — the registry (0.6.0, D-37).
|
|
7
|
+
*
|
|
8
|
+
* Pure: no I/O, no page access, no timers, no clocks. Given a failed (or
|
|
9
|
+
* ineffective) workflow step and the envelope the extension returned, say what
|
|
10
|
+
* the step is blocked on and what would clear it. The runner then waits for
|
|
11
|
+
* that condition as an ordinary `wait_for_state` call and retries the same
|
|
12
|
+
* step — so every wait and every retry still goes through the normal tool path
|
|
13
|
+
* and extension policy.
|
|
14
|
+
*
|
|
15
|
+
* Design: docs/NEXT_RELEASE_SCOPE_0.6.md (§3 of the 0.5 doc);
|
|
16
|
+
* the wait kinds it relies on: docs/ADR/ADR-007-waiting-and-timeouts.md.
|
|
17
|
+
*
|
|
18
|
+
* Adding a resolver is one entry here plus a fixture that fails without it.
|
|
19
|
+
* The validation rules at the bottom are checked by tests/resolvers.test.mjs.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A change here that alters behaviour resets learned demotions (step profile). */
|
|
23
|
+
const REGISTRY_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Codes that are never auto-resolved: a person, a policy, or a unique target is
|
|
27
|
+
* needed. A resolver that claimed one of these would be guessing.
|
|
28
|
+
*/
|
|
29
|
+
const FORBIDDEN_CODES = Object.freeze([
|
|
30
|
+
'USER_CONFIRMATION_REQUIRED',
|
|
31
|
+
'USER_ACTION_REQUIRED',
|
|
32
|
+
'POLICY_BLOCKED',
|
|
33
|
+
'TARGET_AMBIGUOUS',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** Risks that must never be repeated in place (audit AU-08). */
|
|
37
|
+
const CONSEQUENTIAL_RISKS = new Set(['external_side_effect', 'destructive', 'financial']);
|
|
38
|
+
|
|
39
|
+
/** Tools a resolver may retry in place. A read-only tool is never a workflow step. */
|
|
40
|
+
const RETRYABLE_TOOLS = new Set([
|
|
41
|
+
'browser_navigate', 'element_click', 'field_fill', 'select_option', 'wait_for_state', 'browser_press_key',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Failures that prove the action never ran: the target was missing, unusable or
|
|
46
|
+
* a stale ref. Only these may be retried for a click or a key press; anything
|
|
47
|
+
* else may already have submitted something (AU-08).
|
|
48
|
+
*/
|
|
49
|
+
const ACTION_DID_NOT_RUN_CODES = new Set(['TARGET_NOT_FOUND', 'TARGET_NOT_INTERACTABLE', 'STALE_SNAPSHOT']);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Failures a resolver may act on: the page has not got there yet. Anything else
|
|
53
|
+
* — a bad argument, a Pro gate, a missing workflow, a cancelled run — is
|
|
54
|
+
* deterministic, and waiting for the page cannot change it (ADR-007: only
|
|
55
|
+
* failures that can clear up are waited on). A successful call that changed
|
|
56
|
+
* nothing has no code at all and is classified by its evidence.
|
|
57
|
+
*/
|
|
58
|
+
const TRANSIENT_CODES = Object.freeze([
|
|
59
|
+
'TIMEOUT', 'TARGET_NOT_FOUND', 'TARGET_NOT_INTERACTABLE', 'STALE_SNAPSHOT',
|
|
60
|
+
'NAVIGATION_FAILED', 'BRIDGE_DISCONNECTED', 'POSTCONDITION_NOT_MET', 'SITE_ERROR',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** The wait kinds the extension understands (registered in doWaitFor). */
|
|
64
|
+
const WAIT_KINDS = Object.freeze(['urlContains', 'text', 'target', 'targetGone', 'actionable', 'indicatorGone', 'networkQuiet', 'requestDone']);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ordered: the more specific answer wins. The condition hierarchy follows the
|
|
68
|
+
* design — the step's own expectation, then actionability, then a specific
|
|
69
|
+
* request, then a UI signal, then global network quiet (as a fallback only).
|
|
70
|
+
*/
|
|
71
|
+
const RESOLVERS = Object.freeze([
|
|
72
|
+
{
|
|
73
|
+
id: 'expectation_unmet',
|
|
74
|
+
safety: 'retry_reversible',
|
|
75
|
+
then: 'retry_step',
|
|
76
|
+
// About what the step produced, so it can never be a pre-wait: an
|
|
77
|
+
// expectation cannot be there before the step that produces it runs.
|
|
78
|
+
promotable: false,
|
|
79
|
+
budgetMs: { floor: 500, default: 5000, ceiling: 30000 },
|
|
80
|
+
matches: ({ evidence, step }) => Boolean(step.expect) && evidence.expectationMet === false,
|
|
81
|
+
wait: ({ step }) => waitForRecordedExpectation(step.expect),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'click_no_effect',
|
|
85
|
+
safety: 'retry_reversible',
|
|
86
|
+
then: 'retry_step',
|
|
87
|
+
budgetMs: { floor: 500, default: 3000, ceiling: 10000 },
|
|
88
|
+
// The narrow AU-08 exception: reversible, nothing visible changed, and no
|
|
89
|
+
// request went out. A click that started a request is waited on instead.
|
|
90
|
+
matches: ({ evidence, step }) => step.tool === 'element_click'
|
|
91
|
+
&& evidence.noObservableChange === true
|
|
92
|
+
&& evidence.risk === 'reversible'
|
|
93
|
+
&& Number(evidence.requestsStarted ?? 0) === 0,
|
|
94
|
+
wait: ({ step }) => ({ actionable: step.args.target, networkQuiet: true }),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'target_not_ready',
|
|
98
|
+
safety: 'retry_reversible',
|
|
99
|
+
then: 'retry_step',
|
|
100
|
+
budgetMs: { floor: 500, default: 5000, ceiling: 20000 },
|
|
101
|
+
matches: ({ code }) => code === 'TARGET_NOT_INTERACTABLE',
|
|
102
|
+
wait: ({ step }) => ({ actionable: step.args.target }),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'target_late',
|
|
106
|
+
safety: 'retry_reversible',
|
|
107
|
+
then: 'retry_step',
|
|
108
|
+
budgetMs: { floor: 500, default: 8000, ceiling: 30000 },
|
|
109
|
+
matches: ({ code, evidence }) => code === 'TARGET_NOT_FOUND'
|
|
110
|
+
&& (Number(evidence.pendingRequests ?? 0) > 0 || Boolean(evidence.busyIndicator)),
|
|
111
|
+
wait: ({ step }) => ({ target: step.args.target }),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'api_pending',
|
|
115
|
+
safety: 'retry_reversible',
|
|
116
|
+
then: 'retry_step',
|
|
117
|
+
budgetMs: { floor: 1000, default: 15000, ceiling: 45000 },
|
|
118
|
+
matches: ({ evidence }) => evidence.likelyCause === 'server_slow'
|
|
119
|
+
|| Number(evidence.pendingRequests ?? 0) > 0
|
|
120
|
+
|| Boolean(evidence.slowestRequest),
|
|
121
|
+
// The step's own recorded request is the strongest signal; global quiet is
|
|
122
|
+
// only the capped fallback.
|
|
123
|
+
wait: ({ step }) => (step.expect && step.expect.request
|
|
124
|
+
? { requestDone: step.expect.request }
|
|
125
|
+
: { networkQuiet: true }),
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'still_rendering',
|
|
129
|
+
safety: 'retry_reversible',
|
|
130
|
+
then: 'retry_step',
|
|
131
|
+
budgetMs: { floor: 500, default: 10000, ceiling: 30000 },
|
|
132
|
+
matches: ({ evidence }) => Boolean(evidence.busyIndicator) || evidence.likelyCause === 'still_rendering',
|
|
133
|
+
wait: () => ({ indicatorGone: true }),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: 'site_error',
|
|
137
|
+
safety: 'group',
|
|
138
|
+
then: 'retry_group',
|
|
139
|
+
budgetMs: { floor: 0, default: 0, ceiling: 0 },
|
|
140
|
+
matches: ({ code, evidence }) => code === 'SITE_ERROR' || evidence.likelyCause === 'site_error',
|
|
141
|
+
wait: () => null,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'context_lost',
|
|
145
|
+
safety: 'group',
|
|
146
|
+
then: 'retry_group',
|
|
147
|
+
budgetMs: { floor: 0, default: 0, ceiling: 0 },
|
|
148
|
+
matches: ({ evidence }) => evidence.contextLost === true,
|
|
149
|
+
wait: () => null,
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
/** The wait args for a recorded expectation (`appears` and `request` can combine). */
|
|
154
|
+
function waitForRecordedExpectation(expect) {
|
|
155
|
+
if (!expect) return null;
|
|
156
|
+
const args = {};
|
|
157
|
+
if (expect.appears) args.target = expect.appears;
|
|
158
|
+
if (expect.request && expect.request.pathContains) args.requestDone = expect.request;
|
|
159
|
+
return Object.keys(args).length > 0 ? args : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* How long the recorded-expectation wait may look for its evidence: the
|
|
164
|
+
* person's `maxWaitMs` when set, otherwise the settle the recorder measured
|
|
165
|
+
* (the learned pace for that step), otherwise the default expectation timeout.
|
|
166
|
+
* It is the budget of a bounded poll that ends the moment the evidence is
|
|
167
|
+
* there — never a pause (A6-10). Clamped to the shared limits.
|
|
168
|
+
*/
|
|
169
|
+
function expectBudgetMs(expect, maxWaitMs = null) {
|
|
170
|
+
const asked = Number.isFinite(maxWaitMs) && maxWaitMs > 0 ? maxWaitMs : null;
|
|
171
|
+
const recorded = Number.isFinite(expect?.settleMs) && expect.settleMs > 0 ? expect.settleMs : null;
|
|
172
|
+
const ms = asked ?? recorded ?? LIMITS.DEFAULT_EXPECT_TIMEOUT_MS;
|
|
173
|
+
return Math.max(LIMITS.SETTLE_QUIET_MS, Math.min(ms, LIMITS.MAX_WAIT_MS));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The failure a classifier works from, normalised from either envelope shape. */
|
|
177
|
+
function normalise(env) {
|
|
178
|
+
return {
|
|
179
|
+
code: env?.error?.code ?? env?.code ?? null,
|
|
180
|
+
evidence: env?.evidence ?? {},
|
|
181
|
+
state: env?.state ?? (env?.ok === false ? 'failed' : 'completed'),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What is this step blocked on? Returns `{ id, safety, then, wait, budgetMs }`,
|
|
187
|
+
* or null when nothing safe could clear it (a person, a policy, ambiguity, or
|
|
188
|
+
* an unknown failure). A resolver whose `wait` is null only selects the
|
|
189
|
+
* existing group-recovery path.
|
|
190
|
+
*/
|
|
191
|
+
function classify(env, step, options = {}) {
|
|
192
|
+
const failure = normalise(env);
|
|
193
|
+
if (!step || !RETRYABLE_TOOLS.has(step.tool)) return null;
|
|
194
|
+
if (failure.code && FORBIDDEN_CODES.includes(failure.code)) return null;
|
|
195
|
+
if (failure.code && !TRANSIENT_CODES.includes(failure.code)) return null;
|
|
196
|
+
const demoted = Array.isArray(options.demoted) ? options.demoted : [];
|
|
197
|
+
for (const resolver of RESOLVERS) {
|
|
198
|
+
if (demoted.includes(resolver.id)) continue;
|
|
199
|
+
if (!resolver.matches({ ...failure, step })) continue;
|
|
200
|
+
const wait = resolver.wait({ step, evidence: failure.evidence });
|
|
201
|
+
if (resolver.then === 'retry_step' && !wait) return null;
|
|
202
|
+
return {
|
|
203
|
+
id: resolver.id,
|
|
204
|
+
safety: resolver.safety,
|
|
205
|
+
then: resolver.then,
|
|
206
|
+
wait,
|
|
207
|
+
budgetMs: resolveBudget(resolver, options),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** One resolver by id, for a step profile that learned to pre-wait. */
|
|
214
|
+
function resolverById(id) {
|
|
215
|
+
return RESOLVERS.find((resolver) => resolver.id === id && resolver.then === 'retry_step') ?? null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The wait args for a resolver id applied to this step (promotion from the step profile). */
|
|
219
|
+
function waitArgsFor(id, step) {
|
|
220
|
+
const resolver = resolverById(id);
|
|
221
|
+
return resolver ? resolver.wait({ step, evidence: {} }) : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* May a step profile wait for this blocker *before* acting? Only a blocker about
|
|
226
|
+
* what the step is waiting on — never one about what it produces.
|
|
227
|
+
*/
|
|
228
|
+
function promotable(id) {
|
|
229
|
+
const resolver = resolverById(id);
|
|
230
|
+
return Boolean(resolver) && resolver.promotable !== false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* How long to wait for this resolver, in this order: the resolver's own default,
|
|
235
|
+
* the step's learned p95 × 1.5 (once it has ≥ 3 runs), the person's patience,
|
|
236
|
+
* an explicit cap, then the resolver's floor/ceiling.
|
|
237
|
+
*/
|
|
238
|
+
function resolveBudget(resolver, { learnedMs = null, patience = 'normal', maxWaitMs = null } = {}) {
|
|
239
|
+
const { floor, default: fallback, ceiling } = resolver.budgetMs;
|
|
240
|
+
let ms = fallback;
|
|
241
|
+
if (Number.isFinite(learnedMs) && learnedMs > 0) ms = Math.round(learnedMs * 1.5);
|
|
242
|
+
const factor = patience === 'fast' ? 0.5 : patience === 'patient' ? 3 : 1;
|
|
243
|
+
ms = Math.round(ms * factor);
|
|
244
|
+
if (Number.isFinite(maxWaitMs) && maxWaitMs > 0) ms = Math.min(ms, maxWaitMs);
|
|
245
|
+
return Math.max(floor, Math.min(ms, ceiling));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* May this step be retried in place under AU-08?
|
|
250
|
+
*
|
|
251
|
+
* - the resolver must actually retry the step;
|
|
252
|
+
* - its tool must be one a workflow may repeat;
|
|
253
|
+
* - a consequential step is never repeated, whatever the failure code;
|
|
254
|
+
* - a click or key press is repeated only when the action provably did not run,
|
|
255
|
+
* or under the click_no_effect resolver's own narrow rule;
|
|
256
|
+
* - everything else (fills, selects, navigations, waits) is safe to repeat.
|
|
257
|
+
*/
|
|
258
|
+
function mayRetryInPlace(resolver, env, step) {
|
|
259
|
+
if (!resolver || resolver.then !== 'retry_step') return false;
|
|
260
|
+
if (!step || !RETRYABLE_TOOLS.has(step.tool)) return false;
|
|
261
|
+
const { code, evidence } = normalise(env);
|
|
262
|
+
if (CONSEQUENTIAL_RISKS.has(evidence.risk)) return false;
|
|
263
|
+
if (resolver.id === 'click_no_effect') return true;
|
|
264
|
+
if (step.tool === 'element_click' || step.tool === 'browser_press_key') {
|
|
265
|
+
return ACTION_DID_NOT_RUN_CODES.has(code);
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
REGISTRY_VERSION,
|
|
272
|
+
FORBIDDEN_CODES,
|
|
273
|
+
CONSEQUENTIAL_RISKS,
|
|
274
|
+
RETRYABLE_TOOLS,
|
|
275
|
+
ACTION_DID_NOT_RUN_CODES,
|
|
276
|
+
TRANSIENT_CODES,
|
|
277
|
+
WAIT_KINDS,
|
|
278
|
+
RESOLVERS,
|
|
279
|
+
classify,
|
|
280
|
+
resolveBudget,
|
|
281
|
+
mayRetryInPlace,
|
|
282
|
+
resolverById,
|
|
283
|
+
promotable,
|
|
284
|
+
waitArgsFor,
|
|
285
|
+
waitForRecordedExpectation,
|
|
286
|
+
expectBudgetMs,
|
|
287
|
+
};
|