browserzap 0.5.5 → 0.6.1
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 +56 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
971
|
-
|
|
972
|
-
data
|
|
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
|
|