@reticlehq/server 2.6.0 → 2.8.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/README.md +14 -5
- package/SKILL.md +251 -33
- package/dist/bridge/bridge.d.ts +6 -1
- package/dist/bridge/bridge.js +27 -4
- package/dist/capsule/causal-summary.d.ts +20 -0
- package/dist/capsule/causal-summary.js +31 -6
- package/dist/capsule/divergence.js +7 -1
- package/dist/cli/chromium-hint.d.ts +68 -0
- package/dist/cli/chromium-hint.js +145 -0
- package/dist/cli/cli-doctor.js +31 -36
- package/dist/cli/cli-kill.d.ts +58 -0
- package/dist/cli/cli-kill.js +162 -0
- package/dist/cli/cli-launch.d.ts +18 -0
- package/dist/cli/cli-launch.js +32 -1
- package/dist/cli/cli-parse.d.ts +16 -1
- package/dist/cli/cli-parse.js +32 -9
- package/dist/cli/cli-port.d.ts +8 -0
- package/dist/cli/cli-port.js +36 -1
- package/dist/cli/cli-verify.js +2 -2
- package/dist/cli/doctor-daemon-line.js +10 -1
- package/dist/cli/doctor-sessions-line.d.ts +12 -0
- package/dist/cli/doctor-sessions-line.js +66 -0
- package/dist/cli/port-holder.d.ts +10 -0
- package/dist/cli/port-holder.js +26 -2
- package/dist/cli.js +139 -23
- package/dist/crawl/crawl-tools.js +12 -2
- package/dist/crawl/crawl.d.ts +13 -0
- package/dist/crawl/crawl.js +50 -2
- package/dist/daemon/daemon-resilience.js +2 -2
- package/dist/daemon/loopback-alias.d.ts +32 -0
- package/dist/daemon/loopback-alias.js +80 -0
- package/dist/events/contradictions.d.ts +14 -0
- package/dist/events/contradictions.js +26 -17
- package/dist/events/predicate-eval.d.ts +26 -3
- package/dist/events/predicate-eval.js +284 -95
- package/dist/events/predicate-parse.d.ts +1 -1
- package/dist/events/predicate-parse.js +18 -4
- package/dist/events/predicate.js +170 -18
- package/dist/events/ring-buffer.d.ts +8 -0
- package/dist/events/ring-buffer.js +34 -1
- package/dist/flows/flow-classify.js +1 -1
- package/dist/flows/flow-tools.js +12 -4
- package/dist/flows/heal-run.js +1 -1
- package/dist/honesty/blind-spots.js +6 -2
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +9 -1
- package/dist/honesty/unsettled.d.ts +40 -0
- package/dist/honesty/unsettled.js +78 -0
- package/dist/honesty/verified.d.ts +7 -0
- package/dist/honesty/verified.js +27 -4
- package/dist/http-server.js +11 -1
- package/dist/index.js +29 -2
- package/dist/init/agent-rules.d.ts +16 -0
- package/dist/init/agent-rules.js +96 -17
- package/dist/init/astro-patch.d.ts +2 -1
- package/dist/init/astro-patch.js +11 -5
- package/dist/init/capabilities.d.ts +22 -0
- package/dist/init/capabilities.js +76 -0
- package/dist/init/confirm.d.ts +96 -0
- package/dist/init/confirm.js +138 -0
- package/dist/init/cra.js +3 -2
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +15 -0
- package/dist/init/non-js-project.d.ts +27 -0
- package/dist/init/non-js-project.js +84 -0
- package/dist/init/plan-framework.d.ts +9 -0
- package/dist/init/plan-framework.js +72 -16
- package/dist/init/plan.d.ts +40 -4
- package/dist/init/plan.js +183 -43
- package/dist/init/run.d.ts +24 -2
- package/dist/init/run.js +181 -46
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +46 -6
- package/dist/init/snippets.d.ts +64 -5
- package/dist/init/snippets.js +220 -31
- package/dist/init/vite-config.js +29 -3
- package/dist/input/network-mock-tools.d.ts +19 -0
- package/dist/input/network-mock-tools.js +29 -4
- package/dist/license/license.js +1 -1
- package/dist/mcp/mcp-proxy.d.ts +69 -3
- package/dist/mcp/mcp-proxy.js +215 -23
- package/dist/mcp/mcp.js +58 -7
- package/dist/mcp/proxy-lifecycle.d.ts +20 -1
- package/dist/mcp/proxy-lifecycle.js +22 -1
- package/dist/pool/browser-pool.d.ts +22 -2
- package/dist/pool/browser-pool.js +44 -4
- package/dist/pool/playwright-launcher.js +10 -2
- package/dist/project/learned-routes.d.ts +24 -0
- package/dist/project/learned-routes.js +63 -0
- package/dist/project/project-store.d.ts +6 -0
- package/dist/project/project-store.js +25 -1
- package/dist/session/live-control-tools.js +39 -2
- package/dist/session/live-control.d.ts +7 -9
- package/dist/session/live-control.js +34 -1
- package/dist/session/no-session-diagnosis.d.ts +23 -4
- package/dist/session/no-session-diagnosis.js +128 -44
- package/dist/session/no-session-watch.js +12 -0
- package/dist/session/ref-provenance.d.ts +54 -0
- package/dist/session/ref-provenance.js +61 -0
- package/dist/session/review-store.d.ts +19 -0
- package/dist/session/review-store.js +34 -5
- package/dist/session/session-lease.js +1 -1
- package/dist/session/session-manager.d.ts +9 -0
- package/dist/session/session-manager.js +11 -0
- package/dist/session/session.d.ts +8 -0
- package/dist/session/session.js +12 -0
- package/dist/status-payload.d.ts +13 -1
- package/dist/status-payload.js +6 -1
- package/dist/telemetry/app-instrumented.d.ts +37 -0
- package/dist/telemetry/app-instrumented.js +54 -0
- package/dist/telemetry/automation-hint.d.ts +17 -0
- package/dist/telemetry/automation-hint.js +71 -0
- package/dist/telemetry/bug-found.d.ts +0 -20
- package/dist/telemetry/bug-found.js +38 -21
- package/dist/telemetry/cli-telemetry.js +7 -1
- package/dist/telemetry/daemon-telemetry.d.ts +4 -4
- package/dist/telemetry/daemon-telemetry.js +8 -4
- package/dist/telemetry/feedback.js +1 -1
- package/dist/telemetry/init-telemetry.js +9 -1
- package/dist/telemetry/install-source.d.ts +36 -0
- package/dist/telemetry/install-source.js +40 -0
- package/dist/telemetry/run-telemetry.js +4 -1
- package/dist/telemetry/session-metrics.d.ts +3 -3
- package/dist/telemetry/session-metrics.js +9 -4
- package/dist/telemetry/telemetry.d.ts +7 -1
- package/dist/telemetry/telemetry.js +17 -2
- package/dist/telemetry/tool-refused.d.ts +19 -0
- package/dist/telemetry/tool-refused.js +55 -0
- package/dist/telemetry/verification-of.js +72 -8
- package/dist/temp-dir.d.ts +27 -0
- package/dist/temp-dir.js +46 -0
- package/dist/tools/act-tools.js +77 -11
- package/dist/tools/assert-grade.d.ts +1 -1
- package/dist/tools/assert-grade.js +27 -5
- package/dist/tools/assert-verdict.js +25 -1
- package/dist/tools/contract-tools.js +4 -0
- package/dist/tools/dynamic-tools.js +1 -1
- package/dist/tools/error-recovery.d.ts +10 -0
- package/dist/tools/error-recovery.js +70 -5
- package/dist/tools/feedback-invite.js +1 -1
- package/dist/tools/invoke-tool.js +149 -32
- package/dist/tools/lease-tools.d.ts +29 -1
- package/dist/tools/lease-tools.js +58 -5
- package/dist/tools/navigate-result.js +1 -1
- package/dist/tools/observe-tools.js +1 -1
- package/dist/tools/observed-nothing.js +1 -1
- package/dist/tools/react-grace.d.ts +48 -0
- package/dist/tools/react-grace.js +87 -0
- package/dist/tools/read-tools.js +20 -4
- package/dist/tools/settle-in-flight.d.ts +79 -0
- package/dist/tools/settle-in-flight.js +106 -0
- package/dist/tools/snapshot-delta.d.ts +7 -0
- package/dist/tools/snapshot-delta.js +130 -6
- package/dist/tools/tool-kit.d.ts +52 -5
- package/dist/tools/tool-kit.js +55 -24
- package/dist/tools/tool-surface.js +28 -9
- package/dist/tools/tools.js +38 -4
- package/dist/visual/capture-cleanup.d.ts +16 -0
- package/dist/visual/capture-cleanup.js +39 -0
- package/dist/visual/visual-tools.d.ts +18 -0
- package/dist/visual/visual-tools.js +24 -5
- package/docs/README.md +56 -11
- package/docs/actions.mdx +87 -0
- package/docs/agent-cheatsheet.md +102 -26
- package/docs/architecture.md +46 -35
- package/docs/benchmarks.md +76 -72
- package/docs/best-practices.mdx +211 -0
- package/docs/capabilities.mdx +387 -0
- package/docs/cli/affected.mdx +51 -0
- package/docs/cli/capsules.mdx +69 -0
- package/docs/cli/cloud.mdx +127 -0
- package/docs/cli/doctor.mdx +76 -0
- package/docs/cli/drive.mdx +55 -0
- package/docs/cli/feedback.mdx +67 -0
- package/docs/cli/gate.mdx +64 -0
- package/docs/cli/help.mdx +92 -0
- package/docs/cli/hunt.mdx +57 -0
- package/docs/cli/identify.mdx +62 -0
- package/docs/cli/init.mdx +94 -0
- package/docs/cli/kill.mdx +91 -0
- package/docs/cli/license.mdx +48 -0
- package/docs/cli/mcp.mdx +61 -0
- package/docs/cli/open.mdx +68 -0
- package/docs/cli/restart.mdx +60 -0
- package/docs/cli/rollback.mdx +35 -0
- package/docs/cli/serve.mdx +62 -0
- package/docs/cli/status.mdx +64 -0
- package/docs/cli/stop.mdx +56 -0
- package/docs/cli/telemetry.mdx +57 -0
- package/docs/cli/update.mdx +52 -0
- package/docs/cli/verify.mdx +57 -0
- package/docs/cli/version.mdx +39 -0
- package/docs/cli/watch.mdx +55 -0
- package/docs/cli.mdx +219 -0
- package/docs/debugging.md +13 -9
- package/docs/deploy-checks.md +15 -11
- package/docs/desktop-apps.md +49 -40
- package/docs/desktop.mdx +188 -0
- package/docs/docs.json +363 -0
- package/docs/enterprise.md +20 -16
- package/docs/faq.mdx +88 -0
- package/docs/fixtures.md +11 -7
- package/docs/flows.md +26 -22
- package/docs/for-agents.md +71 -0
- package/docs/frameworks.mdx +199 -0
- package/docs/gate-plan.md +45 -41
- package/docs/gates.md +23 -19
- package/docs/getting-started.md +76 -65
- package/docs/human-control.md +22 -18
- package/docs/index.mdx +101 -0
- package/docs/install-agentic.mdx +215 -0
- package/docs/install-manual.mdx +194 -0
- package/docs/instrumentation.mdx +232 -0
- package/docs/integration-patterns.md +38 -34
- package/docs/local-registry.md +16 -12
- package/docs/multi-agent-testing.md +16 -12
- package/docs/packages/babel-plugin.mdx +70 -0
- package/docs/packages/browser.mdx +111 -0
- package/docs/packages/core.mdx +106 -0
- package/docs/packages/electron.mdx +87 -0
- package/docs/packages/eslint-plugin.mdx +84 -0
- package/docs/packages/next.mdx +91 -0
- package/docs/packages/react.mdx +91 -0
- package/docs/packages/server.mdx +98 -0
- package/docs/packages/tauri.mdx +63 -0
- package/docs/packages/test.mdx +108 -0
- package/docs/packages/vite-plugin.mdx +96 -0
- package/docs/packages.mdx +217 -0
- package/docs/platform-integration.md +33 -26
- package/docs/predicates.mdx +179 -0
- package/docs/quickstart.mdx +211 -0
- package/docs/recipes.mdx +242 -0
- package/docs/skill-file.mdx +105 -0
- package/docs/state-management.mdx +197 -0
- package/docs/style.css +14 -0
- package/docs/system-map.md +47 -32
- package/docs/telemetry-contract.md +101 -46
- package/docs/telemetry-events.mdx +430 -0
- package/docs/telemetry.md +63 -50
- package/docs/testing.md +10 -6
- package/docs/token-efficiency.md +30 -26
- package/docs/tools-act-and-wait.mdx +123 -0
- package/docs/tools-act-sequence.mdx +120 -0
- package/docs/tools-act.mdx +82 -0
- package/docs/tools-assert.mdx +70 -0
- package/docs/tools-console.mdx +59 -0
- package/docs/tools-inspect.mdx +85 -0
- package/docs/tools-navigate.mdx +63 -0
- package/docs/tools-network.mdx +86 -0
- package/docs/tools-observe.mdx +89 -0
- package/docs/tools-overview.mdx +157 -0
- package/docs/tools-query.mdx +82 -0
- package/docs/tools-session-and-feedback.mdx +91 -0
- package/docs/tools-sessions.mdx +65 -0
- package/docs/tools-snapshot.mdx +75 -0
- package/docs/tools-state.mdx +81 -0
- package/docs/tools-tools-and-run.mdx +125 -0
- package/docs/tools-wait-for.mdx +66 -0
- package/docs/troubleshooting.mdx +193 -0
- package/docs/usage.md +175 -153
- package/docs/vs-chrome-devtools-mcp.mdx +82 -0
- package/docs/vs-playwright-mcp.mdx +85 -0
- package/docs/vs-screenshots.mdx +96 -0
- package/docs/what-is-reticle.mdx +63 -0
- package/docs/why-reticle.mdx +107 -0
- package/package.json +2 -2
- package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
- package/docs/matrix/2.5.0/codex-machine.json +0 -20
- package/docs/matrix/2.5.0/cursor-machine.json +0 -22
- package/docs/matrix/2.5.0/gemini-machine.json +0 -22
- package/docs/matrix/2.5.0/opencode-machine.json +0 -22
- package/docs/matrix/2.5.0/vscode-machine.json +0 -22
- package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
- package/docs/matrix/MATRIX.md +0 -26
- package/docs/matrix/README.md +0 -59
- package/docs/mint.json +0 -61
package/dist/events/predicate.js
CHANGED
|
@@ -5,8 +5,20 @@ import { selectPath, capDepth } from '../session/state-select.js';
|
|
|
5
5
|
import { describeTestidMiss } from './testid-near-miss.js';
|
|
6
6
|
import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
|
|
7
7
|
import { isAmbient, ambientKeyOf } from '../journal/ambient.js';
|
|
8
|
-
import { PredicateSchema, matchValue, evalNet, evalRoute, evalConsole, evalAnimation, evalSignal, evalSettled, } from './predicate-eval.js';
|
|
8
|
+
import { PredicateSchema, matchValue, evalNet, evalRoute, evalConsole, evalAnimation, evalSignal, evalSettled, residualQueryChecks, satisfiesResiduals, describeResidual, } from './predicate-eval.js';
|
|
9
9
|
export { PredicateSchema, PredicateKind };
|
|
10
|
+
/**
|
|
11
|
+
* A composite result that carries a child's "nobody could evaluate this" up to the verdict rule.
|
|
12
|
+
*
|
|
13
|
+
* `decideVerified` already treats `inconclusive` correctly and does it ahead of the failure clause,
|
|
14
|
+
* so all a composite has to do is stop dropping the field on its way out. `pass` stays false because
|
|
15
|
+
* nothing was proven; what changes is that the verdict reads UNKNOWN rather than blaming the app for
|
|
16
|
+
* a clause the CALL under-specified.
|
|
17
|
+
*/
|
|
18
|
+
function unreadableComposite(child, evidence) {
|
|
19
|
+
const reason = child.inconclusive ?? 'a sub-predicate could not be evaluated';
|
|
20
|
+
return { pass: false, failureReason: reason, inconclusive: reason, evidence };
|
|
21
|
+
}
|
|
10
22
|
async function matchOnce(session, query, state) {
|
|
11
23
|
const res = await session.command(ReticleCommand.MATCH, { query, state });
|
|
12
24
|
if (!res.ok)
|
|
@@ -14,8 +26,44 @@ async function matchOnce(session, query, state) {
|
|
|
14
26
|
return (res.result ?? { matched: false, count: 0, elements: [] });
|
|
15
27
|
}
|
|
16
28
|
async function evalElement(session, query, state, absent, diagnose) {
|
|
17
|
-
|
|
29
|
+
// Fields the browser's locator would have DROPPED, enforced back here — see residualQueryChecks.
|
|
30
|
+
// Checked before the round-trip when nothing can enforce them: a predicate that cannot be evaluated
|
|
31
|
+
// must say so rather than resolve to whatever the surviving half of it happened to match.
|
|
32
|
+
const residual = residualQueryChecks(query);
|
|
33
|
+
if (residual.unusable.length > 0) {
|
|
34
|
+
const reason = `the element locator ignores ${residual.unusable.map((f) => `\`${f}\``).join(', ')} ` +
|
|
35
|
+
`in ${JSON.stringify(query)} — it resolves by the first of by+value, component/source, role, ` +
|
|
36
|
+
'text, label, placeholder, testid, alt that is present, and nothing here can check the rest. ' +
|
|
37
|
+
'Assert them one locator at a time, or move the extra field into the locator';
|
|
38
|
+
return { pass: false, failureReason: reason, inconclusive: reason };
|
|
39
|
+
}
|
|
40
|
+
let match = await matchOnce(session, query, state);
|
|
18
41
|
const subject = JSON.stringify(query);
|
|
42
|
+
// A residual narrows the SET; `count` is every match while `elements` is only the described prefix,
|
|
43
|
+
// so a locator broad enough to be truncated cannot be narrowed honestly. Say so instead of guessing.
|
|
44
|
+
if (residual.checks.length > 0 && match.count > match.elements.length) {
|
|
45
|
+
const reason = `${String(match.count)} elements matched ${subject} and only ${String(match.elements.length)} were described, so ${residual.checks.map(([f]) => `\`${f}\``).join(', ')} could not be checked against all of them — narrow the locator`;
|
|
46
|
+
return { pass: false, failureReason: reason, inconclusive: reason };
|
|
47
|
+
}
|
|
48
|
+
const kept = match.elements.filter((element) => satisfiesResiduals(element, residual.checks));
|
|
49
|
+
// The locator found something and the dropped fields disagree with it. Reported separately from a
|
|
50
|
+
// plain miss because the fixes are opposite: the element IS there, its value is not what was claimed.
|
|
51
|
+
if (residual.checks.length > 0 && match.matched && 0 === kept.length && !absent) {
|
|
52
|
+
const wanted = residual.checks.map(([f, want]) => `${f}=${JSON.stringify(want)}`).join(', ');
|
|
53
|
+
return {
|
|
54
|
+
pass: false,
|
|
55
|
+
failureReason: `element matching ${subject} is present but ${wanted} does not hold`,
|
|
56
|
+
observed: match.elements
|
|
57
|
+
.map((element) => residual.checks.map(([f]) => describeResidual(element, f)).join(', '))
|
|
58
|
+
.join('; '),
|
|
59
|
+
expected: `an element matching ${subject} with ${wanted}`,
|
|
60
|
+
assertion: `element.${residual.checks[0]?.[0] ?? 'residual'}`,
|
|
61
|
+
evidence: match.elements,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (residual.checks.length > 0) {
|
|
65
|
+
match = { ...match, matched: kept.length > 0, count: kept.length, elements: kept };
|
|
66
|
+
}
|
|
19
67
|
// A given-but-missing scope is handled ASYMMETRICALLY, because "absent" and "present" ask different
|
|
20
68
|
// questions of a scope that no longer exists:
|
|
21
69
|
// - ABSENT: an element is trivially absent from a container that isn't there. This is also the
|
|
@@ -114,6 +162,33 @@ async function evalElement(session, query, state, absent, diagnose) {
|
|
|
114
162
|
...(present.length > 0 ? { evidence: { presentTestids: present } } : {}),
|
|
115
163
|
};
|
|
116
164
|
}
|
|
165
|
+
/** The single element of a list, or undefined when there is not exactly one. */
|
|
166
|
+
function oneOf(names) {
|
|
167
|
+
return 1 === names.length ? names[0] : undefined;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* `readState`'s truncation report, when the caps actually fired.
|
|
171
|
+
*
|
|
172
|
+
* Its PRESENCE is the warning — the field is omitted entirely on an intact read, which is what lets
|
|
173
|
+
* "the list is short" stay distinguishable from "I shortened the list".
|
|
174
|
+
*/
|
|
175
|
+
function truncationOf(result) {
|
|
176
|
+
if ('object' !== typeof result || null === result)
|
|
177
|
+
return undefined;
|
|
178
|
+
const report = result.truncation;
|
|
179
|
+
return 'object' === typeof report && null !== report
|
|
180
|
+
? report
|
|
181
|
+
: undefined;
|
|
182
|
+
}
|
|
183
|
+
/** A scoped `readState` reply, narrowed to the shape `selectPath` would have produced. */
|
|
184
|
+
function asSelection(result) {
|
|
185
|
+
if ('object' !== typeof result || null === result)
|
|
186
|
+
return undefined;
|
|
187
|
+
const record = result;
|
|
188
|
+
return 'boolean' === typeof record.found
|
|
189
|
+
? { found: record.found, value: record.value }
|
|
190
|
+
: undefined;
|
|
191
|
+
}
|
|
117
192
|
async function evalState(session, p) {
|
|
118
193
|
const res = await session.command(ReticleCommand.STATE_READ, p.store !== undefined ? { store: p.store } : {});
|
|
119
194
|
if (!res.ok) {
|
|
@@ -127,17 +202,75 @@ async function evalState(session, p) {
|
|
|
127
202
|
}
|
|
128
203
|
const stores = (res.result ?? {}).stores ?? {};
|
|
129
204
|
const names = Object.keys(stores);
|
|
130
|
-
|
|
205
|
+
// Ambiguity is about the PATH, not the store count.
|
|
206
|
+
//
|
|
207
|
+
// Registering more than one store is normal — an app store, a query cache, and the render meter
|
|
208
|
+
// Reticle registers itself — and asking "which of these three?" when only one of them HAS the
|
|
209
|
+
// path is a question with one possible answer. It was costing a real verdict: a bench-app drive
|
|
210
|
+
// asserting `{path:'view'}` returned `unknown` while the same response body carried the matching
|
|
211
|
+
// `view` state diff. `unknown` is not a pass, so that is a verification that did not happen.
|
|
212
|
+
//
|
|
213
|
+
// So narrow to the stores that actually carry the path, and refuse only when THOSE collide.
|
|
214
|
+
const candidates = p.store === undefined ? names.filter((n) => selectPath(stores[n], p.path).found) : [];
|
|
215
|
+
const storeName = p.store ?? (1 === names.length ? names[0] : oneOf(candidates));
|
|
131
216
|
if (storeName === undefined) {
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
217
|
+
// With no store registered there is nothing to read, and with two stores that both carry the
|
|
218
|
+
// path there is no way to pick — neither is a finding about the app, no assertion was evaluated,
|
|
219
|
+
// so both are inconclusive rather than failed. See honesty/inconclusive.
|
|
220
|
+
//
|
|
221
|
+
// Zero candidates is the one case that is NOT a question: every registered store was searched
|
|
222
|
+
// and none exposes the path, so the assertion cannot hold anywhere. That is the same verdict a
|
|
223
|
+
// named store has always produced for a missing path, and it falls through to it below.
|
|
224
|
+
if (0 === names.length) {
|
|
225
|
+
const reason = 'no registered store to read state from';
|
|
226
|
+
return { pass: false, failureReason: reason, inconclusive: reason };
|
|
227
|
+
}
|
|
228
|
+
if (candidates.length > 1) {
|
|
229
|
+
// Names the stores that actually collide. Listing all of them made the reader weigh
|
|
230
|
+
// candidates that could never have matched.
|
|
231
|
+
const reason = `multiple stores (${candidates.join(', ')}) expose '${p.path}'; name one with \`store\``;
|
|
232
|
+
return { pass: false, failureReason: reason, inconclusive: reason };
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
pass: false,
|
|
236
|
+
failureReason: `state path '${p.path}' not found in any registered store (${names.join(', ')})`,
|
|
237
|
+
observed: `no path '${p.path}' in ${names.join(', ')}`,
|
|
238
|
+
expected: `some registered store to expose '${p.path}'`,
|
|
239
|
+
assertion: 'state.path-missing',
|
|
240
|
+
evidence: { searchedStores: names },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
let selection = selectPath(stores[storeName], p.path);
|
|
244
|
+
// The whole-store read walks into a value the transport caps may already have mangled.
|
|
245
|
+
//
|
|
246
|
+
// `readState` has a SCOPED mode that selects the path out of the RAW store before sanitising, added
|
|
247
|
+
// precisely so a large or deep path still resolves. This path was not using it, so a store with one
|
|
248
|
+
// big collection in it truncated the small value sitting beside it, and the comparison then ran
|
|
249
|
+
// against the literal string "[TRUNCATED]" and returned a confident `no`. Measured on the Atlas
|
|
250
|
+
// fixture: a one-element array reported as a failed assertion while the same payload's state diffs
|
|
251
|
+
// showed the assertion holding.
|
|
252
|
+
//
|
|
253
|
+
// Re-read only when a cap actually fired, so an intact read costs exactly what it did before.
|
|
254
|
+
if (truncationOf(res.result) !== undefined) {
|
|
255
|
+
const scoped = await session.command(ReticleCommand.STATE_READ, {
|
|
256
|
+
store: storeName,
|
|
257
|
+
path: p.path,
|
|
258
|
+
});
|
|
259
|
+
const result = scoped.ok ? scoped.result : undefined;
|
|
260
|
+
const scopedTruncation = truncationOf(result);
|
|
261
|
+
if (scopedTruncation !== undefined) {
|
|
262
|
+
// Even the raw sub-tree was too big. Nothing here knows what the value IS, so nothing here can
|
|
263
|
+
// say the assertion failed — that would be an accusation the evidence does not support.
|
|
264
|
+
const reason = `state '${p.path}' could not be read intact — the transport caps truncated it, so the ` +
|
|
265
|
+
'value was never compared. Assert a narrower path, or a smaller field inside it';
|
|
266
|
+
return { pass: false, failureReason: reason, inconclusive: reason };
|
|
267
|
+
}
|
|
268
|
+
if (result !== undefined) {
|
|
269
|
+
const scopedSelection = asSelection(result);
|
|
270
|
+
if (scopedSelection !== undefined)
|
|
271
|
+
selection = scopedSelection;
|
|
272
|
+
}
|
|
139
273
|
}
|
|
140
|
-
const selection = selectPath(stores[storeName], p.path);
|
|
141
274
|
if (!selection.found) {
|
|
142
275
|
return {
|
|
143
276
|
pass: false,
|
|
@@ -206,24 +339,43 @@ diagnose = true) {
|
|
|
206
339
|
}
|
|
207
340
|
case PredicateKind.ALL_OF: {
|
|
208
341
|
const results = await Promise.all(predicate.predicates.map((p) => evaluatePredicate(session, p, since, diagnose)));
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
342
|
+
// A clause that genuinely failed OUTRANKS one nobody could read. Softening a real failure to
|
|
343
|
+
// UNKNOWN would hide the defect the agent came for, which is the more expensive of the two
|
|
344
|
+
// mistakes; the reverse — grading an unreadable clause as a defect in the app — is the one
|
|
345
|
+
// that was happening.
|
|
346
|
+
const failed = results.find((r) => !r.pass && r.inconclusive === undefined);
|
|
347
|
+
if (failed !== undefined) {
|
|
348
|
+
return {
|
|
213
349
|
pass: false,
|
|
214
350
|
failureReason: failed.failureReason ?? 'a sub-predicate of allOf failed',
|
|
215
351
|
evidence: results,
|
|
216
352
|
};
|
|
353
|
+
}
|
|
354
|
+
const unreadable = results.find((r) => r.inconclusive !== undefined);
|
|
355
|
+
if (unreadable !== undefined)
|
|
356
|
+
return unreadableComposite(unreadable, results);
|
|
357
|
+
return { pass: true, evidence: results.map((r) => r.evidence) };
|
|
217
358
|
}
|
|
218
359
|
case PredicateKind.ANY_OF: {
|
|
219
360
|
const results = await Promise.all(predicate.predicates.map((p) => evaluatePredicate(session, p, since, diagnose)));
|
|
220
361
|
const passed = results.find((r) => r.pass);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
362
|
+
if (passed !== undefined)
|
|
363
|
+
return { pass: true, evidence: passed.evidence };
|
|
364
|
+
// "No sub-predicate matched" is a claim anyOf is not entitled to make while one of them was
|
|
365
|
+
// never read: the unreadable clause might have been the one that would have matched.
|
|
366
|
+
const unreadable = results.find((r) => r.inconclusive !== undefined);
|
|
367
|
+
if (unreadable !== undefined)
|
|
368
|
+
return unreadableComposite(unreadable, results);
|
|
369
|
+
return { pass: false, failureReason: 'no sub-predicate of anyOf matched', evidence: results };
|
|
224
370
|
}
|
|
225
371
|
case PredicateKind.NOT: {
|
|
226
372
|
const inner = await evaluatePredicate(session, predicate.predicate, since, diagnose);
|
|
373
|
+
// The sharpest case of the three, and the only one that produced a GREEN. `not` read the
|
|
374
|
+
// child's `pass: false` as "the inner predicate did not hold" and passed — so an assertion
|
|
375
|
+
// nobody could evaluate became a verdict of verified, manufactured out of a missing reading.
|
|
376
|
+
// You cannot negate an answer nobody had.
|
|
377
|
+
if (inner.inconclusive !== undefined)
|
|
378
|
+
return unreadableComposite(inner, inner);
|
|
227
379
|
return inner.pass
|
|
228
380
|
? { pass: false, failureReason: 'negated predicate unexpectedly held', evidence: inner }
|
|
229
381
|
: { pass: true };
|
|
@@ -24,6 +24,14 @@ export declare class RingBuffer {
|
|
|
24
24
|
since(cursor: number): ReticleEvent[];
|
|
25
25
|
/** Events within the last `windowMs`, relative to `now`. */
|
|
26
26
|
window(windowMs: number, now: number): ReticleEvent[];
|
|
27
|
+
/**
|
|
28
|
+
* Did this buffer evict scarce evidence that belonged to a window opened at `cursor`?
|
|
29
|
+
*
|
|
30
|
+
* The one honest input to "was the capture clean". A `false` here means every non-churn event in
|
|
31
|
+
* the window is still held, whatever the drop counter says — and the drop counter says a great
|
|
32
|
+
* deal, because age eviction runs on every push.
|
|
33
|
+
*/
|
|
34
|
+
lostSince(cursor: number): boolean;
|
|
27
35
|
/** Snapshot of buffer health for the agent — live events held and cumulative drops since connect. */
|
|
28
36
|
bufferHealth(): {
|
|
29
37
|
total: number;
|
|
@@ -23,6 +23,16 @@ export class RingBuffer {
|
|
|
23
23
|
#head = 0;
|
|
24
24
|
#totalBytes = 0;
|
|
25
25
|
#droppedCount = 0;
|
|
26
|
+
/**
|
|
27
|
+
* The timestamp of the newest NON-CHURN event this buffer has ever evicted, or `undefined` if it
|
|
28
|
+
* has never lost one. This — not the drop counter — is what impeaches an observation window.
|
|
29
|
+
*
|
|
30
|
+
* The counter cannot answer the question. It moves for age eviction (everything past 60s, on every
|
|
31
|
+
* push) and for churn eviction (the low-signal floor, sacrificed on purpose so scarce evidence
|
|
32
|
+
* survives), neither of which is evidence lost from the window an agent just observed. See
|
|
33
|
+
* `lostSince` and `ring-buffer-window-loss.test.ts`.
|
|
34
|
+
*/
|
|
35
|
+
#lastScarceLossT;
|
|
26
36
|
constructor(options = {}) {
|
|
27
37
|
this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS;
|
|
28
38
|
this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS;
|
|
@@ -81,7 +91,10 @@ export class RingBuffer {
|
|
|
81
91
|
}
|
|
82
92
|
const victim = this.#findChurnAfterHead();
|
|
83
93
|
if (-1 === victim) {
|
|
84
|
-
// Buffer genuinely full of high-signal events — fall back to FIFO so it stays bounded.
|
|
94
|
+
// Buffer genuinely full of high-signal events — fall back to FIFO so it stays bounded. This
|
|
95
|
+
// is the one eviction path that destroys scarce evidence, and the only one that should ever
|
|
96
|
+
// make a verdict say it could not see.
|
|
97
|
+
this.#noteScarceLoss(this.#head);
|
|
85
98
|
this.#totalBytes -= this.#eventBytes[this.#head] ?? 0;
|
|
86
99
|
this.#head += 1;
|
|
87
100
|
continue;
|
|
@@ -91,6 +104,7 @@ export class RingBuffer {
|
|
|
91
104
|
this.#eventBytes.splice(victim, 1);
|
|
92
105
|
}
|
|
93
106
|
while (this.#liveCount() > 0 && (this.#events[this.#head]?.t ?? cutoff) < cutoff) {
|
|
107
|
+
this.#noteScarceLoss(this.#head);
|
|
94
108
|
this.#totalBytes -= this.#eventBytes[this.#head] ?? 0;
|
|
95
109
|
this.#head += 1;
|
|
96
110
|
}
|
|
@@ -102,6 +116,25 @@ export class RingBuffer {
|
|
|
102
116
|
this.#head = 0;
|
|
103
117
|
}
|
|
104
118
|
}
|
|
119
|
+
/** Record an eviction at `index` as scarce loss, unless it was the churn floor being sacrificed. */
|
|
120
|
+
#noteScarceLoss(index) {
|
|
121
|
+
const victim = this.#events[index];
|
|
122
|
+
if (victim === undefined || CHURN_TYPES.has(victim.type))
|
|
123
|
+
return;
|
|
124
|
+
if (this.#lastScarceLossT === undefined || victim.t > this.#lastScarceLossT) {
|
|
125
|
+
this.#lastScarceLossT = victim.t;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Did this buffer evict scarce evidence that belonged to a window opened at `cursor`?
|
|
130
|
+
*
|
|
131
|
+
* The one honest input to "was the capture clean". A `false` here means every non-churn event in
|
|
132
|
+
* the window is still held, whatever the drop counter says — and the drop counter says a great
|
|
133
|
+
* deal, because age eviction runs on every push.
|
|
134
|
+
*/
|
|
135
|
+
lostSince(cursor) {
|
|
136
|
+
return this.#lastScarceLossT !== undefined && this.#lastScarceLossT >= cursor;
|
|
137
|
+
}
|
|
105
138
|
/** First churn event after the head, or -1 if none within the bounded scan (keeps eviction cheap). */
|
|
106
139
|
#findChurnAfterHead() {
|
|
107
140
|
const end = Math.min(this.#events.length, this.#head + CHURN_SCAN_LIMIT);
|
|
@@ -27,7 +27,7 @@ export const FlowAssertionGrade = {
|
|
|
27
27
|
/** Performs actions but asserts nothing observable — passes even if the feature is broken. */
|
|
28
28
|
ASSERTION_FREE: 'assertion-free',
|
|
29
29
|
};
|
|
30
|
-
const ASSERTION_FREE_WARNING = 'This flow performs actions but asserts no observable consequence
|
|
30
|
+
const ASSERTION_FREE_WARNING = 'This flow performs actions but asserts no observable consequence, so it will pass even if the feature is broken. Add a consequence assertion with reticle_annotate (assert-signal / assert-net) or a success-state.';
|
|
31
31
|
const PRESENCE_ONLY_WARNING = 'This flow only checks element presence, not an observable consequence (signal/network). A locator healed to the wrong element can still pass it. Add a consequence assertion (assert-signal / assert-net / success-state).';
|
|
32
32
|
const INTENT_WITHOUT_OUTCOME_WARNING = 'This flow declares a business intent but asserts no observable outcome (signal/network) — it claims to verify a goal it cannot actually check. Add a success-state consequence so the flow fails when the goal stops being met.';
|
|
33
33
|
// Consequence vs presence-only classification lives in @reticlehq/core (one source for all graders).
|
package/dist/flows/flow-tools.js
CHANGED
|
@@ -367,23 +367,31 @@ export const FLOW_TOOLS = [
|
|
|
367
367
|
requested.length > 0) {
|
|
368
368
|
const concurrency = resolveConcurrency(requested.length, pool.capacity(), parallelArg);
|
|
369
369
|
const outcomes = await mapWithConcurrency(requested, concurrency, async (flowName) => {
|
|
370
|
-
const
|
|
370
|
+
const start = deps.now();
|
|
371
|
+
const lease = await acquireLeasedSession(pool, deps.sessions, appUrl, projectId);
|
|
371
372
|
try {
|
|
372
|
-
|
|
373
|
+
const replay = await replayNamedFlow(deps, { flowName, sessionId: lease.sessionId });
|
|
374
|
+
return { replay, durationMs: deps.now() - start };
|
|
373
375
|
}
|
|
374
376
|
finally {
|
|
375
|
-
// Always release: a crashed flow must never leak a slot and starve the rest of the suite.
|
|
376
377
|
await lease.release().catch(() => undefined);
|
|
377
378
|
}
|
|
378
379
|
});
|
|
379
380
|
const parallelRuns = await Promise.all(outcomes.map(async (o, i) => {
|
|
380
381
|
const name = requested[i] ?? '';
|
|
381
|
-
const replay = o.ok && o.value !== undefined ? o.value : leaseFailureReplay(name, o.error);
|
|
382
|
+
const replay = o.ok && o.value !== undefined ? o.value.replay : leaseFailureReplay(name, o.error);
|
|
382
383
|
const loaded = await deps.flows.load(name, projectId).catch(() => null);
|
|
383
384
|
const flow = loaded !== null && loaded.ok ? loaded.value : undefined;
|
|
384
385
|
return flow === undefined ? { replay } : { replay, flow };
|
|
385
386
|
}));
|
|
387
|
+
const timed = outcomes.map((o, i) => ({
|
|
388
|
+
replay: o.ok && o.value !== undefined
|
|
389
|
+
? o.value.replay
|
|
390
|
+
: leaseFailureReplay(requested[i] ?? '', o.error),
|
|
391
|
+
durationMs: o.ok && o.value !== undefined ? o.value.durationMs : 0,
|
|
392
|
+
}));
|
|
386
393
|
const flaky = await recordSuiteFlakes(deps.fs, deps.reticleRoot, parallelRuns);
|
|
394
|
+
await persistAndSyncVerificationRun(deps, timed, projectId);
|
|
387
395
|
const verdict = buildSuiteVerdict(parallelRuns);
|
|
388
396
|
return flaky.length > 0 ? { ...verdict, flaky: [...flaky] } : verdict;
|
|
389
397
|
}
|
package/dist/flows/heal-run.js
CHANGED
|
@@ -16,7 +16,7 @@ const HEAL_MESSAGES = {
|
|
|
16
16
|
NOTHING: 'nothing to heal — every anchor resolved on replay',
|
|
17
17
|
HEALED: "rewrote drifted testid anchors to their nearest surviving match and re-verified the flow's success consequence still fires",
|
|
18
18
|
DRIFT_DRY: 'confident rebind(s) proposed — re-run with apply:true to write them to disk',
|
|
19
|
-
UNHEALABLE: `drift found, but no nearest match cleared the confidence floor (HEAL_CONFIDENCE_MIN=${HEAL_CONFIDENCE_MIN}); file left untouched
|
|
19
|
+
UNHEALABLE: `drift found, but no nearest match cleared the confidence floor (HEAL_CONFIDENCE_MIN=${HEAL_CONFIDENCE_MIN}); file left untouched; add a data-testid or fix the flow by hand`,
|
|
20
20
|
HEALED_UNVERIFIED: 'rewrote drifted testid anchors — but this flow declares no success consequence, so the rebind resolves a locator without proving the intent still holds. Add a success-state assertion (reticle_annotate) so future heals can be verified.',
|
|
21
21
|
CONSEQUENCE_BROKEN: 'rebind resolves the drifted locator to a surviving element, but the healed flow no longer satisfies its success consequence — refusing to write (a heal that loses the intent would ship a green-but-dead test). Fix by hand and verify',
|
|
22
22
|
};
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// The kind enum lives in core (it crosses the wire in a BLIND_SPOT event); re-exported here so existing
|
|
8
8
|
// honesty-side imports keep working.
|
|
9
9
|
export { BlindSpotKind } from '@reticlehq/core';
|
|
10
|
-
import { BlindSpotKind, EventType } from '@reticlehq/core';
|
|
10
|
+
import { BlindSpotKind, EventType, ReticleEnv, TRANSPORT_LIMITS, } from '@reticlehq/core';
|
|
11
11
|
/**
|
|
12
12
|
* Whether the observation window saw everything it was asked about.
|
|
13
13
|
*
|
|
@@ -29,7 +29,11 @@ const LABEL = {
|
|
|
29
29
|
[BlindSpotKind.VIRTUALIZED_UNMOUNTED]: (n) => `${String(n)} virtualized unmounted row${1 === n ? '' : 's'} unobserved`,
|
|
30
30
|
// Not "some rows we could not see" — the events never reached the observer, so this window is a
|
|
31
31
|
// SAMPLE of what the app did. Phrased as a caveat on what the whole result MEANS.
|
|
32
|
-
|
|
32
|
+
// Names a NUMBER, not a variable. "Raise it for a busy app" leaves the reader to invent a value,
|
|
33
|
+
// and the one who reported this had to guess before finding one that worked. The default was
|
|
34
|
+
// raised in the same change, so a page still hitting the cap after that is genuinely unusual and
|
|
35
|
+
// deserves a concrete next value rather than an adjective.
|
|
36
|
+
[BlindSpotKind.RATE_LIMITED]: (n) => `${String(n)} event${1 === n ? '' : 's'} dropped by the bridge rate cap, so this window is SAMPLED. Set ${ReticleEnv.MAX_MESSAGES_PER_SECOND}=${String(TRANSPORT_LIMITS.MAX_MESSAGES_PER_SECOND * 5)} and restart the daemon, then re-run this drive`,
|
|
33
37
|
// Not a count of things — a single fact about the page. Phrased so the coverage line reads as a
|
|
34
38
|
// caveat on what the network view MEANS, not as a tally.
|
|
35
39
|
// A count of unverifiable ACTIONS, not of things we failed to look at. The distinction matters:
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* block. Harnesses gate on it (e.g. "grade ≥ net AND integrity clean"). This is pure composition +
|
|
6
6
|
* assessment — all five components already exist individually; wiring it onto results is a later step.
|
|
7
7
|
*/
|
|
8
|
+
import type { CaptureLoss } from '@reticlehq/core';
|
|
8
9
|
/** Assertion grade, strongest first — the tier the verdict actually proved. */
|
|
9
10
|
export declare const HonestyGrade: {
|
|
10
11
|
readonly SIGNAL: "signal";
|
|
@@ -23,6 +24,15 @@ interface HonestyInputs {
|
|
|
23
24
|
coveragePartial?: boolean;
|
|
24
25
|
truncated?: boolean;
|
|
25
26
|
blindSpots?: readonly string[];
|
|
27
|
+
/**
|
|
28
|
+
* The machine-readable half of `issues` — WHICH kind of loss, from core's closed `CaptureLoss`.
|
|
29
|
+
*
|
|
30
|
+
* `issues` is prose built for an agent to read, and prose is not something a dashboard can group
|
|
31
|
+
* by: `unclean_capture` became a large share of all field verdicts with no way to ask which of its three
|
|
32
|
+
* causes produced them. Passed by the caller rather than derived here, because only the caller
|
|
33
|
+
* knows whether a blind-spot note came from the browser transport or from a boundary in the page.
|
|
34
|
+
*/
|
|
35
|
+
losses?: readonly CaptureLoss[];
|
|
26
36
|
}
|
|
27
37
|
export interface HonestyBlock {
|
|
28
38
|
grade: HonestyGrade;
|
|
@@ -40,6 +50,7 @@ export interface HonestyBlock {
|
|
|
40
50
|
integrity: {
|
|
41
51
|
clean: boolean;
|
|
42
52
|
issues: string[];
|
|
53
|
+
losses?: CaptureLoss[];
|
|
43
54
|
};
|
|
44
55
|
}
|
|
45
56
|
export declare function buildHonestyBlock(inputs: HonestyInputs): HonestyBlock;
|
package/dist/honesty/honesty.js
CHANGED
|
@@ -46,7 +46,15 @@ export function buildHonestyBlock(inputs) {
|
|
|
46
46
|
? {}
|
|
47
47
|
: { envelope: { samples, sufficient: samples >= MIN_ENVELOPE_SAMPLES } }),
|
|
48
48
|
coverage: { ...(pct === undefined ? {} : { pct }), partial },
|
|
49
|
-
integrity: {
|
|
49
|
+
integrity: {
|
|
50
|
+
clean: 0 === issues.length,
|
|
51
|
+
issues,
|
|
52
|
+
// Only when something was actually lost. An empty array beside `clean: true` would be a field
|
|
53
|
+
// that is always present and always says nothing, which is how a signal gets tuned out.
|
|
54
|
+
...(0 === issues.length || undefined === inputs.losses || 0 === inputs.losses.length
|
|
55
|
+
? {}
|
|
56
|
+
: { losses: [...new Set(inputs.losses)] }),
|
|
57
|
+
},
|
|
50
58
|
};
|
|
51
59
|
}
|
|
52
60
|
/** Whether a verdict's honesty block clears the harness's bar. Reasons list every failed criterion. */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an `unsettled` verdict was waiting for, and what its window held instead.
|
|
3
|
+
*
|
|
4
|
+
* `unsettled` is the commonest reason a verdict comes back `unknown`, and all it said was that the
|
|
5
|
+
* page "never settled" — a sentence equally true of a page that polls, a page that animates, and a
|
|
6
|
+
* page whose write is still open. Those need three different next moves, and an agent handed the
|
|
7
|
+
* sentence cannot tell which it is holding, so the drive stops there. An `unknown` that explains
|
|
8
|
+
* itself is a retry; one that does not is a dead end.
|
|
9
|
+
*
|
|
10
|
+
* Two releases already tuned this window and neither is undone here. The budget is still spent in
|
|
11
|
+
* full before anything is called unsettled (settle-in-flight.ts), and the app is still given its
|
|
12
|
+
* reaction window before any accusation (waitForReaction). This only describes what was true at the
|
|
13
|
+
* moment the budget genuinely ran out.
|
|
14
|
+
*
|
|
15
|
+
* It rides in `because`, beside `verifiedReason`, rather than in a field of its own: the verdict has
|
|
16
|
+
* one sentence an agent is known to read, and a caveat parked anywhere else is a caveat nobody reads.
|
|
17
|
+
*/
|
|
18
|
+
import type { Predicate } from '../events/predicate.js';
|
|
19
|
+
export interface UnsettledWindow {
|
|
20
|
+
/** What the wait was for, in the caller's own terms — see `describeWaitTarget`. */
|
|
21
|
+
waitedFor: string;
|
|
22
|
+
/** Requests that started in this window and had not come back, as "METHOD url". */
|
|
23
|
+
stillInFlight: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The wait, in one phrase.
|
|
27
|
+
*
|
|
28
|
+
* The default matters most: a call that declares no `until` waits for the page to go IDLE, and an
|
|
29
|
+
* agent that never chose that wait cannot be expected to recognise it in a timeout about it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function describeWaitTarget(predicate: Predicate): string;
|
|
32
|
+
/**
|
|
33
|
+
* The `because` sentence for an unsettled verdict: the wait, the observation, the next move.
|
|
34
|
+
*
|
|
35
|
+
* The two branches are the point. An outstanding write is worth waiting for and worth asserting
|
|
36
|
+
* directly; page churn is neither, and telling an agent to raise its timeout on churn sells it a
|
|
37
|
+
* slower dead end. Without the detail the sentence is the one that shipped before, so a caller that
|
|
38
|
+
* cannot supply the facts loses nothing.
|
|
39
|
+
*/
|
|
40
|
+
export declare function unsettledBecause(base: string, window: UnsettledWindow | undefined): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an `unsettled` verdict was waiting for, and what its window held instead.
|
|
3
|
+
*
|
|
4
|
+
* `unsettled` is the commonest reason a verdict comes back `unknown`, and all it said was that the
|
|
5
|
+
* page "never settled" — a sentence equally true of a page that polls, a page that animates, and a
|
|
6
|
+
* page whose write is still open. Those need three different next moves, and an agent handed the
|
|
7
|
+
* sentence cannot tell which it is holding, so the drive stops there. An `unknown` that explains
|
|
8
|
+
* itself is a retry; one that does not is a dead end.
|
|
9
|
+
*
|
|
10
|
+
* Two releases already tuned this window and neither is undone here. The budget is still spent in
|
|
11
|
+
* full before anything is called unsettled (settle-in-flight.ts), and the app is still given its
|
|
12
|
+
* reaction window before any accusation (waitForReaction). This only describes what was true at the
|
|
13
|
+
* moment the budget genuinely ran out.
|
|
14
|
+
*
|
|
15
|
+
* It rides in `because`, beside `verifiedReason`, rather than in a field of its own: the verdict has
|
|
16
|
+
* one sentence an agent is known to read, and a caveat parked anywhere else is a caveat nobody reads.
|
|
17
|
+
*/
|
|
18
|
+
import { PredicateKind } from '@reticlehq/core';
|
|
19
|
+
/** Enough to recognise the one that is stuck; not so many that a chatty window eats the verdict. */
|
|
20
|
+
const MAX_LISTED_REQUESTS = 3;
|
|
21
|
+
/**
|
|
22
|
+
* The wait, in one phrase.
|
|
23
|
+
*
|
|
24
|
+
* The default matters most: a call that declares no `until` waits for the page to go IDLE, and an
|
|
25
|
+
* agent that never chose that wait cannot be expected to recognise it in a timeout about it.
|
|
26
|
+
*/
|
|
27
|
+
export function describeWaitTarget(predicate) {
|
|
28
|
+
switch (predicate.kind) {
|
|
29
|
+
case PredicateKind.SETTLED:
|
|
30
|
+
return 'the page to go idle';
|
|
31
|
+
case PredicateKind.SIGNAL:
|
|
32
|
+
return predicate.name === undefined ? 'any signal' : `signal '${predicate.name}'`;
|
|
33
|
+
case PredicateKind.NET:
|
|
34
|
+
return `a request${predicate.method === undefined ? '' : ` ${predicate.method}`}${predicate.urlContains === undefined ? '' : ` matching '${predicate.urlContains}'`}`;
|
|
35
|
+
case PredicateKind.ROUTE:
|
|
36
|
+
return `a route change${predicate.pathname === undefined ? '' : ` to '${predicate.pathname}'`}${predicate.contains === undefined ? '' : ` containing '${predicate.contains}'`}`;
|
|
37
|
+
case PredicateKind.STATE:
|
|
38
|
+
return `state at '${predicate.path}'`;
|
|
39
|
+
case PredicateKind.TEXT:
|
|
40
|
+
return `text containing '${predicate.contains}'`;
|
|
41
|
+
case PredicateKind.ELEMENT:
|
|
42
|
+
return `an element matching ${JSON.stringify(predicate.query)}`;
|
|
43
|
+
case PredicateKind.CONSOLE:
|
|
44
|
+
return 'a console message';
|
|
45
|
+
case PredicateKind.ANIMATION:
|
|
46
|
+
return predicate.name === undefined ? 'an animation' : `animation '${predicate.name}'`;
|
|
47
|
+
case PredicateKind.ALL_OF:
|
|
48
|
+
return `all of (${predicate.predicates.map(describeWaitTarget).join('; ')})`;
|
|
49
|
+
case PredicateKind.ANY_OF:
|
|
50
|
+
return `any of (${predicate.predicates.map(describeWaitTarget).join('; ')})`;
|
|
51
|
+
case PredicateKind.NOT:
|
|
52
|
+
return `the absence of ${describeWaitTarget(predicate.predicate)}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The `because` sentence for an unsettled verdict: the wait, the observation, the next move.
|
|
57
|
+
*
|
|
58
|
+
* The two branches are the point. An outstanding write is worth waiting for and worth asserting
|
|
59
|
+
* directly; page churn is neither, and telling an agent to raise its timeout on churn sells it a
|
|
60
|
+
* slower dead end. Without the detail the sentence is the one that shipped before, so a caller that
|
|
61
|
+
* cannot supply the facts loses nothing.
|
|
62
|
+
*/
|
|
63
|
+
export function unsettledBecause(base, window) {
|
|
64
|
+
if (window === undefined)
|
|
65
|
+
return `${base} — re-check, or assert a consequence that settles`;
|
|
66
|
+
if (0 === window.stillInFlight.length) {
|
|
67
|
+
return (`${base}. It waited for ${window.waitedFor}, and no request started in this window was left ` +
|
|
68
|
+
'open — what kept the page busy was its own churn (a poll, a timer, an animation), so waiting ' +
|
|
69
|
+
'longer will not help. Assert the consequence you care about (a signal, a route change, or ' +
|
|
70
|
+
'store state) instead of waiting for idle, and re-check if the app is genuinely slow');
|
|
71
|
+
}
|
|
72
|
+
const shown = window.stillInFlight.slice(0, MAX_LISTED_REQUESTS);
|
|
73
|
+
const rest = window.stillInFlight.length - shown.length;
|
|
74
|
+
return (`${base}. It waited for ${window.waitedFor}, and ${String(window.stillInFlight.length)} ` +
|
|
75
|
+
`request(s) started in this window had not come back: ${shown.join(', ')}` +
|
|
76
|
+
`${rest > 0 ? `, and ${String(rest)} more` : ''} — re-check once they land, raise timeout_ms, ` +
|
|
77
|
+
'or assert the request itself so the wait ends on the response rather than on idle');
|
|
78
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Verified, VerifiedReason } from '@reticlehq/core';
|
|
2
2
|
import { type HonestyBlock } from './honesty.js';
|
|
3
|
+
import { type UnsettledWindow } from './unsettled.js';
|
|
3
4
|
/**
|
|
4
5
|
* The decision rule: eight trust dimensions in, one answer out.
|
|
5
6
|
*
|
|
@@ -51,6 +52,12 @@ interface VerifiedInputs {
|
|
|
51
52
|
* never read. See `hasUnreadWriteOutcome` — the status line describes the transport, not the result.
|
|
52
53
|
*/
|
|
53
54
|
outcomeUnread?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* What the wait was for and what the window held when it ended — read ONLY by the two clauses that
|
|
57
|
+
* answer UNSETTLED, which is the commonest reason a verdict comes back `unknown` and was also the
|
|
58
|
+
* least actionable. Optional: without it those clauses say exactly what they said before.
|
|
59
|
+
*/
|
|
60
|
+
unsettled?: UnsettledWindow;
|
|
54
61
|
}
|
|
55
62
|
interface VerifiedVerdict {
|
|
56
63
|
verified: Verified;
|