@reticlehq/server 2.8.0 → 2.10.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/SKILL.md +130 -813
- package/dist/bridge/bridge.d.ts +23 -0
- package/dist/bridge/bridge.js +80 -2
- package/dist/bridge/bridge.test-harness.d.ts +5 -0
- package/dist/bridge/bridge.test-harness.js +6 -4
- package/dist/capsule/capsule-store.d.ts +2 -2
- package/dist/capsule/causal-summary.d.ts +16 -1
- package/dist/capsule/causal-summary.js +2 -1
- package/dist/cli/cli-doctor.js +41 -13
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-launch.d.ts +0 -17
- package/dist/cli/cli-launch.js +0 -28
- package/dist/cli/cli-parse.d.ts +1 -1
- package/dist/cli/cli-parse.js +1 -1
- package/dist/cli/cli-port.d.ts +24 -0
- package/dist/cli/cli-port.js +66 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +45 -1
- package/dist/cli/config-discovery.d.ts +35 -0
- package/dist/cli/config-discovery.js +163 -0
- package/dist/cli/doctor-daemon-line.js +4 -1
- package/dist/cli/doctor-project-line.d.ts +37 -0
- package/dist/cli/doctor-project-line.js +35 -0
- package/dist/cli/doctor-rows.d.ts +51 -0
- package/dist/cli/doctor-rows.js +54 -0
- package/dist/cli/doctor-sessions-line.js +6 -3
- package/dist/cli/drive-attach.d.ts +99 -0
- package/dist/cli/drive-attach.js +203 -0
- package/dist/cli/drive-command.d.ts +13 -0
- package/dist/cli/drive-command.js +103 -0
- package/dist/cli/open-note.d.ts +20 -0
- package/dist/cli/open-note.js +37 -0
- package/dist/cli/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +65 -70
- package/dist/crawl/crawl.d.ts +7 -2
- package/dist/crawl/crawl.js +52 -9
- package/dist/ee/audit-log.js +2 -2
- package/dist/events/body-failures.d.ts +2 -2
- package/dist/events/contradiction-folds.d.ts +45 -0
- package/dist/events/contradiction-folds.js +42 -0
- package/dist/events/contradictions.d.ts +51 -1
- package/dist/events/contradictions.js +26 -7
- package/dist/events/declared.d.ts +45 -0
- package/dist/events/declared.js +73 -0
- package/dist/events/echo-mismatch.d.ts +9 -3
- package/dist/events/echo-mismatch.js +33 -5
- package/dist/events/event-filters.d.ts +1 -1
- package/dist/events/event-filters.js +7 -4
- package/dist/events/predicate-eval.d.ts +17 -91
- package/dist/events/predicate-eval.js +85 -326
- package/dist/events/predicate-parse.js +31 -3
- package/dist/events/predicate-route.d.ts +32 -0
- package/dist/events/predicate-route.js +102 -0
- package/dist/events/predicate-schema.d.ts +99 -0
- package/dist/events/predicate-schema.js +309 -0
- package/dist/events/predicate.d.ts +8 -0
- package/dist/events/predicate.js +127 -5
- package/dist/events/stale-response.d.ts +2 -3
- package/dist/events/stale-response.js +49 -2
- package/dist/events/unit-mismatch.d.ts +2 -2
- package/dist/flows/annotate-tools.js +3 -0
- package/dist/flows/annotate.js +18 -4
- package/dist/flows/coverage.d.ts +38 -5
- package/dist/flows/coverage.js +30 -4
- package/dist/flows/decision.d.ts +15 -0
- package/dist/flows/decision.js +1 -1
- package/dist/flows/flow-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +9 -1
- package/dist/flows/replay.js +3 -0
- package/dist/flows/verify-change-tools.js +1 -1
- package/dist/honesty/accepted-write.d.ts +3 -0
- package/dist/honesty/accepted-write.js +8 -3
- package/dist/honesty/already-true.js +15 -0
- package/dist/honesty/blind-spots.js +4 -0
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +1 -0
- package/dist/honesty/uncaptured-bodies.d.ts +4 -2
- package/dist/honesty/uncaptured-bodies.js +9 -2
- package/dist/honesty/unread-outcome.d.ts +8 -0
- package/dist/honesty/unread-outcome.js +36 -9
- package/dist/honesty/unsettled.d.ts +9 -0
- package/dist/honesty/unsettled.js +7 -3
- package/dist/honesty/verified.d.ts +29 -3
- package/dist/honesty/verified.js +67 -8
- package/dist/http-server.d.ts +19 -0
- package/dist/http-server.js +202 -4
- package/dist/impact/impact-recorder.d.ts +23 -0
- package/dist/impact/impact-recorder.js +76 -0
- package/dist/impact/impact-store.d.ts +47 -0
- package/dist/impact/impact-store.js +171 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +60 -10
- package/dist/init/agent-rules.d.ts +20 -0
- package/dist/init/agent-rules.js +36 -0
- package/dist/init/astro-patch.js +27 -1
- package/dist/init/confirm.d.ts +21 -3
- package/dist/init/confirm.js +32 -5
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- package/dist/init/csp-check.d.ts +38 -0
- package/dist/init/csp-check.js +92 -0
- package/dist/init/csp-doctor.d.ts +25 -0
- package/dist/init/csp-doctor.js +49 -0
- package/dist/init/cursor.js +6 -9
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +33 -3
- package/dist/init/mcp-clients.d.ts +1 -0
- package/dist/init/mcp-clients.js +51 -7
- package/dist/init/mcp.d.ts +16 -2
- package/dist/init/mcp.js +23 -1
- package/dist/init/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +25 -1
- package/dist/init/plan.d.ts +7 -2
- package/dist/init/plan.js +66 -30
- package/dist/init/run.js +11 -5
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +12 -4
- package/dist/init/snippets.d.ts +1 -1
- package/dist/init/snippets.js +35 -7
- package/dist/init/vite-config.js +24 -2
- package/dist/input/network-mock-tools.js +8 -1
- package/dist/input/scroll-find.d.ts +6 -0
- package/dist/input/scroll-find.js +17 -2
- package/dist/input/scroll-tools.js +4 -0
- package/dist/journal/attach-journal.d.ts +9 -1
- package/dist/journal/attach-journal.js +13 -2
- package/dist/journal/journal-query.d.ts +13 -0
- package/dist/journal/journal-query.js +28 -3
- package/dist/journal/workspace-gitignore.d.ts +10 -0
- package/dist/journal/workspace-gitignore.js +59 -0
- package/dist/license/license.d.ts +69 -4
- package/dist/license/license.js +84 -15
- package/dist/mcp/mcp-proxy.d.ts +7 -93
- package/dist/mcp/mcp-proxy.js +183 -250
- package/dist/mcp/mcp.d.ts +27 -2
- package/dist/mcp/mcp.js +77 -36
- package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
- package/dist/mcp/proxy-daemon-probe.js +48 -0
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +110 -3
- package/dist/mcp/proxy-lifecycle.d.ts +10 -1
- package/dist/mcp/proxy-lifecycle.js +11 -2
- package/dist/mcp/proxy-log.d.ts +63 -0
- package/dist/mcp/proxy-log.js +132 -0
- package/dist/mcp/server-instructions.d.ts +17 -0
- package/dist/mcp/server-instructions.js +56 -0
- package/dist/mcp/shared-params.d.ts +27 -0
- package/dist/mcp/shared-params.js +36 -0
- package/dist/mcp/sse-frame-parser.d.ts +19 -0
- package/dist/mcp/sse-frame-parser.js +41 -0
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/session/connection-memory.d.ts +59 -0
- package/dist/session/connection-memory.js +108 -0
- package/dist/session/dev-command.d.ts +26 -0
- package/dist/session/dev-command.js +89 -0
- package/dist/session/dev-server-probe.d.ts +68 -0
- package/dist/session/dev-server-probe.js +102 -7
- package/dist/session/last-act.d.ts +26 -0
- package/dist/session/last-act.js +35 -1
- package/dist/session/live-control-tools.js +42 -5
- package/dist/session/no-session-diagnosis.d.ts +55 -2
- package/dist/session/no-session-diagnosis.js +199 -22
- package/dist/session/no-session-next-action.d.ts +46 -0
- package/dist/session/no-session-next-action.js +85 -0
- package/dist/session/no-session-watch.d.ts +40 -1
- package/dist/session/no-session-watch.js +172 -11
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -1
- package/dist/session/session-manager.d.ts +4 -0
- package/dist/session/session-manager.js +35 -5
- package/dist/session/session-readiness.js +1 -1
- package/dist/session/session.d.ts +10 -1
- package/dist/session/session.js +146 -5
- package/dist/telemetry/app-instrumented.d.ts +8 -0
- package/dist/telemetry/app-instrumented.js +10 -0
- package/dist/telemetry/bug-found.d.ts +18 -0
- package/dist/telemetry/bug-found.js +14 -0
- package/dist/telemetry/daemon-telemetry.js +29 -0
- package/dist/telemetry/install-source.d.ts +20 -0
- package/dist/telemetry/install-source.js +34 -0
- package/dist/telemetry/instrumentation-stall.d.ts +57 -0
- package/dist/telemetry/instrumentation-stall.js +103 -0
- package/dist/telemetry/license-activation.d.ts +48 -0
- package/dist/telemetry/license-activation.js +57 -0
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +10 -0
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +23 -3
- package/dist/tools/act-tools.js +166 -29
- package/dist/tools/assert-verdict.js +32 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/dynamic-tools.js +17 -8
- package/dist/tools/error-recovery.js +10 -6
- package/dist/tools/feedback-tools.js +31 -6
- package/dist/tools/invoke-tool.d.ts +1 -1
- package/dist/tools/invoke-tool.js +30 -8
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +11 -0
- package/dist/tools/lease-tools.js +141 -96
- package/dist/tools/observe-tools.js +9 -3
- package/dist/tools/react-grace.d.ts +4 -0
- package/dist/tools/react-grace.js +6 -3
- package/dist/tools/read-tools.js +47 -1
- package/dist/tools/resolve-target.d.ts +33 -0
- package/dist/tools/resolve-target.js +53 -0
- package/dist/tools/settle-in-flight.d.ts +15 -0
- package/dist/tools/settle-in-flight.js +42 -2
- package/dist/tools/snapshot-delta.d.ts +3 -0
- package/dist/tools/snapshot-delta.js +67 -6
- package/dist/tools/tool-kit.d.ts +38 -3
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +84 -19
- package/dist/tools/tools.js +61 -5
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +1 -2
- package/docs/actions.mdx +41 -8
- package/docs/agent-cheatsheet.md +6 -4
- package/docs/architecture.md +6 -0
- package/docs/benchmarks.md +60 -12
- package/docs/best-practices.mdx +11 -6
- package/docs/capabilities.mdx +204 -57
- package/docs/cli/affected.mdx +6 -4
- package/docs/cli/capsules.mdx +9 -8
- package/docs/cli/cloud.mdx +30 -4
- package/docs/cli/doctor.mdx +41 -8
- package/docs/cli/drive.mdx +34 -15
- package/docs/cli/gate.mdx +6 -6
- package/docs/cli/help.mdx +8 -6
- package/docs/cli/hunt.mdx +20 -4
- package/docs/cli/identify.mdx +16 -2
- package/docs/cli/init.mdx +35 -8
- package/docs/cli/kill.mdx +5 -5
- package/docs/cli/license.mdx +1 -1
- package/docs/cli/open.mdx +6 -0
- package/docs/cli/restart.mdx +6 -6
- package/docs/cli/serve.mdx +10 -3
- package/docs/cli/status.mdx +29 -5
- package/docs/cli/stop.mdx +8 -0
- package/docs/cli/verify.mdx +20 -1
- package/docs/cli/version.mdx +1 -1
- package/docs/cli/watch.mdx +9 -2
- package/docs/cli.mdx +44 -22
- package/docs/debugging.md +3 -1
- package/docs/deploy-checks.md +31 -2
- package/docs/desktop.mdx +276 -80
- package/docs/docs.json +13 -12
- package/docs/enterprise.md +9 -3
- package/docs/faq.mdx +49 -35
- package/docs/fixtures.md +2 -0
- package/docs/flows.md +7 -5
- package/docs/for-agents.md +3 -3
- package/docs/frameworks.mdx +46 -7
- package/docs/gate-plan.md +4 -2
- package/docs/gates.md +8 -6
- package/docs/getting-started.md +53 -12
- package/docs/human-control.md +2 -0
- package/docs/index.mdx +43 -1
- package/docs/install-agentic.mdx +13 -4
- package/docs/install-manual.mdx +6 -4
- package/docs/instrumentation.mdx +7 -1
- package/docs/integration-patterns.md +3 -1
- package/docs/local-registry.md +4 -2
- package/docs/multi-agent-testing.md +3 -1
- package/docs/packages/babel-plugin.mdx +15 -6
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/core.mdx +26 -16
- package/docs/packages/electron.mdx +5 -7
- package/docs/packages/eslint-plugin.mdx +5 -3
- package/docs/packages/next.mdx +3 -5
- package/docs/packages/react.mdx +3 -3
- package/docs/packages/server.mdx +2 -2
- package/docs/packages/tauri.mdx +6 -6
- package/docs/packages/test.mdx +18 -7
- package/docs/packages/vite-plugin.mdx +4 -6
- package/docs/packages.mdx +14 -13
- package/docs/platform-integration.md +2 -0
- package/docs/predicates.mdx +76 -7
- package/docs/quickstart.mdx +51 -5
- package/docs/recipes.mdx +63 -23
- package/docs/skill-file.mdx +3 -1
- package/docs/state-management.mdx +2 -0
- package/docs/system-map.md +3 -1
- package/docs/telemetry-contract.md +33 -5
- package/docs/telemetry-events.mdx +63 -18
- package/docs/telemetry.md +8 -5
- package/docs/testing.md +35 -1
- package/docs/token-efficiency.md +8 -2
- package/docs/tools-act-and-wait.mdx +64 -14
- package/docs/tools-act-sequence.mdx +34 -17
- package/docs/tools-act.mdx +24 -11
- package/docs/tools-assert.mdx +69 -10
- package/docs/tools-console.mdx +11 -7
- package/docs/tools-inspect.mdx +11 -8
- package/docs/tools-navigate.mdx +41 -5
- package/docs/tools-network.mdx +42 -9
- package/docs/tools-observe.mdx +66 -10
- package/docs/tools-overview.mdx +26 -23
- package/docs/tools-query.mdx +52 -8
- package/docs/tools-session-and-feedback.mdx +35 -9
- package/docs/tools-sessions.mdx +39 -10
- package/docs/tools-snapshot.mdx +57 -3
- package/docs/tools-state.mdx +24 -1
- package/docs/tools-tools-and-run.mdx +55 -24
- package/docs/tools-wait-for.mdx +34 -15
- package/docs/troubleshooting.mdx +80 -10
- package/docs/usage.md +46 -24
- package/docs/vs-chrome-devtools-mcp.mdx +41 -0
- package/docs/vs-playwright-mcp.mdx +42 -2
- package/docs/vs-screenshots.mdx +42 -1
- package/docs/what-is-reticle.mdx +1 -1
- package/docs/why-reticle.mdx +3 -1
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -265
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { openFailureNote } from './cli/open-note.js';
|
|
3
4
|
import { realpathSync } from 'node:fs';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { stateDirProblem } from './daemon/state-dir.js';
|
|
7
|
+
import { statusNextAction } from './cli/status-next-action.js';
|
|
8
|
+
import { hasConnectedBefore } from './session/connection-memory.js';
|
|
6
9
|
import { reticleStateHome } from './daemon/daemon.js';
|
|
7
10
|
import { handleWatch, handleCapsules, handleGate, loadNamedFlows, resolveChangedFiles, } from './cli/cli-flow-commands.js';
|
|
8
11
|
import { RETICLE_DEFAULT_PORT, ReticleDir, ReticleEnv } from '@reticlehq/core';
|
|
@@ -11,7 +14,7 @@ import { createNodeFileSystem } from './project/fs-port.js';
|
|
|
11
14
|
import { affectedSavedFlows } from './flows/flow-sources.js';
|
|
12
15
|
import { availableUpdate } from './update/update-nudge.js';
|
|
13
16
|
import { handleUpdate, handleRollback } from './cli/cli-update-commands.js';
|
|
14
|
-
import {
|
|
17
|
+
import { startDaemon } from './index.js';
|
|
15
18
|
import { isCloudCommand, runCloudCommand } from './cli/cloud-cli.js';
|
|
16
19
|
import { SERVER_VERSION } from './version/server-version.js';
|
|
17
20
|
import { log } from './log.js';
|
|
@@ -22,7 +25,8 @@ import { installDaemonResilience, installProxyResilience, recordExitReason, Daem
|
|
|
22
25
|
import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
|
|
23
26
|
import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/heartbeat.js';
|
|
24
27
|
import { everServedToolCall } from './daemon/daemon-usefulness.js';
|
|
25
|
-
import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen,
|
|
28
|
+
import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, openInBrowser, openCommand, } from './cli/cli-launch.js';
|
|
29
|
+
import { handleDrive } from './cli/drive-command.js';
|
|
26
30
|
import { handleVerify } from './cli/cli-verify.js';
|
|
27
31
|
import { runKill } from './cli/cli-kill.js';
|
|
28
32
|
import { summarizeHunt } from './hunt/hunt-report.js';
|
|
@@ -220,17 +224,34 @@ async function handleRestart(port, force) {
|
|
|
220
224
|
// same lie `serve` already stopped telling.
|
|
221
225
|
await serveWithHonestExit({ port, headless: true, http: false });
|
|
222
226
|
}
|
|
227
|
+
/** `{ nextAction }` when there is one, `{}` when a session is connected — so the success case is silent. */
|
|
228
|
+
function withNextAction(facts) {
|
|
229
|
+
const next = statusNextAction(facts);
|
|
230
|
+
return next === undefined ? {} : { nextAction: next };
|
|
231
|
+
}
|
|
223
232
|
function handleStatus(port) {
|
|
224
233
|
const pid = readPid(port);
|
|
234
|
+
// Durable, so it survives the daemon idling out — which is the state `status` is most often run in.
|
|
235
|
+
const projectId = readProjectId(process.cwd());
|
|
236
|
+
const previouslyConnected = hasConnectedBefore(reticleStateHome(), port, projectId);
|
|
237
|
+
// Whether `init` has run HERE. Registering the MCP server does not wire the app, and more than one
|
|
238
|
+
// path does the first without the second — so this is the commonest reason `status` has nothing to
|
|
239
|
+
// report, and it was not among the facts this command could state.
|
|
240
|
+
const initialized = projectId !== undefined;
|
|
225
241
|
if (null === pid || !isAlive(pid)) {
|
|
226
242
|
// `running: false` on its own has been reported about a port that was demonstrably occupied,
|
|
227
243
|
// because the pid file is not the port. Ask the port before answering.
|
|
228
244
|
void probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }).then((presence) => {
|
|
245
|
+
const running = presenceIsUsable(presence);
|
|
229
246
|
log('reticle_status', {
|
|
230
247
|
port,
|
|
231
|
-
running
|
|
248
|
+
running,
|
|
232
249
|
presence,
|
|
233
250
|
...(presence === PortPresence.FOREIGN ? { reason: describePresence(presence, port) } : {}),
|
|
251
|
+
// `init` promises this command says why the app has not connected. Without it the answer was
|
|
252
|
+
// `running: false` and nothing else, which reads as "Reticle is broken" for what is usually
|
|
253
|
+
// just a daemon that has not been asked to do anything yet.
|
|
254
|
+
...withNextAction({ running, sessionCount: 0, previouslyConnected, initialized }),
|
|
234
255
|
});
|
|
235
256
|
});
|
|
236
257
|
return;
|
|
@@ -243,10 +264,23 @@ function handleStatus(port) {
|
|
|
243
264
|
const update = availableUpdate();
|
|
244
265
|
const nudge = update === undefined ? {} : { updateAvailable: update };
|
|
245
266
|
if (payload === undefined) {
|
|
246
|
-
log('reticle_status', {
|
|
267
|
+
log('reticle_status', {
|
|
268
|
+
port,
|
|
269
|
+
running: true,
|
|
270
|
+
pid,
|
|
271
|
+
...nudge,
|
|
272
|
+
...withNextAction({ running: true, sessionCount: 0, previouslyConnected, initialized }),
|
|
273
|
+
});
|
|
247
274
|
return;
|
|
248
275
|
}
|
|
249
|
-
|
|
276
|
+
const summary = summarizeStatus(payload);
|
|
277
|
+
// Only when the daemon did NOT already explain itself. It has the whole diagnosis in-process and
|
|
278
|
+
// puts it on the wire as `why`; printing a second, thinner opinion beside it risks two confident
|
|
279
|
+
// answers pointing different ways, which is worse than one.
|
|
280
|
+
const next = summary.why === undefined
|
|
281
|
+
? withNextAction({ running: true, ...summary, previouslyConnected, initialized })
|
|
282
|
+
: {};
|
|
283
|
+
log('reticle_status', { port, running: true, pid, ...summary, ...next, ...nudge });
|
|
250
284
|
});
|
|
251
285
|
}
|
|
252
286
|
/** Print the running package version (resolved once in server-version.ts). */
|
|
@@ -318,13 +352,20 @@ async function handleHunt(dir) {
|
|
|
318
352
|
}
|
|
319
353
|
/** Ensure a daemon is reachable on `port` (probe the real port; spawn + wait only if nothing's there). */
|
|
320
354
|
function ensureDaemon(port) {
|
|
321
|
-
return
|
|
355
|
+
return probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }).then(async (presence) => {
|
|
322
356
|
// Attaching to whatever already owns the port is the whole point of a daemon — but it means an
|
|
323
357
|
// upgrade does NOT take effect until that daemon dies, and nothing used to say so. Say it here,
|
|
324
358
|
// where both versions are in hand, and keep attaching: killing another agent's daemon on a
|
|
325
359
|
// version bump is worse than a loud warning.
|
|
326
|
-
if (
|
|
360
|
+
if (presenceIsUsable(presence))
|
|
327
361
|
return warnOnDaemonSkew(port);
|
|
362
|
+
// Not free, not a daemon: a stranger holds the port. Spawning here is guaranteed to fail —
|
|
363
|
+
// the child cannot bind — and `waitForDaemon` would then report READY anyway, because its
|
|
364
|
+
// probe is the same bare TCP connect and the stranger accepts. That is the "serve reports
|
|
365
|
+
// success for a daemon that never bound" shape. Refuse instead, with the sentence `doctor`
|
|
366
|
+
// says.
|
|
367
|
+
if (PortPresence.FREE !== presence)
|
|
368
|
+
throw new Error(describePresence(presence, port));
|
|
328
369
|
const scriptPath = process.argv[1];
|
|
329
370
|
if (scriptPath === undefined)
|
|
330
371
|
throw new Error('cannot locate the reticle daemon script');
|
|
@@ -410,6 +451,7 @@ function handleOpen(requestedPort, url) {
|
|
|
410
451
|
const connected = await waitForNewSession(port, sessions.length);
|
|
411
452
|
log('reticle_open', {
|
|
412
453
|
port,
|
|
454
|
+
...(port === requestedPort ? {} : { requestedPort }),
|
|
413
455
|
opened: decision.url,
|
|
414
456
|
connected,
|
|
415
457
|
...(connected
|
|
@@ -422,14 +464,7 @@ function handleOpen(requestedPort, url) {
|
|
|
422
464
|
// about a browser THIS command never uses, so a missing Chromium reads as the
|
|
423
465
|
// explanation for a session that is missing for an unrelated reason. Both misdirects
|
|
424
466
|
// were reported from the field, each costing several calls of app-side wiring hunt.
|
|
425
|
-
note:
|
|
426
|
-
`Reticle's own Chromium, so a chromium warning from \`reticle doctor\` is unrelated ` +
|
|
427
|
-
`to this). No Reticle session appeared. By far the likeliest cause is that the app ` +
|
|
428
|
-
`carries no Reticle SDK, or dials a port other than ${String(port)} — run ` +
|
|
429
|
-
`\`reticle init\` in the app's directory and restart its dev server. If the app IS ` +
|
|
430
|
-
`wired, give the page a moment and check the browser console: the SDK announces its ` +
|
|
431
|
-
`own connect failures there, including the one it refuses to make from a ` +
|
|
432
|
-
`non-localhost host without allowNonLocalhost.`,
|
|
467
|
+
note: openFailureNote(port, requestedPort),
|
|
433
468
|
}),
|
|
434
469
|
});
|
|
435
470
|
})
|
|
@@ -478,6 +513,12 @@ function handleDaemonInner(parsed) {
|
|
|
478
513
|
// which is after everything below has run. Without it the exit line says `code: 0` and a
|
|
479
514
|
// reader cannot tell a tidy stop from the bridge disappearing — see #123.
|
|
480
515
|
recordExitReason(reason);
|
|
516
|
+
// Say so on the wire before anything closes. The proxy on the other end sees a clean stream
|
|
517
|
+
// end whether we retired on schedule or died under it, and it has no other way to tell —
|
|
518
|
+
// which is how a designed shutdown came to dominate the metric that means "the agent lost
|
|
519
|
+
// its tools". Told, the proxy classifies its own drop honestly. First, because the send has
|
|
520
|
+
// to reach a socket that is still open.
|
|
521
|
+
server.announceShutdown?.();
|
|
481
522
|
// Awaited before the close/exit chain: `process.exit(0)` kills an in-flight POST, and this is
|
|
482
523
|
// the one event carrying the whole session. A failed send resolves anyway (emit swallows its
|
|
483
524
|
// own errors), so this can delay the exit by at most the send timeout, never prevent it.
|
|
@@ -572,8 +613,15 @@ function handleMcp(opts) {
|
|
|
572
613
|
* disappeared mid-session with nothing said. Respawning here makes the reconnect self-healing.
|
|
573
614
|
*/
|
|
574
615
|
const ensure = async () => {
|
|
575
|
-
|
|
616
|
+
// The same question every other surface asks. It used to be a bare TCP connect, so a stranger on
|
|
617
|
+
// the bridge port answered "a daemon is here" — the proxy connected, the stream ended, and each
|
|
618
|
+
// client request woke into the identical non-answer. Rejecting here is what puts the proxy
|
|
619
|
+
// dormant with a reason, instead of pretending the wake succeeded.
|
|
620
|
+
const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
|
|
621
|
+
if (presenceIsUsable(presence))
|
|
576
622
|
return;
|
|
623
|
+
if (PortPresence.FREE !== presence)
|
|
624
|
+
throw new Error(describePresence(presence, port));
|
|
577
625
|
const scriptPath = process.argv[1];
|
|
578
626
|
if (scriptPath === undefined) {
|
|
579
627
|
log('reticle_mcp_no_script', {});
|
|
@@ -640,59 +688,6 @@ function handleMcp(opts) {
|
|
|
640
688
|
});
|
|
641
689
|
});
|
|
642
690
|
}
|
|
643
|
-
function handleLegacyDrive(parsed) {
|
|
644
|
-
void driveWithHonestConflict(parsed);
|
|
645
|
-
}
|
|
646
|
-
/**
|
|
647
|
-
* Refuse the port BEFORE binding it, and name what is holding it.
|
|
648
|
-
*
|
|
649
|
-
* `drive` went straight to `start`, which binds. The listen error surfaces asynchronously on the
|
|
650
|
-
* server object long after `start` has resolved, so the `.catch` on that promise could never see it
|
|
651
|
-
* and the process died with the raw `node:net` EADDRINUSE stack — in the one situation this command
|
|
652
|
-
* is most often run in, since `reticle_sessions` recommends `reticle drive` for a throttled tab and
|
|
653
|
-
* a throttled tab nearly always coexists with the daemon holding the port.
|
|
654
|
-
*/
|
|
655
|
-
async function driveWithHonestConflict(parsed) {
|
|
656
|
-
const presence = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
|
|
657
|
-
const conflict = drivePortConflict(presence, parsed.port, { ourPid: readPid(parsed.port) });
|
|
658
|
-
if (conflict !== undefined) {
|
|
659
|
-
log('reticle_drive_port_conflict', { port: parsed.port, presence, reason: conflict });
|
|
660
|
-
process.stderr.write(`${conflict}\n`);
|
|
661
|
-
process.exit(1);
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
// The probe cannot close the race: something can take the port between here and the bind, and the
|
|
665
|
-
// bind reports it on the server, outside every promise this function holds. Catching it at the
|
|
666
|
-
// process is the only place that sees it at all — and only EADDRINUSE, so a genuine crash still
|
|
667
|
-
// crashes rather than being dressed up as a port conflict. Who won the race is not knowable from
|
|
668
|
-
// in here, so the sentence used is the one that is true of any holder.
|
|
669
|
-
process.on('uncaughtException', (error) => {
|
|
670
|
-
const code = error?.code;
|
|
671
|
-
if ('EADDRINUSE' !== code)
|
|
672
|
-
throw error;
|
|
673
|
-
const reason = drivePortConflict(PortPresence.FOREIGN, parsed.port);
|
|
674
|
-
log('reticle_drive_port_conflict', {
|
|
675
|
-
port: parsed.port,
|
|
676
|
-
presence: PortPresence.FOREIGN,
|
|
677
|
-
reason,
|
|
678
|
-
});
|
|
679
|
-
process.stderr.write(`${String(reason)}\n`);
|
|
680
|
-
process.exit(1);
|
|
681
|
-
});
|
|
682
|
-
const options = {
|
|
683
|
-
port: parsed.port,
|
|
684
|
-
driveUrl: parsed.driveUrl,
|
|
685
|
-
headless: parsed.headless,
|
|
686
|
-
};
|
|
687
|
-
try {
|
|
688
|
-
await start(options);
|
|
689
|
-
log('reticle_started', { port: parsed.port });
|
|
690
|
-
}
|
|
691
|
-
catch (error) {
|
|
692
|
-
log('reticle_start_failed', { error: error instanceof Error ? error.message : String(error) });
|
|
693
|
-
process.exit(1);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
691
|
function main() {
|
|
697
692
|
// Before anything reads process.env — notably the telemetry gate and the bridge's security
|
|
698
693
|
// options — fold in a project-local `.env`. Values already in the environment always win.
|
|
@@ -782,7 +777,7 @@ function main() {
|
|
|
782
777
|
handleOpen(parsed.port, parsed.url);
|
|
783
778
|
break;
|
|
784
779
|
case 'drive':
|
|
785
|
-
|
|
780
|
+
handleDrive(parsed);
|
|
786
781
|
break;
|
|
787
782
|
case 'verify':
|
|
788
783
|
handleVerify(parsed);
|
package/dist/crawl/crawl.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type CommandResult, type ReticleEvent } from '@reticlehq/core';
|
|
2
2
|
/** The slice of Session the crawler needs — so tests inject a fake without a live browser. */
|
|
3
3
|
export interface CrawlSession {
|
|
4
4
|
command(name: string, args?: Record<string, unknown>): Promise<CommandResult>;
|
|
@@ -21,8 +21,13 @@ export interface CrawlAnomaly {
|
|
|
21
21
|
* disagreeing about the same click). The second kind is why an autonomous crawl is worth more
|
|
22
22
|
* than a human clicking the same buttons: a person sees only the screen, so a UI that advanced
|
|
23
23
|
* while its write failed looks like success to them and always will.
|
|
24
|
+
*
|
|
25
|
+
* `CrawlAnomalyKind` and `ContradictionKind` are what THIS package puts here, and both are still
|
|
26
|
+
* enforced at the emit sites below. The type is a plain string because a rule registered by a
|
|
27
|
+
* consumer contributes kinds this package has never heard of, and a closed union here would mean
|
|
28
|
+
* a consumer's private vocabulary had to ship in the free product to be reportable.
|
|
24
29
|
*/
|
|
25
|
-
kind:
|
|
30
|
+
kind: string;
|
|
26
31
|
/** The control that triggered it. */
|
|
27
32
|
ref: string;
|
|
28
33
|
desc: string;
|
package/dist/crawl/crawl.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { span } from '../trace.js';
|
|
2
|
-
import { ActionType, CRAWL_DEFAULTS, CrawlAnomalyKind,
|
|
2
|
+
import { ActionType, CRAWL_DEFAULTS, CrawlAnomalyKind, DANGEROUS_ACTION_CONFIRM_ARG, EventType, ReticleCommand, } from '@reticlehq/core';
|
|
3
3
|
import { crawlEmptyNote } from './crawl-empty.js';
|
|
4
4
|
import { parseInteractive, asRecord, asNumber, asString, sourceOf, } from '../tools/tools-helpers.js';
|
|
5
5
|
import { ReticleTool } from '../tools/tool-names.js';
|
|
@@ -86,6 +86,37 @@ async function countRevealed(session, opts, before) {
|
|
|
86
86
|
const known = new Set(before.map((i) => (i.ref !== '' ? i.ref : i.desc)));
|
|
87
87
|
return parseInteractive(tree).filter((i) => !known.has(i.ref !== '' ? i.ref : i.desc)).length;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The confirmation half of the dead-control rule. Kept separate so the rule above reads as one
|
|
91
|
+
* question, and so the second click gets its own window: reusing the first would let the first
|
|
92
|
+
* click's own (absent) activity decide the second, which is not a second sample at all.
|
|
93
|
+
*
|
|
94
|
+
* `true` means "silent both times" — the only state the anomaly is reported in. Anything that goes
|
|
95
|
+
* wrong here returns `false`: a confirmation that cannot run is not evidence, and the direction that
|
|
96
|
+
* costs a missed finding is better than the one that invents one.
|
|
97
|
+
*/
|
|
98
|
+
async function stillSilent(session, item, settleMs, opts, sleep) {
|
|
99
|
+
const since = session.elapsed();
|
|
100
|
+
session.beginAction?.(ReticleTool.CRAWL, { ref: item.ref, action: ActionType.CLICK });
|
|
101
|
+
try {
|
|
102
|
+
const again = await session.command(ReticleCommand.ACT, {
|
|
103
|
+
ref: item.ref,
|
|
104
|
+
action: ActionType.CLICK,
|
|
105
|
+
args: true === opts.confirmDangerous ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {},
|
|
106
|
+
});
|
|
107
|
+
await sleep(settleMs);
|
|
108
|
+
// A control that has GONE (the first click removed it) is not a dead control — it did something.
|
|
109
|
+
if (!again.ok || false === asRecord(again.result)['dispatched'])
|
|
110
|
+
return false;
|
|
111
|
+
return !session.eventsSince(since).some(isActivity);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
session.finishAction?.();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
89
120
|
export async function crawl(session, opts, sleep) {
|
|
90
121
|
const maxSteps = opts.maxSteps ?? CRAWL_DEFAULTS.MAX_STEPS;
|
|
91
122
|
const settleMs = opts.settleMs ?? CRAWL_DEFAULTS.SETTLE_MS;
|
|
@@ -194,14 +225,26 @@ export async function crawl(session, opts, sleep) {
|
|
|
194
225
|
0 === errs.length &&
|
|
195
226
|
!events.some(isActivity) &&
|
|
196
227
|
!legitimatelyInert(item.desc)) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
228
|
+
// CONFIRM IT. One silent sample is a sample, not a fact, and this is the strongest claim the
|
|
229
|
+
// crawler makes — "your button does nothing" is the finding a reader acts on immediately.
|
|
230
|
+
//
|
|
231
|
+
// Measured on a fixture whose FIXED twin has a working primary CTA: the crawl reported it dead
|
|
232
|
+
// on 2 of 3 runs, always a control the drive had already clicked successfully moments earlier.
|
|
233
|
+
// A false positive in this tier is the most expensive kind there is — it is the tier a verdict
|
|
234
|
+
// is built from, and the twin column is the whole claim this product makes.
|
|
235
|
+
//
|
|
236
|
+
// A second click costs one round trip on the rare control that looked dead, and nothing at all
|
|
237
|
+
// on every control that did not. A genuinely dead control is silent twice; the flake is not.
|
|
238
|
+
if (await stillSilent(session, item, settleMs, opts, sleep)) {
|
|
239
|
+
counts.deadControls += 1;
|
|
240
|
+
anomalies.push({
|
|
241
|
+
kind: CrawlAnomalyKind.DEAD_CONTROL,
|
|
242
|
+
ref: item.ref,
|
|
243
|
+
desc: item.desc,
|
|
244
|
+
detail: 'clicked TWICE and the app did nothing either time (no DOM/network/route/signal change)',
|
|
245
|
+
...source,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
205
248
|
}
|
|
206
249
|
}
|
|
207
250
|
// One more look, AFTER the clicks. The enumeration above happened before any of them, so anything
|
package/dist/ee/audit-log.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* ee feature follows: call assertEnterprise...) first, then do the privileged work. The body here is a
|
|
4
4
|
* stub — the real audit sink is the hosted control plane. It exists now to prove the gate.
|
|
5
5
|
*/
|
|
6
|
-
import { assertEnterprise } from '../license/license.js';
|
|
6
|
+
import { assertEnterprise, EnterpriseFeature } from '../license/license.js';
|
|
7
7
|
/** Record an audit event — gated. Throws EnterpriseLicenseError in production without a valid license. */
|
|
8
8
|
export function recordAuditEvent(event, ctx) {
|
|
9
|
-
assertEnterprise(
|
|
9
|
+
assertEnterprise(EnterpriseFeature.AUDIT_LOG, ctx);
|
|
10
10
|
return event; // stub: a real implementation persists to the enterprise audit store
|
|
11
11
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
|
-
import type {
|
|
2
|
+
import type { OwnContradiction } from './contradictions.js';
|
|
3
3
|
/** Contradictions for 2xx responses whose bodies report failure. One per offending call. */
|
|
4
|
-
export declare function findBodyFailures(events: readonly ReticleEvent[]):
|
|
4
|
+
export declare function findBodyFailures(events: readonly ReticleEvent[]): OwnContradiction[];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReticleEvent } from '@reticlehq/core';
|
|
2
|
+
import type { Contradiction, ContradictionOptions } from './contradictions.js';
|
|
3
|
+
/**
|
|
4
|
+
* Rules a CONSUMER adds to the contradiction pass, without editing the ones this package ships.
|
|
5
|
+
*
|
|
6
|
+
* Every rule in `contradictions.ts` is a pure fold over a recording — events in, findings out, no
|
|
7
|
+
* session, no clock, no IO. A service embedding this engine wants rules of exactly that shape for
|
|
8
|
+
* itself, and the only thing standing between it and one was that `findContradictions` folds over a
|
|
9
|
+
* list written into its own body. Appending to that list means editing this package, which means a
|
|
10
|
+
* fork, which means the consumer's rules and this package's rules become one tree to merge forever.
|
|
11
|
+
*
|
|
12
|
+
* A registry rather than a parameter on `findContradictions`, deliberately. There are six call sites
|
|
13
|
+
* and a consumer reaches none of them: the tools call the pass internally, and a tool handler is
|
|
14
|
+
* handed a deps bag, not options. Threading a parameter would mean touching every path a verdict can
|
|
15
|
+
* take — and would still miss the one inside `crawl`. Registration happens once, at boot, by whoever
|
|
16
|
+
* composed the process.
|
|
17
|
+
*
|
|
18
|
+
* This is not a public plugin API and should not be documented as one. It has a single intended
|
|
19
|
+
* consumer, which is a service we own, and its shape is free to change in lockstep with it.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* One consumer rule.
|
|
23
|
+
*
|
|
24
|
+
* Receives the app's OWN events — dev-tooling traffic is already removed, exactly as it is for the
|
|
25
|
+
* shipped rules. A second answer to "which events count" would be a second product.
|
|
26
|
+
*/
|
|
27
|
+
export type ContradictionFold = (events: readonly ReticleEvent[], options: ContradictionOptions) => readonly Contradiction[];
|
|
28
|
+
/**
|
|
29
|
+
* Add a rule to every verdict this engine produces. Returns the undo.
|
|
30
|
+
*
|
|
31
|
+
* The undo is not a convenience: without it a registry is process-global state that tests cannot
|
|
32
|
+
* clean up, so each one starts depending on the order it ran in.
|
|
33
|
+
*/
|
|
34
|
+
export declare function registerContradictionFold(fold: ContradictionFold): () => void;
|
|
35
|
+
/** What is registered right now. Exported so a guard can assert the registry is empty by default. */
|
|
36
|
+
export declare function registeredContradictionFolds(): readonly ContradictionFold[];
|
|
37
|
+
/**
|
|
38
|
+
* Run every registered rule, containing anything one of them throws.
|
|
39
|
+
*
|
|
40
|
+
* A registered fold is somebody else's code running inside every verdict path this package has. If a
|
|
41
|
+
* throw could propagate, one defect in a consumer's rule would take down `assert`, `act_and_wait`,
|
|
42
|
+
* `observe` and `crawl` at once — turning a missing finding into a dead engine. Contained and logged:
|
|
43
|
+
* the rules that did run still report, and the failure is visible rather than swallowed.
|
|
44
|
+
*/
|
|
45
|
+
export declare function runRegisteredFolds(events: readonly ReticleEvent[], options: ContradictionOptions): Contradiction[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { log } from '../log.js';
|
|
2
|
+
const FOLDS = [];
|
|
3
|
+
/**
|
|
4
|
+
* Add a rule to every verdict this engine produces. Returns the undo.
|
|
5
|
+
*
|
|
6
|
+
* The undo is not a convenience: without it a registry is process-global state that tests cannot
|
|
7
|
+
* clean up, so each one starts depending on the order it ran in.
|
|
8
|
+
*/
|
|
9
|
+
export function registerContradictionFold(fold) {
|
|
10
|
+
FOLDS.push(fold);
|
|
11
|
+
return () => {
|
|
12
|
+
const at = FOLDS.indexOf(fold);
|
|
13
|
+
if (at !== -1)
|
|
14
|
+
FOLDS.splice(at, 1);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** What is registered right now. Exported so a guard can assert the registry is empty by default. */
|
|
18
|
+
export function registeredContradictionFolds() {
|
|
19
|
+
return [...FOLDS];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Run every registered rule, containing anything one of them throws.
|
|
23
|
+
*
|
|
24
|
+
* A registered fold is somebody else's code running inside every verdict path this package has. If a
|
|
25
|
+
* throw could propagate, one defect in a consumer's rule would take down `assert`, `act_and_wait`,
|
|
26
|
+
* `observe` and `crawl` at once — turning a missing finding into a dead engine. Contained and logged:
|
|
27
|
+
* the rules that did run still report, and the failure is visible rather than swallowed.
|
|
28
|
+
*/
|
|
29
|
+
export function runRegisteredFolds(events, options) {
|
|
30
|
+
const found = [];
|
|
31
|
+
for (const fold of FOLDS) {
|
|
32
|
+
try {
|
|
33
|
+
found.push(...fold(events, options));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
log('contradiction_fold_failed', {
|
|
37
|
+
error: error instanceof Error ? error.message : String(error),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ContradictionKind, type ReticleEvent } from '@reticlehq/core';
|
|
2
|
+
import { type DeclaredNetFailure } from './declared.js';
|
|
2
3
|
/**
|
|
3
4
|
* The contradiction hunter.
|
|
4
5
|
*
|
|
@@ -18,7 +19,16 @@ import { ContradictionKind, type ReticleEvent } from '@reticlehq/core';
|
|
|
18
19
|
* Pure: a window of events in, findings out. No session, no IO, no clock.
|
|
19
20
|
*/
|
|
20
21
|
export interface Contradiction {
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Typed `string`, not `ContradictionKind`, because the vocabulary is open at the EDGE.
|
|
24
|
+
*
|
|
25
|
+
* `ContradictionKind` enumerates what THIS package's rules emit, and every one of them is still
|
|
26
|
+
* checked against it — see `OwnContradiction`, which is what the folds below build. A rule
|
|
27
|
+
* registered by a consumer emits kinds this package has never heard of and must not have to add
|
|
28
|
+
* them here to be reportable: a shared enum is exactly how a consumer's private vocabulary ends up
|
|
29
|
+
* shipped in the free product by accident.
|
|
30
|
+
*/
|
|
31
|
+
kind: string;
|
|
22
32
|
/** What one channel asserted — the optimistic half. */
|
|
23
33
|
claim: string;
|
|
24
34
|
/** What the other channel asserted — the half that contradicts it. */
|
|
@@ -26,6 +36,15 @@ export interface Contradiction {
|
|
|
26
36
|
/** Concrete evidence, so the agent can go straight to the call or the control. */
|
|
27
37
|
detail: string;
|
|
28
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* A contradiction emitted by one of THIS package's rules — the kind is closed.
|
|
41
|
+
*
|
|
42
|
+
* Widening `Contradiction.kind` to `string` for the consumer seam would otherwise have made every
|
|
43
|
+
* emit site below accept a typo'd literal. This keeps them checked without closing the edge.
|
|
44
|
+
*/
|
|
45
|
+
export type OwnContradiction = Contradiction & {
|
|
46
|
+
kind: ContradictionKind;
|
|
47
|
+
};
|
|
29
48
|
export interface ContradictionOptions {
|
|
30
49
|
/** The action that opened this window, when one did. Enables the no-effect check. */
|
|
31
50
|
action?: string | undefined;
|
|
@@ -63,5 +82,36 @@ export interface ContradictionOptions {
|
|
|
63
82
|
* test, which is weaker but never wrong in the direction of a false accusation.
|
|
64
83
|
*/
|
|
65
84
|
mutatedWithin?: number | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Requests the CALLER declared would fail, read off the oracle it wrote before acting.
|
|
87
|
+
*
|
|
88
|
+
* `ui-advanced-request-failed` cannot tell "the app swallowed the error and carried on" from "the
|
|
89
|
+
* app rendered the error, which is the behaviour under test" — both are a moved DOM beside a
|
|
90
|
+
* failed request. `failureAcknowledged` recovers the first from the app's own state, and an app
|
|
91
|
+
* that renders its error straight into the DOM without touching a store defeats it.
|
|
92
|
+
*
|
|
93
|
+
* The declaration is the missing evidence, and it costs no new API: an agent verifying an error
|
|
94
|
+
* path already writes `{ net, POST, /api/login, status: 500 }` into the predicate. Measured in the
|
|
95
|
+
* field: every branch of a login error path (500, 401, 503) passed every declared clause and every
|
|
96
|
+
* run still returned `verified: "no" / contradicted`, so error handling — empty states, offline
|
|
97
|
+
* banners, 4xx/5xx messaging, lockouts — was the code least able to reach a green verdict and the
|
|
98
|
+
* most worth verifying.
|
|
99
|
+
*
|
|
100
|
+
* Scoped as narrowly as it can be: it suppresses ONLY the heuristic rule that cannot see the
|
|
101
|
+
* difference. A success signal fired over the declared failure still contradicts, and a server
|
|
102
|
+
* fault blamed on the user is still misattributed — see the negative controls in
|
|
103
|
+
* `contradictions.declared.test.ts`.
|
|
104
|
+
*/
|
|
105
|
+
expectedFailures?: readonly DeclaredNetFailure[] | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* The caller declared an on-screen consequence and it HELD in this window.
|
|
108
|
+
*
|
|
109
|
+
* `route-rendered-nothing` infers a blank destination from the absence of DOM events, and a
|
|
110
|
+
* verdict that names it beside an element match — heading found, with its source file and line —
|
|
111
|
+
* is a clause its own evidence disproves. Positive evidence outranks the absence it is inferred
|
|
112
|
+
* from. Undefined/false leaves the rule exactly as it was, which is the case that catches a route
|
|
113
|
+
* with no view.
|
|
114
|
+
*/
|
|
115
|
+
renderProved?: boolean | undefined;
|
|
66
116
|
}
|
|
67
117
|
export declare function findContradictions(allEvents: readonly ReticleEvent[], options?: ContradictionOptions): Contradiction[];
|
|
@@ -4,6 +4,8 @@ import { findBodyFailures } from './body-failures.js';
|
|
|
4
4
|
import { findEchoMismatches } from './echo-mismatch.js';
|
|
5
5
|
import { findUnitMismatches } from './unit-mismatch.js';
|
|
6
6
|
import { asNumber, asString } from '../tools/tools-helpers.js';
|
|
7
|
+
import { matchesDeclaredFailure } from './declared.js';
|
|
8
|
+
import { runRegisteredFolds } from './contradiction-folds.js';
|
|
7
9
|
function netCall(e) {
|
|
8
10
|
const status = asNumber(e.data['status']);
|
|
9
11
|
return {
|
|
@@ -171,7 +173,7 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
171
173
|
// Needs body capture; silent without it, which is why the assert path also declares when bodies
|
|
172
174
|
// were never recorded rather than letting an unread payload read as an empty one.
|
|
173
175
|
found.push(...findBodyFailures(events));
|
|
174
|
-
found.push(...findEchoMismatches(events));
|
|
176
|
+
found.push(...findEchoMismatches(events, options.actionSince));
|
|
175
177
|
// ── A money value written back at the wrong SCALE ───────────────────────────────────────────
|
|
176
178
|
found.push(...findUnitMismatches(events, options.prior ?? []));
|
|
177
179
|
// ── The action landed on something that does not react ──────────────────────────────────────
|
|
@@ -192,6 +194,9 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
192
194
|
];
|
|
193
195
|
}
|
|
194
196
|
const failed = settled.filter((c) => false === c.ok);
|
|
197
|
+
// The failures NOBODY declared — see ContradictionOptions.expectedFailures. Only the heuristic
|
|
198
|
+
// "the UI moved while a request failed" rule reads this; the sharp rules still read `failed`.
|
|
199
|
+
const unexpected = failed.filter((c) => !matchesDeclaredFailure(c, options.expectedFailures ?? []));
|
|
195
200
|
const advanced = uiAdvanced(events);
|
|
196
201
|
const signals = events
|
|
197
202
|
.filter((e) => e.type === EventType.SIGNAL)
|
|
@@ -215,7 +220,7 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
215
220
|
e.type === EventType.DOM_REMOVED ||
|
|
216
221
|
e.type === EventType.DOM_TEXT);
|
|
217
222
|
const fetched = events.some((e) => e.type === EventType.NET_REQUEST || e.type === EventType.NET_PENDING);
|
|
218
|
-
if (routed && !rendered && !fetched) {
|
|
223
|
+
if (routed && !rendered && !fetched && true !== options.renderProved) {
|
|
219
224
|
found.push({
|
|
220
225
|
kind: ContradictionKind.ROUTE_RENDERED_NOTHING,
|
|
221
226
|
claim: 'the app navigated to a new route',
|
|
@@ -251,7 +256,19 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
251
256
|
// claimed success" about an app that plainly reported a failure is true in outline and wrong in
|
|
252
257
|
// its reasoning, which is how a checker stops being believed.
|
|
253
258
|
const successSignals = signals.filter((name) => !ACKNOWLEDGED.test(name));
|
|
254
|
-
|
|
259
|
+
// An app that RETRACTED has not claimed success, whenever it fired the optimistic signal.
|
|
260
|
+
//
|
|
261
|
+
// The weaker UI rule below already consulted this and the sharper signal rule did not, so an app
|
|
262
|
+
// that announced "ack:requested", met a 500, and then correctly emitted "ack:failed" and rolled the
|
|
263
|
+
// row back was reported as explicitly asserting success — the strongest accusation this file makes,
|
|
264
|
+
// against code doing exactly the right thing. On a fixture suite the scenario's FIXED twin produced
|
|
265
|
+
// the same finding as the build that swallowed the failure, which makes the finding worthless on
|
|
266
|
+
// the one measurement that scores precision.
|
|
267
|
+
//
|
|
268
|
+
// Ordering cannot decide this: an optimistic UI legitimately fires its success signal BEFORE the
|
|
269
|
+
// response, so "the claim must follow the failure" would miss the real defect. What separates them
|
|
270
|
+
// is not when the app spoke, it is whether it took it back.
|
|
271
|
+
if (failed.length > 0 && successSignals.length > 0 && !failureAcknowledged(events)) {
|
|
255
272
|
found.push({
|
|
256
273
|
kind: ContradictionKind.SIGNAL_CONTRADICTED,
|
|
257
274
|
claim: `the app fired ${successSignals.map((s) => `"${s}"`).join(', ')}`,
|
|
@@ -259,12 +276,12 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
259
276
|
detail: failed.map(describe).join('; '),
|
|
260
277
|
});
|
|
261
278
|
}
|
|
262
|
-
else if (
|
|
279
|
+
else if (unexpected.length > 0 && advanced && !misattributed && !failureAcknowledged(events)) {
|
|
263
280
|
found.push({
|
|
264
281
|
kind: ContradictionKind.UI_ADVANCED_REQUEST_FAILED,
|
|
265
282
|
claim: 'the UI moved forward (DOM/store/route changed)',
|
|
266
|
-
counter: `${String(
|
|
267
|
-
detail:
|
|
283
|
+
counter: `${String(unexpected.length)} request(s) in the same window failed`,
|
|
284
|
+
detail: unexpected.map(describe).join('; '),
|
|
268
285
|
});
|
|
269
286
|
}
|
|
270
287
|
// ── A write succeeded and nothing on the client moved ───────────────────────────────────────
|
|
@@ -337,5 +354,7 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
337
354
|
});
|
|
338
355
|
}
|
|
339
356
|
}
|
|
340
|
-
|
|
357
|
+
// Consumer rules run LAST and over the same app-only window, so a service embedding this engine
|
|
358
|
+
// adds to the verdict rather than forking the file that produces it.
|
|
359
|
+
return [...found, ...runRegisteredFolds(events, options)];
|
|
341
360
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the caller DECLARED before acting — the oracle, read back.
|
|
3
|
+
*
|
|
4
|
+
* `act_and_wait` asks the agent to name the expected consequence BEFORE the action, and that
|
|
5
|
+
* declaration was then thrown away: everything downstream judged the window as if nobody had said
|
|
6
|
+
* what they were expecting. Two measured consequences, one bug:
|
|
7
|
+
*
|
|
8
|
+
* - An error path can be declared exactly — `{ net, POST, /api/login, status: 500 }` — and the
|
|
9
|
+
* contradiction hunter still reported `ui-advanced-request-failed`, because a failed request plus
|
|
10
|
+
* a moved DOM is all it can see. The failure was EXPECTED and said so in the predicate, so a
|
|
11
|
+
* verdict of "channels disagree" is a statement about a disagreement nobody had.
|
|
12
|
+
* - A destination whose content was asserted and FOUND was still reported as
|
|
13
|
+
* `route-rendered-nothing`, a clause the element evidence beside it disproves.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately conservative about what counts as declared. Only what the caller REQUIRED is read:
|
|
16
|
+
* the top level and `allOf` chains. An `anyOf` branch may never have held, and a `not` declares the
|
|
17
|
+
* opposite of a consequence — honouring either would suppress a real finding on the strength of
|
|
18
|
+
* something that never happened. Pure: a predicate in, a description out.
|
|
19
|
+
*/
|
|
20
|
+
import type { Predicate } from './predicate-eval.js';
|
|
21
|
+
/** A failing call the caller named in advance — matched against the window's real calls. */
|
|
22
|
+
export interface DeclaredNetFailure {
|
|
23
|
+
method?: string;
|
|
24
|
+
urlContains?: string;
|
|
25
|
+
status?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface DeclaredExpectations {
|
|
28
|
+
/** Requests the caller declared would FAIL, so failing is the expected outcome, not a disagreement. */
|
|
29
|
+
netFailures: readonly DeclaredNetFailure[];
|
|
30
|
+
/** The caller required something to be ON SCREEN — an element or text, present rather than absent. */
|
|
31
|
+
rendersContent: boolean;
|
|
32
|
+
}
|
|
33
|
+
export declare function declaredExpectations(predicate: Predicate | undefined): DeclaredExpectations;
|
|
34
|
+
/**
|
|
35
|
+
* Does a call the window recorded match a failure the caller declared?
|
|
36
|
+
*
|
|
37
|
+
* Every field the caller named must agree — a declaration about `POST /api/login → 500` says nothing
|
|
38
|
+
* about `POST /api/orders`, and treating it as a blanket amnesty for failed requests would remove
|
|
39
|
+
* the check rather than inform it. Fields the caller left out are not constraints.
|
|
40
|
+
*/
|
|
41
|
+
export declare function matchesDeclaredFailure(call: {
|
|
42
|
+
method: string;
|
|
43
|
+
url: string;
|
|
44
|
+
status: number | undefined;
|
|
45
|
+
}, declared: readonly DeclaredNetFailure[]): boolean;
|