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 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.ok]));
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 };
@@ -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
  }
@@ -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 = { LIMITS, CALL_DEADLINE_MS, STATUS_DEADLINE_MS, RUN_DEADLINE_MS, callDeadlineMs };
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
- runOnce: (setParams) => { const r = runOnce(setParams, first); first = false; return r; },
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, from the start, in the background (${job.runId}).`,
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
 
@@ -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
- runOnce: (setParams) => runOnce(setParams, 0),
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 start.` : null,
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
- runWorkflow(wf, {}, 0, profile, { paramSets, labels }).catch((err) => log('panel_run_error', { message: err.message }));
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
+ };