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.
@@ -48,6 +48,18 @@ const EXPECT = {
48
48
 
49
49
  const TIMEOUT = { type: 'integer', minimum: 100, maximum: LIMITS.MAX_WAIT_MS, description: `Milliseconds (max ${LIMITS.MAX_WAIT_MS}).` };
50
50
 
51
+ /** A request to wait for: method and a fragment of its path. Query strings are never matched. */
52
+ const REQUEST_PATTERN = {
53
+ type: 'object',
54
+ additionalProperties: false,
55
+ required: ['pathContains'],
56
+ description: 'A request to wait for, by method and a fragment of its path (no query strings).',
57
+ properties: {
58
+ method: { type: 'string', maxLength: 16, description: 'HTTP method, e.g. GET. Any method when omitted.' },
59
+ pathContains: { type: 'string', minLength: 1, maxLength: 200, description: 'A fragment of the request path, e.g. /api/accounts.' },
60
+ },
61
+ };
62
+
51
63
  /** A saved step's own pacing: a deliberate pause the person set, never added by BrowserZap. */
52
64
  const PACING = {
53
65
  type: 'integer', minimum: 0, maximum: LIMITS.MAX_STEP_PACING_MS,
@@ -201,8 +213,10 @@ const MCP_TOOLS = [
201
213
  {
202
214
  name: 'wait_for_state',
203
215
  description:
204
- 'Wait (bounded) until the URL contains a string, some text appears, or an element appears/disappears. ' +
205
- 'Use instead of fixed sleeps.',
216
+ 'Wait (bounded, polled) until the page reaches a state: the URL contains a string, text appears, an element appears ' +
217
+ 'or disappears, an element becomes actionable (visible, enabled, uncovered and stable), a spinner goes away, ' +
218
+ 'the network goes quiet, or a specific request answers. Use instead of fixed sleeps. With several conditions, ' +
219
+ 'all must hold at once.',
206
220
  inputSchema: {
207
221
  type: 'object',
208
222
  additionalProperties: false,
@@ -212,6 +226,10 @@ const MCP_TOOLS = [
212
226
  text: { type: 'string', maxLength: 500 },
213
227
  target: TARGET,
214
228
  targetGone: TARGET,
229
+ actionable: { ...TARGET, description: 'An element that must be visible, enabled, uncovered and not moving.' },
230
+ indicatorGone: { type: 'boolean', description: 'Wait until no visible spinner, progress bar or "Loading…" indicator remains.' },
231
+ networkQuiet: { type: 'boolean', description: 'Wait until no tracked request is in flight and the page has been quiet for a moment.' },
232
+ requestDone: REQUEST_PATTERN,
215
233
  timeoutMs: TIMEOUT,
216
234
  },
217
235
  },
@@ -383,8 +401,9 @@ const MCP_TOOLS = [
383
401
  'Save a workflow from explicit steps. Each step is { tool, args } with tool one of browser_navigate, element_click, ' +
384
402
  'field_fill, select_option, wait_for_state, browser_press_key, browser_scroll and args exactly as that tool takes them ' +
385
403
  '(no refs, no approvalId). Use "{{param}}" inside strings for values given at run time. A step may also carry ' +
386
- 'waitBeforeMs (give a slow site that long before this step) and retry (repeat a group of steps on failure); ' +
387
- 'pace.waitBeforeMs is the workflow-wide default. Pacing is only ever what the person set — never added automatically.',
404
+ 'waitBeforeMs (give a slow site that long before this step), retry (repeat a group of steps on failure), ' +
405
+ 'patience or maxWaitMs (how long to wait for a blocker), and pace.waitBeforeMs is the workflow-wide default. ' +
406
+ 'Pacing is only ever what the person set — never added automatically.',
388
407
  inputSchema: {
389
408
  type: 'object',
390
409
  additionalProperties: false,
@@ -414,6 +433,26 @@ const MCP_TOOLS = [
414
433
  note: { type: 'string', maxLength: 200 },
415
434
  waitBeforeMs: PACING,
416
435
  retry: RETRY,
436
+ patience: {
437
+ type: 'string',
438
+ enum: ['fast', 'normal', 'patient'],
439
+ description: 'How long this step gives a blocker it waits on: half, the default, or three times the learned budget. Never a fixed sleep.',
440
+ },
441
+ maxWaitMs: {
442
+ type: 'integer', minimum: 100, maximum: LIMITS.MAX_WAIT_MS,
443
+ description: `An explicit cap on one resolver wait for this step (100–${LIMITS.MAX_WAIT_MS}).`,
444
+ },
445
+ expect: {
446
+ type: 'object',
447
+ additionalProperties: false,
448
+ minProperties: 1,
449
+ description: 'What this step produces. Replay waits for it before calling the step done; no values.',
450
+ properties: {
451
+ appears: { ...TARGET, description: 'An element (usually a dialog) that appears after this step.' },
452
+ request: REQUEST_PATTERN,
453
+ settleMs: { type: 'integer', minimum: 0, maximum: LIMITS.MAX_STEP_PACING_MS },
454
+ },
455
+ },
417
456
  onFail: {
418
457
  type: 'string',
419
458
  enum: ['stop', 'skip'],
@@ -31,7 +31,8 @@ const path = require('path');
31
31
 
32
32
  const { configDir } = require('./secret');
33
33
  const { MCP_TOOLS, validateArguments } = require('./tools');
34
- const { LIMITS, RUN_DEADLINE_MS } = require('./limits');
34
+ const { LIMITS, RUN_DEADLINE_MS, MAX_STEP_RESOLVE_MS, MAX_RESOLVER_ATTEMPTS, MAX_INPLACE_CLICK_RETRIES } = require('./limits');
35
+ const { classify, mayRetryInPlace, resolveBudget, resolverById, promotable, waitArgsFor, waitForRecordedExpectation, expectBudgetMs } = require('./resolvers');
35
36
 
36
37
  const SCHEMA_VERSION = 1;
37
38
  const NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
@@ -85,6 +86,12 @@ const RETRY_DELAYS_MS = [2000, 5000];
85
86
  const TIMEOUT_TOOLS = new Set(['browser_navigate', 'element_click', 'field_fill', 'select_option', 'wait_for_state']);
86
87
  /** Durations kept per step for learning how fast a site is (run history). */
87
88
  const HISTORY_RUNS = 20;
89
+ /** Step-profile thresholds (0.6.0): learn after this many runs, promote a blocker seen this often, demote one never cleared. */
90
+ const PROFILE_MIN_RUNS = 3;
91
+ const PROFILE_PROMOTE_RATE = 0.6;
92
+ const PROFILE_DEMOTE_MISSES = 3;
93
+ /** How many runs the promotion rule looks at: a blocker that stops happening stops being promoted (A6-07). */
94
+ const PROFILE_WINDOW_RUNS = 10;
88
95
 
89
96
  /** Tools a workflow step may call: the ones that change or wait on the page. */
90
97
  const STEP_TOOLS = new Set([
@@ -290,7 +297,7 @@ function originOf(url) {
290
297
  }
291
298
 
292
299
  /** Fields a saved step may carry. `retry` is not allowed inside a cleanup step: no retries within retries. */
293
- const STEP_FIELDS = ['tool', 'args', 'expectChange', 'note', 'waitBeforeMs', 'retry', 'onFail'];
300
+ const STEP_FIELDS = ['tool', 'args', 'expectChange', 'note', 'waitBeforeMs', 'retry', 'onFail', 'patience', 'maxWaitMs', 'expect'];
294
301
  const CLEANUP_STEP_FIELDS = ['tool', 'args', 'expectChange', 'note'];
295
302
 
296
303
  /**
@@ -325,6 +332,69 @@ function canonicalStep(step, at, allowed = STEP_FIELDS) {
325
332
  const out = { tool: step.tool, args };
326
333
  if (step.expectChange === true) out.expectChange = true;
327
334
  if (typeof step.note === 'string' && step.note) out.note = step.note.slice(0, 200);
335
+ // How patient this step is with a blocker (0.6.0): a multiplier, never a hidden pause.
336
+ if (step.patience !== undefined) {
337
+ if (!['fast', 'normal', 'patient'].includes(step.patience)) {
338
+ throw new WorkflowError('INVALID_ARGUMENT', `${at}.patience must be "fast", "normal" or "patient".`);
339
+ }
340
+ out.patience = step.patience;
341
+ }
342
+ if (step.maxWaitMs !== undefined) {
343
+ if (!Number.isInteger(step.maxWaitMs) || step.maxWaitMs < 100 || step.maxWaitMs > MAX_STEP_TIMEOUT_MS) {
344
+ throw new WorkflowError('INVALID_ARGUMENT', `${at}.maxWaitMs must be an integer from 100 to ${MAX_STEP_TIMEOUT_MS}.`);
345
+ }
346
+ out.maxWaitMs = step.maxWaitMs;
347
+ }
348
+ if (step.expect !== undefined) out.expect = canonicalExpect(step.expect, `${at}.expect`);
349
+ return out;
350
+ }
351
+
352
+ /**
353
+ * What a step produced when it was recorded (0.6.0): a dialog that appeared, a
354
+ * request it started (method + path fragment, ids templated), and how long the
355
+ * page took to settle. Replay waits for this before calling the step done.
356
+ */
357
+ function canonicalExpect(expect, at) {
358
+ if (!expect || typeof expect !== 'object' || Array.isArray(expect)) throw new WorkflowError('INVALID_ARGUMENT', `${at} must be an object.`);
359
+ const allowed = ['appears', 'request', 'settleMs'];
360
+ for (const key of Object.keys(expect)) {
361
+ if (!allowed.includes(key)) throw new WorkflowError('INVALID_ARGUMENT', `${at}.${key} is not a known expectation field.`);
362
+ }
363
+ const out = {};
364
+ if (expect.appears !== undefined) {
365
+ if (!expect.appears || typeof expect.appears !== 'object' || Array.isArray(expect.appears)) {
366
+ throw new WorkflowError('INVALID_ARGUMENT', `${at}.appears must be a target object.`);
367
+ }
368
+ if (hasRef({ target: expect.appears })) {
369
+ throw new WorkflowError('INVALID_ARGUMENT', `${at}.appears: refs only live as long as one page; use role + name, label or text.`);
370
+ }
371
+ out.appears = expect.appears;
372
+ }
373
+ if (expect.request !== undefined) {
374
+ const request = expect.request;
375
+ if (!request || typeof request !== 'object' || Array.isArray(request)) {
376
+ throw new WorkflowError('INVALID_ARGUMENT', `${at}.request must be an object.`);
377
+ }
378
+ const normalized = {};
379
+ if (request.method !== undefined) {
380
+ if (typeof request.method !== 'string' || !request.method || request.method.length > 16) {
381
+ throw new WorkflowError('INVALID_ARGUMENT', `${at}.request.method must be a short HTTP method.`);
382
+ }
383
+ normalized.method = request.method.toUpperCase();
384
+ }
385
+ if (typeof request.pathContains !== 'string' || !request.pathContains || request.pathContains.length > 200) {
386
+ throw new WorkflowError('INVALID_ARGUMENT', `${at}.request.pathContains must be a path fragment (1–200 characters).`);
387
+ }
388
+ normalized.pathContains = request.pathContains;
389
+ out.request = normalized;
390
+ }
391
+ if (expect.settleMs !== undefined) {
392
+ if (!Number.isInteger(expect.settleMs) || expect.settleMs < 0 || expect.settleMs > MAX_PACING_MS) {
393
+ throw new WorkflowError('INVALID_ARGUMENT', `${at}.settleMs must be an integer from 0 to ${MAX_PACING_MS}.`);
394
+ }
395
+ out.settleMs = expect.settleMs;
396
+ }
397
+ if (Object.keys(out).length === 0) throw new WorkflowError('INVALID_ARGUMENT', `${at} needs appears, request or settleMs.`);
328
398
  return out;
329
399
  }
330
400
 
@@ -595,7 +665,22 @@ async function run(wf, {
595
665
  const paceDefault = wf.pace?.waitBeforeMs ?? 0;
596
666
  let groupAttempts = 0;
597
667
  let pendingRecovery = null;
598
- const base = { workflow: wf.name, version: wf.version, startAt, totalSteps: wf.steps.length, origins };
668
+ /** Steps a resolver cleared in place (0.6.0). */
669
+ let resolvedInPlace = 0;
670
+ // What earlier runs learned about these steps: hints for the person, and a
671
+ // blocker frequent enough to wait for before acting (never a hidden change).
672
+ const hints = [];
673
+ if (history?.profile) {
674
+ for (let i = 0; i < wf.steps.length; i++) {
675
+ const learned = history.profile(i);
676
+ if (learned.promotedId) hints.push({ index: i, hint: `often blocked on ${learned.promotedId}; this run waits for it before acting` });
677
+ for (const id of learned.demoted) hints.push({ index: i, hint: `${id} never cleared in ${learned.blockers[id].seen} runs; skipped for this step` });
678
+ }
679
+ }
680
+ const base = {
681
+ workflow: wf.name, version: wf.version, startAt, totalSteps: wf.steps.length, origins,
682
+ ...(hints.length ? { hints } : {}),
683
+ };
599
684
 
600
685
  for (let i = startAt; i < wf.steps.length; i++) {
601
686
  if (signal?.aborted) return stopped('CANCELLED', `Cancelled before step ${i + 1}.`, i, null);
@@ -624,6 +709,21 @@ async function run(wf, {
624
709
  const learned = Math.min(LIMITS.MAX_WAIT_MS, Math.round(usualMs * 3));
625
710
  if (learned > (args.timeoutMs ?? 0) && learned > DEFAULT_STEP_TIMEOUT_MS) args.timeoutMs = learned;
626
711
  }
712
+ const profile = history?.profile ? history.profile(i) : null;
713
+ // A blocker this step hit often in earlier runs is waited for before
714
+ // acting — the same condition, just earlier, so no failed attempt first.
715
+ if (profile?.promotedId && step.onFail !== 'skip') {
716
+ const preResolver = resolverById(profile.promotedId);
717
+ const preArgs = waitArgsFor(profile.promotedId, step);
718
+ const budget = preResolver
719
+ ? resolveBudget(preResolver, { learnedMs: profile.p95Ms, patience: step.patience, maxWaitMs: step.maxWaitMs })
720
+ : 0;
721
+ if (preArgs && budget >= 250 && activeMs() + budget < deadlineMs) {
722
+ onStep({ index: i, tool: step.tool, preWait: profile.promotedId });
723
+ const pre = await execute('wait_for_state', { ...preArgs, timeoutMs: budget }, { allowedOrigins: origins, signal, index: i });
724
+ personMs += Number(pre.evidence?.waitedForPersonMs) || 0;
725
+ }
726
+ }
627
727
  onStep({ index: i, tool: step.tool, waitBeforeMs: waitBefore });
628
728
  const stepStarted = now();
629
729
  let env = await execute(step.tool, args, { allowedOrigins: origins, signal, index: i });
@@ -640,6 +740,73 @@ async function run(wf, {
640
740
  personMs += Number(env.evidence?.waitedForPersonMs) || 0;
641
741
  if (CONFIRMATION_RISKS.has(env.evidence?.risk)) unsafe.set(i, `step ${i + 1} (${step.tool}) needed the user's approval`);
642
742
  }
743
+ // ── Resolver engine (0.6.0, D-37) ────────────────────────────────────────
744
+ // A step that failed, or a click that landed but changed nothing, asks the
745
+ // registry what it is blocked on. The wait runs as a normal tool call, then
746
+ // the SAME step is retried in place — never a group, never a blind repeat.
747
+ // A recorded expectation is checked first: the step is done when what it
748
+ // produced is there, not merely when the click landed.
749
+ if (env.ok && step.expect) {
750
+ const expected = waitForRecordedExpectation(step.expect);
751
+ if (expected) {
752
+ // `maxWaitMs` when the person set one, else the settle the recorder
753
+ // measured for this step, else the default — the budget of a bounded
754
+ // poll, never a pause (A6-10).
755
+ const budget = expectBudgetMs(step.expect, step.maxWaitMs);
756
+ const met = await execute('wait_for_state', { ...expected, timeoutMs: budget }, { allowedOrigins: origins, signal, index: i });
757
+ personMs += Number(met.evidence?.waitedForPersonMs) || 0;
758
+ if (met.ok) {
759
+ env = { ...env, evidence: { ...env.evidence, expectationMet: true, ...(met.evidence?.elapsedMs ? { expectationWaitedMs: met.evidence.elapsedMs } : {}) } };
760
+ } else {
761
+ // The step's own summary describes a success; the failure has to say
762
+ // what was missing, or the run report tells the person the step worked.
763
+ const message = `The step ran, but what it produced did not appear: ${met.summary}`;
764
+ env = {
765
+ ...env,
766
+ ok: false,
767
+ state: 'failed',
768
+ summary: message,
769
+ error: { code: 'POSTCONDITION_NOT_MET', message },
770
+ evidence: { ...env.evidence, expectationMet: false, ...(met.evidence?.notReady ? { notReady: met.evidence.notReady } : {}) },
771
+ };
772
+ }
773
+ }
774
+ }
775
+ const resolver = { by: null, attempts: 0, waitedMs: 0, spentMs: 0 };
776
+ if (step.onFail !== 'skip' && !unsafe.has(i)) {
777
+ for (let attempt = 1; attempt <= MAX_RESOLVER_ATTEMPTS; attempt++) {
778
+ if (signal?.aborted) break;
779
+ const ineffective = step.tool === 'element_click' && env.ok && env.evidence?.noObservableChange === true;
780
+ if (env.ok && !ineffective) break;
781
+ const hit = classify(env, step, {
782
+ learnedMs: usualMs,
783
+ patience: step.patience,
784
+ maxWaitMs: step.maxWaitMs,
785
+ demoted: profile?.demoted ?? [],
786
+ });
787
+ if (!hit || hit.then !== 'retry_step' || !mayRetryInPlace(hit, env, step)) break;
788
+ if (hit.id === 'click_no_effect' && resolver.attempts >= MAX_INPLACE_CLICK_RETRIES) break;
789
+ const budget = Math.min(hit.budgetMs, MAX_STEP_RESOLVE_MS - resolver.spentMs);
790
+ if (budget < 250 || activeMs() + budget >= deadlineMs) break;
791
+ resolver.by = hit.id;
792
+ resolver.attempts += 1;
793
+ onStep({ index: i, tool: step.tool, retry: resolver.attempts, resolving: hit.id });
794
+ const waited = await execute('wait_for_state', { ...hit.wait, timeoutMs: budget }, { allowedOrigins: origins, signal, index: i });
795
+ personMs += Number(waited.evidence?.waitedForPersonMs) || 0;
796
+ // `waitedMs` is the time the resolver waited (what a report shows);
797
+ // `spentMs` also counts the retried step, so it bounds the resolve budget.
798
+ const waitedMs = Number(waited.evidence?.elapsedMs) || 0;
799
+ resolver.waitedMs += waitedMs;
800
+ resolver.spentMs += waitedMs;
801
+ // The blocker did not clear: keep the step's own failure, and say what it was waiting on.
802
+ if (!waited.ok) break;
803
+ const retryStarted = Date.now();
804
+ env = await execute(step.tool, args, { allowedOrigins: origins, signal, index: i });
805
+ personMs += Number(env.evidence?.waitedForPersonMs) || 0;
806
+ resolver.spentMs += Date.now() - retryStarted;
807
+ if (CONFIRMATION_RISKS.has(env.evidence?.risk)) unsafe.set(i, `step ${i + 1} (${step.tool}) needed the user's approval`);
808
+ }
809
+ }
643
810
  const record = {
644
811
  index: i,
645
812
  tool: step.tool,
@@ -652,7 +819,19 @@ async function run(wf, {
652
819
  const target = describeTarget(step.tool, step.args);
653
820
  if (target) record.target = target;
654
821
  if (retries) record.retries = retries;
822
+ if (resolver.by) {
823
+ const cleared = env.ok && !(step.expectChange && env.evidence?.noObservableChange);
824
+ if (cleared) {
825
+ record.resolved = { by: resolver.by, attempt: resolver.attempts, waitedMs: resolver.waitedMs };
826
+ resolvedInPlace += 1;
827
+ } else {
828
+ // Tried, but the condition never cleared: keep the step's own failure and
829
+ // say which blocker it was, so the report can explain it.
830
+ record.blocker = { by: resolver.by, attempt: resolver.attempts };
831
+ }
832
+ }
655
833
  if (env.evidence?.likelyCause) record.likelyCause = env.evidence.likelyCause;
834
+ if (env.evidence?.expectationMet !== undefined) record.expectationMet = env.evidence.expectationMet;
656
835
  // The status only: the error page's own text is page content.
657
836
  if (env.evidence?.siteError?.status) record.siteErrorStatus = env.evidence.siteError.status;
658
837
  if (pendingRecovery && i === pendingRecovery.from) {
@@ -660,6 +839,13 @@ async function run(wf, {
660
839
  pendingRecovery = null;
661
840
  }
662
841
  steps.push(record);
842
+ history?.recordProfile?.(i, {
843
+ settleMs: env.evidence?.waitedForSettleMs,
844
+ blocker: record.resolved?.by ?? record.blocker?.by ?? null,
845
+ cleared: Boolean(record.resolved),
846
+ resolved: Boolean(record.resolved),
847
+ ok: env.ok && !(step.expectChange && env.evidence?.noObservableChange),
848
+ });
663
849
  if (env.ok) {
664
850
  completed.add(i);
665
851
  if (env.evidence?.navigated || env.evidence?.domChanged) pageChanged.add(i);
@@ -751,13 +937,14 @@ async function run(wf, {
751
937
  ? ` Slower than usual: ${slowSteps.map((s) => `step ${s.index + 1} took ${Math.round(s.ms / 1000)} s (usually ${Math.round(s.usualMs / 1000)} s)`).join('; ')}.`
752
938
  : '';
753
939
  const recoveryNote = recoveries.length ? ` Recovered ${recoveries.length} time(s).` : '';
940
+ const resolvedNote = resolvedInPlace ? ` Resolved ${resolvedInPlace} step(s) in place.` : '';
754
941
  const skipNote = skipped.length
755
942
  ? ` Done with ${skipped.length} optional step(s) skipped (${skipped.map((s) => `step ${s.index + 1}`).join(', ')}).`
756
943
  : '';
757
944
  return {
758
945
  ok: true,
759
946
  state: 'completed',
760
- summary: `Ran "${wf.name}" v${wf.version}: ${completed.size} step(s)${skipped.length ? ' verified' : ', all verified'}.${skipNote}${recoveryNote}${slowNote}`,
947
+ summary: `Ran "${wf.name}" v${wf.version}: ${completed.size} step(s)${skipped.length ? ' verified' : ', all verified'}.${skipNote}${recoveryNote}${resolvedNote}${slowNote}`,
761
948
  nextAction: null,
762
949
  error: null,
763
950
  evidence: {
@@ -961,15 +1148,61 @@ function runHistory(wf) {
961
1148
  return {
962
1149
  /** The 95th-percentile duration of a step, once it has run at least three times. */
963
1150
  typicalMs(index) {
964
- const list = Array.isArray(data.steps[index]) ? data.steps[index].filter((n) => Number.isFinite(n)) : [];
965
- if (list.length < 3) return null;
966
- const sorted = [...list].sort((a, b) => a - b);
967
- return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)];
1151
+ return p95(profileOf(data, index).ms);
968
1152
  },
969
1153
  record(index, ms) {
970
- const list = Array.isArray(data.steps[index]) ? data.steps[index] : [];
971
- list.push(Math.max(0, Math.round(ms)));
972
- data.steps[index] = list.slice(-HISTORY_RUNS);
1154
+ const profile = profileOf(data, index);
1155
+ profile.ms.push(Math.max(0, Math.round(ms)));
1156
+ writeProfile(data, index, profile);
1157
+ },
1158
+ /**
1159
+ * What this step learned (0.6.0): a learned budget, a blocker frequent
1160
+ * enough to wait for before acting, and blockers that never cleared. Ids,
1161
+ * counts and milliseconds only.
1162
+ *
1163
+ * Promotion looks at the last PROFILE_WINDOW_RUNS runs (the blockerRuns
1164
+ * ring), never at lifetime counts: a blocker that stopped happening is no
1165
+ * longer waited for (A6-07).
1166
+ */
1167
+ profile(index) {
1168
+ const profile = profileOf(data, index);
1169
+ const runs = profile.ms.length;
1170
+ const window = profile.blockerRuns.slice(-PROFILE_WINDOW_RUNS);
1171
+ const recent = {};
1172
+ for (const id of window) if (id) recent[id] = (recent[id] ?? 0) + 1;
1173
+ const blockers = {};
1174
+ let promotedId = null;
1175
+ const demoted = [];
1176
+ for (const [id, raw] of Object.entries(profile.blockers)) {
1177
+ const seen = Number(raw?.seen) || 0;
1178
+ const cleared = Number(raw?.cleared) || 0;
1179
+ blockers[id] = { seen, cleared, recent: recent[id] ?? 0 };
1180
+ if (window.length >= PROFILE_MIN_RUNS && (recent[id] ?? 0) / window.length >= PROFILE_PROMOTE_RATE
1181
+ && cleared > 0 && promotable(id) && !promotedId) promotedId = id;
1182
+ if (seen >= PROFILE_DEMOTE_MISSES && cleared === 0) demoted.push(id);
1183
+ }
1184
+ return {
1185
+ runs, window: window.length, p95Ms: p95(profile.ms), settleP95Ms: p95(profile.settleMs),
1186
+ blockers, promotedId, demoted, outcomes: { ...profile.outcomes },
1187
+ };
1188
+ },
1189
+ /** One step's outcome. Milliseconds and blocker ids; no targets, text or values. */
1190
+ recordProfile(index, { settleMs, blocker, cleared, resolved, ok } = {}) {
1191
+ const profile = profileOf(data, index);
1192
+ if (Number.isFinite(settleMs) && settleMs >= 0) profile.settleMs.push(Math.round(settleMs));
1193
+ // One entry per run, even when nothing blocked it: the ring is the
1194
+ // denominator of the promotion rate.
1195
+ profile.blockerRuns.push(blocker ?? null);
1196
+ if (blocker) {
1197
+ const entry = profile.blockers[blocker] ?? { seen: 0, cleared: 0 };
1198
+ entry.seen += 1;
1199
+ if (cleared) entry.cleared += 1;
1200
+ profile.blockers[blocker] = { seen: entry.seen, cleared: entry.cleared };
1201
+ }
1202
+ if (resolved) profile.outcomes.ok_after_resolve += 1;
1203
+ else if (ok) profile.outcomes.ok += 1;
1204
+ else profile.outcomes.failed += 1;
1205
+ writeProfile(data, index, profile);
973
1206
  },
974
1207
  save() {
975
1208
  try {
@@ -979,6 +1212,42 @@ function runHistory(wf) {
979
1212
  };
980
1213
  }
981
1214
 
1215
+ /**
1216
+ * A step's stored profile, upgrading the old shapes: an array of durations
1217
+ * (0.5.x), or an object without the blockerRuns ring (0.6.0 before A6-07). An
1218
+ * old profile loads as empty history for the ring — the durations it holds are
1219
+ * still used — never as an error and never by rewriting the workflow.
1220
+ */
1221
+ function profileOf(data, index) {
1222
+ const empty = () => ({ ms: [], settleMs: [], blockers: {}, blockerRuns: [], outcomes: { ok: 0, ok_after_resolve: 0, failed: 0 } });
1223
+ const raw = data.steps[index];
1224
+ if (Array.isArray(raw)) return { ...empty(), ms: raw.filter(Number.isFinite) };
1225
+ if (!raw || typeof raw !== 'object') return empty();
1226
+ return {
1227
+ ms: Array.isArray(raw.ms) ? raw.ms.filter(Number.isFinite) : [],
1228
+ settleMs: Array.isArray(raw.settleMs) ? raw.settleMs.filter(Number.isFinite) : [],
1229
+ blockers: raw.blockers && typeof raw.blockers === 'object' ? raw.blockers : {},
1230
+ blockerRuns: Array.isArray(raw.blockerRuns) ? raw.blockerRuns.filter((id) => id === null || typeof id === 'string') : [],
1231
+ outcomes: { ok: 0, ok_after_resolve: 0, failed: 0, ...(raw.outcomes && typeof raw.outcomes === 'object' ? raw.outcomes : {}) },
1232
+ };
1233
+ }
1234
+
1235
+ function writeProfile(data, index, profile) {
1236
+ data.steps[index] = {
1237
+ ms: profile.ms.slice(-HISTORY_RUNS),
1238
+ settleMs: profile.settleMs.slice(-HISTORY_RUNS),
1239
+ blockers: profile.blockers,
1240
+ blockerRuns: profile.blockerRuns.slice(-PROFILE_WINDOW_RUNS),
1241
+ outcomes: profile.outcomes,
1242
+ };
1243
+ }
1244
+
1245
+ function p95(list) {
1246
+ if (list.length < 3) return null;
1247
+ const sorted = [...list].sort((a, b) => a - b);
1248
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)];
1249
+ }
1250
+
982
1251
  // ─── Recorder ────────────────────────────────────────────────────────────────
983
1252
 
984
1253
  function slug(text, fallback) {
@@ -1108,6 +1377,24 @@ class Recorder {
1108
1377
  } catch { /* keep without */ }
1109
1378
  }
1110
1379
  }
1380
+ // What this step produced, so replay waits for it before calling the step
1381
+ // done (0.6.0): a dialog that appeared, the request it started (method +
1382
+ // a path with ids templated), and how long the page took to settle. No
1383
+ // values, no query strings, no page text.
1384
+ const produced = {};
1385
+ if (env.evidence?.appeared?.name) {
1386
+ produced.appears = { role: env.evidence.appeared.role || 'dialog', name: env.evidence.appeared.name };
1387
+ }
1388
+ if (env.evidence?.requestStarted?.pathContains) {
1389
+ produced.request = {
1390
+ ...(env.evidence.requestStarted.method ? { method: env.evidence.requestStarted.method } : {}),
1391
+ pathContains: env.evidence.requestStarted.pathContains,
1392
+ };
1393
+ }
1394
+ if (Number.isFinite(env.evidence?.waitedForSettleMs) && env.evidence.waitedForSettleMs > 0) {
1395
+ produced.settleMs = Math.round(env.evidence.waitedForSettleMs);
1396
+ }
1397
+ if (Object.keys(produced).length) step.expect = produced;
1111
1398
  this.steps.push(step);
1112
1399
  }
1113
1400