@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/cli.js
CHANGED
|
@@ -22,10 +22,12 @@ import { installDaemonResilience, installProxyResilience, recordExitReason, Daem
|
|
|
22
22
|
import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
|
|
23
23
|
import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/heartbeat.js';
|
|
24
24
|
import { everServedToolCall } from './daemon/daemon-usefulness.js';
|
|
25
|
-
import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, openInBrowser, } from './cli/cli-launch.js';
|
|
25
|
+
import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, drivePortConflict, openInBrowser, openCommand, } from './cli/cli-launch.js';
|
|
26
26
|
import { handleVerify } from './cli/cli-verify.js';
|
|
27
|
+
import { runKill } from './cli/cli-kill.js';
|
|
27
28
|
import { summarizeHunt } from './hunt/hunt-report.js';
|
|
28
29
|
import { runInit } from './init/run.js';
|
|
30
|
+
import { confirmInstall, nodeConfirmDeps } from './init/confirm.js';
|
|
29
31
|
import { handleDoctor } from './cli/cli-doctor.js';
|
|
30
32
|
import { buildNodeIo } from './init/node-io.js';
|
|
31
33
|
import { describeLicense } from './license/license.js';
|
|
@@ -38,6 +40,7 @@ import { reportCliRun } from './telemetry/cli-telemetry.js';
|
|
|
38
40
|
export { parseCliArgs, CLI_USAGE };
|
|
39
41
|
function handleInit(parsed) {
|
|
40
42
|
const cwd = process.cwd();
|
|
43
|
+
const io = buildNodeIo(cwd);
|
|
41
44
|
const result = runInit({
|
|
42
45
|
cwd,
|
|
43
46
|
port: parsed.port,
|
|
@@ -45,9 +48,14 @@ function handleInit(parsed) {
|
|
|
45
48
|
dryRun: parsed.dryRun,
|
|
46
49
|
install: parsed.install,
|
|
47
50
|
...(parsed.app === undefined ? {} : { app: parsed.app }),
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
// The outcome is reported by confirmInstall instead, once it knows whether an app connected —
|
|
52
|
+
// `init` writing files was never the same thing as `init` working (#269).
|
|
53
|
+
deferOutcome: true,
|
|
54
|
+
}, io);
|
|
55
|
+
void confirmInstall(result, io, nodeConfirmDeps(parsed.port ?? RETICLE_DEFAULT_PORT)).then(() => {
|
|
56
|
+
if (!result.ok)
|
|
57
|
+
process.exit(1);
|
|
58
|
+
});
|
|
51
59
|
}
|
|
52
60
|
function handleServe(parsed) {
|
|
53
61
|
void serveWithHonestExit(parsed);
|
|
@@ -132,6 +140,10 @@ async function serveWithHonestExit(parsed) {
|
|
|
132
140
|
/** How long `serve` waits for the child to bind. Generous: a cold daemon start is seconds. */
|
|
133
141
|
const SERVE_BIND_TIMEOUT_MS = 15_000;
|
|
134
142
|
const PRESENCE_POLL_MS = 150;
|
|
143
|
+
/** How long a daemon gets to shut down on its own before `stop` stops asking politely. */
|
|
144
|
+
const GRACEFUL_STOP_MS = 5000;
|
|
145
|
+
/** How long after SIGKILL before we accept the process is not ours to kill and say so. */
|
|
146
|
+
const FORCED_STOP_MS = 2000;
|
|
135
147
|
async function waitForPresence(port, want, timeoutMs) {
|
|
136
148
|
const deadline = Date.now() + timeoutMs;
|
|
137
149
|
for (;;) {
|
|
@@ -153,22 +165,61 @@ function handleStop(port, quiet) {
|
|
|
153
165
|
}
|
|
154
166
|
process.kill(pid, 'SIGTERM');
|
|
155
167
|
const started = Date.now();
|
|
168
|
+
let escalated = false;
|
|
156
169
|
const poll = setInterval(() => {
|
|
157
170
|
if (!isAlive(pid)) {
|
|
158
171
|
clearInterval(poll);
|
|
159
172
|
removePid(port);
|
|
160
173
|
if (!quiet)
|
|
161
|
-
log('reticle_daemon_stopped', { port, pid });
|
|
174
|
+
log('reticle_daemon_stopped', { port, pid, ...(escalated ? { escalated } : {}) });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const waited = Date.now() - started;
|
|
178
|
+
// A daemon that ignores SIGTERM is WEDGED — which is the one situation `stop` exists for. Giving
|
|
179
|
+
// up here left the port held, the pid file stale, and a human running `kill -9` by hand at the
|
|
180
|
+
// first two minutes of setup. Escalate instead: graceful first, then unconditional.
|
|
181
|
+
if (!escalated && waited > GRACEFUL_STOP_MS) {
|
|
182
|
+
escalated = true;
|
|
183
|
+
try {
|
|
184
|
+
process.kill(pid, 'SIGKILL');
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Already gone between the liveness check and here — the next tick reports it stopped.
|
|
188
|
+
}
|
|
162
189
|
return;
|
|
163
190
|
}
|
|
164
|
-
if (
|
|
191
|
+
if (waited > GRACEFUL_STOP_MS + FORCED_STOP_MS) {
|
|
165
192
|
clearInterval(poll);
|
|
193
|
+
// Surviving SIGKILL means the pid is not ours to kill (permissions) or is an unkillable
|
|
194
|
+
// zombie. Neither is retryable, and both need the pid named so a human can act on it.
|
|
166
195
|
if (!quiet)
|
|
167
|
-
log('reticle_daemon_stop_timeout', { port, pid });
|
|
196
|
+
log('reticle_daemon_stop_timeout', { port, pid, escalated });
|
|
168
197
|
process.exit(1);
|
|
169
198
|
}
|
|
170
199
|
}, 100);
|
|
171
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* `reticle restart` — free the port, then put a daemon back on it and prove it bound.
|
|
203
|
+
*
|
|
204
|
+
* The two halves are reported separately on purpose. A restart that killed the old daemon and then
|
|
205
|
+
* could not start a new one leaves the user in a different place from one that could not kill
|
|
206
|
+
* anything at all, and a single `ok: false` cannot tell them which happened.
|
|
207
|
+
*/
|
|
208
|
+
async function handleRestart(port, force) {
|
|
209
|
+
const freed = await runKill(port, force);
|
|
210
|
+
if (!freed) {
|
|
211
|
+
log('reticle_restart_aborted', {
|
|
212
|
+
port,
|
|
213
|
+
reason: 'the port was not freed, so nothing was started — the old holder is still there',
|
|
214
|
+
});
|
|
215
|
+
process.exit(1);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Reuses `serve`'s path, which waits for the daemon to ANSWER before claiming it started and exits
|
|
219
|
+
// non-zero when it does not. A restart that reports success for a daemon that never bound is the
|
|
220
|
+
// same lie `serve` already stopped telling.
|
|
221
|
+
await serveWithHonestExit({ port, headless: true, http: false });
|
|
222
|
+
}
|
|
172
223
|
function handleStatus(port) {
|
|
173
224
|
const pid = readPid(port);
|
|
174
225
|
if (null === pid || !isAlive(pid)) {
|
|
@@ -343,8 +394,12 @@ function handleOpen(requestedPort, url) {
|
|
|
343
394
|
log('reticle_open', {
|
|
344
395
|
port,
|
|
345
396
|
error: `could not launch a browser: ${launchError}`,
|
|
346
|
-
|
|
347
|
-
|
|
397
|
+
// Points at the OS handler, not at `reticle doctor`. Doctor's browser check is about
|
|
398
|
+
// Reticle's own Chromium, which this command never touches — the same misdirect the
|
|
399
|
+
// no-session note below spells out at length, left standing here for one more release.
|
|
400
|
+
recovery: 'Nothing was opened. This command asks the OS to open a url in your default browser ' +
|
|
401
|
+
`(\`${openCommand(decision.url, process.platform).cmd}\` on this platform) and that ` +
|
|
402
|
+
'failed, so the fix is on the OS side: open the url yourself, or set a default browser.',
|
|
348
403
|
});
|
|
349
404
|
process.exit(1);
|
|
350
405
|
return;
|
|
@@ -360,11 +415,21 @@ function handleOpen(requestedPort, url) {
|
|
|
360
415
|
...(connected
|
|
361
416
|
? {}
|
|
362
417
|
: {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
418
|
+
// Two claims used to live here that this command cannot support. It said the browser
|
|
419
|
+
// "was launched" — it asked the OS to open a URL and cannot see whether a window
|
|
420
|
+
// appeared — and it offered "the app may still be loading" as a cause with equal
|
|
421
|
+
// weight to the real one. It then pointed at `reticle doctor`, whose Chromium check is
|
|
422
|
+
// about a browser THIS command never uses, so a missing Chromium reads as the
|
|
423
|
+
// explanation for a session that is missing for an unrelated reason. Both misdirects
|
|
424
|
+
// were reported from the field, each costing several calls of app-side wiring hunt.
|
|
425
|
+
note: `the URL was handed to the system default browser (this command does not use ` +
|
|
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.`,
|
|
368
433
|
}),
|
|
369
434
|
});
|
|
370
435
|
})
|
|
@@ -420,7 +485,7 @@ function handleDaemonInner(parsed) {
|
|
|
420
485
|
// The reason rides out on the session summary, which is the only event that fires at this
|
|
421
486
|
// exact moment. The proxy emits the matching `mcp_connection_lost` and cannot know it —
|
|
422
487
|
// it sees a socket end and nothing more, which is how a scheduled idle exit came to make up
|
|
423
|
-
//
|
|
488
|
+
// the large majority of "outages". See SessionSummary.exit.
|
|
424
489
|
.shutdown(reason)
|
|
425
490
|
.then(() => server.close())
|
|
426
491
|
.then(() => {
|
|
@@ -529,8 +594,13 @@ function handleMcp(opts) {
|
|
|
529
594
|
daemonArgs.push(HTTP_TOKEN_FLAG, httpToken);
|
|
530
595
|
}
|
|
531
596
|
spawnDaemon(process.execPath, scriptPath, daemonArgs, port);
|
|
532
|
-
|
|
597
|
+
// Announce the daemon only once the PORT ACCEPTS. This line used to be written the instant the
|
|
598
|
+
// child was spawned, which on a Windows first bootstrap meant `reticle_mcp_daemon_started`
|
|
599
|
+
// followed about ten seconds later by `reticle_mcp_daemon_unavailable` and a first
|
|
600
|
+
// `reticle_sessions` that expired. A readiness signal that precedes readiness is worse than
|
|
601
|
+
// none: a client that believes it stops waiting for the thing that has not happened.
|
|
533
602
|
await waitForDaemon(port);
|
|
603
|
+
log('reticle_mcp_daemon_started', { port, ...(driveUrl !== undefined ? { driveUrl } : {}) });
|
|
534
604
|
};
|
|
535
605
|
// Start the proxy WHATEVER happened to the daemon.
|
|
536
606
|
//
|
|
@@ -571,20 +641,57 @@ function handleMcp(opts) {
|
|
|
571
641
|
});
|
|
572
642
|
}
|
|
573
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
|
+
});
|
|
574
682
|
const options = {
|
|
575
683
|
port: parsed.port,
|
|
576
684
|
driveUrl: parsed.driveUrl,
|
|
577
685
|
headless: parsed.headless,
|
|
578
686
|
};
|
|
579
|
-
|
|
580
|
-
|
|
687
|
+
try {
|
|
688
|
+
await start(options);
|
|
581
689
|
log('reticle_started', { port: parsed.port });
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
log('reticle_start_failed', { error: message });
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
log('reticle_start_failed', { error: error instanceof Error ? error.message : String(error) });
|
|
586
693
|
process.exit(1);
|
|
587
|
-
}
|
|
694
|
+
}
|
|
588
695
|
}
|
|
589
696
|
function main() {
|
|
590
697
|
// Before anything reads process.env — notably the telemetry gate and the bridge's security
|
|
@@ -638,6 +745,15 @@ function main() {
|
|
|
638
745
|
case 'stop':
|
|
639
746
|
handleStop(parsed.port, parsed.quiet);
|
|
640
747
|
break;
|
|
748
|
+
case 'kill':
|
|
749
|
+
void runKill(parsed.port, parsed.force).then((freed) => {
|
|
750
|
+
if (!freed)
|
|
751
|
+
process.exit(1);
|
|
752
|
+
});
|
|
753
|
+
break;
|
|
754
|
+
case 'restart':
|
|
755
|
+
void handleRestart(parsed.port, parsed.force);
|
|
756
|
+
break;
|
|
641
757
|
case 'status':
|
|
642
758
|
handleStatus(parsed.port);
|
|
643
759
|
break;
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { ReticleTool } from '../tools/tool-names.js';
|
|
3
3
|
import { asNumber, asString } from '../tools/tools-helpers.js';
|
|
4
4
|
import { crawl } from './crawl.js';
|
|
5
|
+
import { routeFromUrl, routesFromEvents } from '../project/learned-routes.js';
|
|
5
6
|
const nodeSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
7
|
/**
|
|
7
8
|
* The autonomous "smart monkey" tool. Builds on reticle_explore (which only LISTS) by
|
|
@@ -58,8 +59,10 @@ export const CRAWL_TOOLS = [
|
|
|
58
59
|
.describe('Present only when the page exceeded one snapshot, so controls past the cap were never listed. When present, a zero-anomaly result does NOT mean the app is clean.'),
|
|
59
60
|
truncated: z.boolean(),
|
|
60
61
|
},
|
|
61
|
-
handler: (deps, args) => {
|
|
62
|
+
handler: async (deps, args) => {
|
|
62
63
|
const session = deps.sessions.resolve(asString(args['sessionId']));
|
|
64
|
+
const since = session.elapsed();
|
|
65
|
+
const initialRoute = routeFromUrl(session.url);
|
|
63
66
|
const maxSteps = asNumber(args['maxSteps']);
|
|
64
67
|
const settleMs = asNumber(args['settleMs']);
|
|
65
68
|
const scope = asString(args['scope']);
|
|
@@ -69,7 +72,14 @@ export const CRAWL_TOOLS = [
|
|
|
69
72
|
...(scope !== undefined ? { scope } : {}),
|
|
70
73
|
...(true === args['confirmDangerous'] ? { confirmDangerous: true } : {}),
|
|
71
74
|
};
|
|
72
|
-
|
|
75
|
+
const report = await crawl(session, opts, nodeSleep);
|
|
76
|
+
const routes = [
|
|
77
|
+
...(initialRoute === undefined ? [] : [initialRoute]),
|
|
78
|
+
...routesFromEvents(session.eventsSince(since)),
|
|
79
|
+
];
|
|
80
|
+
if (routes.length > 0)
|
|
81
|
+
await deps.project.recordRoutes(routes);
|
|
82
|
+
return report;
|
|
73
83
|
},
|
|
74
84
|
},
|
|
75
85
|
];
|
package/dist/crawl/crawl.d.ts
CHANGED
|
@@ -39,6 +39,19 @@ export interface CrawlAnomaly {
|
|
|
39
39
|
}
|
|
40
40
|
/** Said out loud when the page was larger than one snapshot, so a clean sweep cannot mean "all clear". */
|
|
41
41
|
export declare const CAPPED_SNAPSHOT_NOTE = "the page exceeded one snapshot, so controls past the cap were never seen \u2014 interactiveFound is a floor, not a total; re-run with a narrower `scope` to cover the rest";
|
|
42
|
+
/**
|
|
43
|
+
* Said out loud when the crawl's OWN clicks put controls on the page that it then never visited.
|
|
44
|
+
*
|
|
45
|
+
* The enumeration happens once, before the first click, so a control that only exists after a login,
|
|
46
|
+
* a modal or a tab switch is invisible to this run. Measured on the bench app: a crawl of the login
|
|
47
|
+
* screen visited three controls, its click on "Sign in" produced seven more, and it returned
|
|
48
|
+
* `truncated: false` with an empty anomaly list and nine of twelve steps unused. Read as a sweep,
|
|
49
|
+
* that is a clean bill of health for an app the crawl never entered.
|
|
50
|
+
*
|
|
51
|
+
* The same words as the capped-snapshot case on purpose — it is the same idea (interactiveFound is a
|
|
52
|
+
* floor) reached by a different route, and a second vocabulary for it would just be more to learn.
|
|
53
|
+
*/
|
|
54
|
+
export declare function revealedControlsNote(count: number): string;
|
|
42
55
|
export interface CrawlReport {
|
|
43
56
|
interactiveFound: number;
|
|
44
57
|
stepsRun: number;
|
package/dist/crawl/crawl.js
CHANGED
|
@@ -6,6 +6,23 @@ import { ReticleTool } from '../tools/tool-names.js';
|
|
|
6
6
|
import { findContradictions } from '../events/contradictions.js';
|
|
7
7
|
/** Said out loud when the page was larger than one snapshot, so a clean sweep cannot mean "all clear". */
|
|
8
8
|
export const CAPPED_SNAPSHOT_NOTE = 'the page exceeded one snapshot, so controls past the cap were never seen — interactiveFound is a floor, not a total; re-run with a narrower `scope` to cover the rest';
|
|
9
|
+
/**
|
|
10
|
+
* Said out loud when the crawl's OWN clicks put controls on the page that it then never visited.
|
|
11
|
+
*
|
|
12
|
+
* The enumeration happens once, before the first click, so a control that only exists after a login,
|
|
13
|
+
* a modal or a tab switch is invisible to this run. Measured on the bench app: a crawl of the login
|
|
14
|
+
* screen visited three controls, its click on "Sign in" produced seven more, and it returned
|
|
15
|
+
* `truncated: false` with an empty anomaly list and nine of twelve steps unused. Read as a sweep,
|
|
16
|
+
* that is a clean bill of health for an app the crawl never entered.
|
|
17
|
+
*
|
|
18
|
+
* The same words as the capped-snapshot case on purpose — it is the same idea (interactiveFound is a
|
|
19
|
+
* floor) reached by a different route, and a second vocabulary for it would just be more to learn.
|
|
20
|
+
*/
|
|
21
|
+
export function revealedControlsNote(count) {
|
|
22
|
+
return (`the page changed as you clicked: ${String(count)} control(s) that did not exist when this crawl ` +
|
|
23
|
+
'started were never visited — interactiveFound is a floor, not a total; re-run the crawl from ' +
|
|
24
|
+
'the state you are now in to cover them');
|
|
25
|
+
}
|
|
9
26
|
/** Any buffer event that proves the app reacted to a click (vs a dead/no-op control). */
|
|
10
27
|
function isActivity(e) {
|
|
11
28
|
return (e.type === EventType.NET_REQUEST ||
|
|
@@ -50,6 +67,25 @@ export function legitimatelyInert(desc) {
|
|
|
50
67
|
return true;
|
|
51
68
|
return /\b(textbox|searchbox|combobox|spinbutton)\b/.test(desc);
|
|
52
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* How many interactive controls exist now that did not exist when the crawl enumerated.
|
|
72
|
+
*
|
|
73
|
+
* Keyed on ref, which is the element's identity: a control that re-rendered keeps its ref and is not
|
|
74
|
+
* new, and one that vanished simply does not appear. Returns 0 rather than throwing if the snapshot
|
|
75
|
+
* fails — this is a disclosure, and failing the whole crawl to report on coverage would be worse
|
|
76
|
+
* than the silence it is fixing.
|
|
77
|
+
*/
|
|
78
|
+
async function countRevealed(session, opts, before) {
|
|
79
|
+
const snap = await session.command(ReticleCommand.SNAPSHOT, {
|
|
80
|
+
mode: 'interactive',
|
|
81
|
+
...(opts.scope !== undefined ? { scope: opts.scope } : {}),
|
|
82
|
+
});
|
|
83
|
+
if (!snap.ok)
|
|
84
|
+
return 0;
|
|
85
|
+
const tree = (snap.result ?? {}).tree ?? '';
|
|
86
|
+
const known = new Set(before.map((i) => (i.ref !== '' ? i.ref : i.desc)));
|
|
87
|
+
return parseInteractive(tree).filter((i) => !known.has(i.ref !== '' ? i.ref : i.desc)).length;
|
|
88
|
+
}
|
|
53
89
|
export async function crawl(session, opts, sleep) {
|
|
54
90
|
const maxSteps = opts.maxSteps ?? CRAWL_DEFAULTS.MAX_STEPS;
|
|
55
91
|
const settleMs = opts.settleMs ?? CRAWL_DEFAULTS.SETTLE_MS;
|
|
@@ -168,6 +204,14 @@ export async function crawl(session, opts, sleep) {
|
|
|
168
204
|
});
|
|
169
205
|
}
|
|
170
206
|
}
|
|
207
|
+
// One more look, AFTER the clicks. The enumeration above happened before any of them, so anything
|
|
208
|
+
// the crawl itself unlocked — a dashboard behind a login, a modal's contents, a tab's panel — was
|
|
209
|
+
// never in `items` and never had a chance to be visited. Counted by ref against what was
|
|
210
|
+
// enumerated, so a control that merely moved or re-rendered under the same ref is not miscounted
|
|
211
|
+
// as new, and a surface that SHRANK (a modal closed) reports nothing, because nothing went
|
|
212
|
+
// unvisited. Skipped when the snapshot was already capped: that case has its own note and a second
|
|
213
|
+
// one would only muddy it.
|
|
214
|
+
const revealed = coverageCapped ? 0 : await countRevealed(session, opts, items);
|
|
171
215
|
return {
|
|
172
216
|
interactiveFound: items.length,
|
|
173
217
|
stepsRun,
|
|
@@ -177,8 +221,12 @@ export async function crawl(session, opts, sleep) {
|
|
|
177
221
|
// True when coverage was bounded for EITHER reason: the step budget ran out, or the page was
|
|
178
222
|
// bigger than one snapshot. Conflating "I stopped early" with "I saw everything" is what turns a
|
|
179
223
|
// partial sweep into "all controls healthy".
|
|
180
|
-
truncated: items.length > stepsRun || coverageCapped,
|
|
181
|
-
...(coverageCapped
|
|
224
|
+
truncated: items.length > stepsRun || coverageCapped || revealed > 0,
|
|
225
|
+
...(coverageCapped
|
|
226
|
+
? { coverageNote: CAPPED_SNAPSHOT_NOTE }
|
|
227
|
+
: revealed > 0
|
|
228
|
+
? { coverageNote: revealedControlsNote(revealed) }
|
|
229
|
+
: {}),
|
|
182
230
|
// A bare `stepsRun: 0` made "the page had no controls" and "it had 34 and none were clicked" the
|
|
183
231
|
// same answer. See crawl-empty.
|
|
184
232
|
...(() => {
|
|
@@ -42,8 +42,8 @@ const DISCONNECT_CODES = new Set([
|
|
|
42
42
|
/**
|
|
43
43
|
* Nobody went away — nobody was ever there. A refused connect is the daemon not being up YET.
|
|
44
44
|
*
|
|
45
|
-
* This is the difference that made `runtime_crashed` useless.
|
|
46
|
-
*
|
|
45
|
+
* This is the difference that made `runtime_crashed` useless. In the field **every crash event
|
|
46
|
+
* carried one fingerprint, `connect ECONNREFUSED`, and every affected user was in the
|
|
47
47
|
* never-verified set.** The proxy is BUILT to tolerate this — it answers the handshake from cache
|
|
48
48
|
* and wakes a daemon on the next request — so reporting it as a crash meant the one metric that is
|
|
49
49
|
* supposed to say "Reticle broke" said nothing but "the daemon had not booted yet", and a real crash
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
|
|
3
|
+
*
|
|
4
|
+
* The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
|
|
5
|
+
* `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
|
|
6
|
+
* default bridge URL says `localhost`. So on the platform that is most of our installs, and the
|
|
7
|
+
* browser that is most of that, the documented default cannot connect — and the error text names the
|
|
8
|
+
* daemon, containers and WSL, none of which is the cause.
|
|
9
|
+
*
|
|
10
|
+
* This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
|
|
11
|
+
* WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
|
|
12
|
+
* second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
|
|
13
|
+
* only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
|
|
16
|
+
* leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
|
|
17
|
+
* precondition for serving.
|
|
18
|
+
*/
|
|
19
|
+
/** IPv6 loopback. The other half of what `localhost` can mean. */
|
|
20
|
+
export declare const IPV6_LOOPBACK = "::1";
|
|
21
|
+
export interface LoopbackAlias {
|
|
22
|
+
/** False when the alias could not be bound — the daemon still serves IPv4 as before. */
|
|
23
|
+
opened: boolean;
|
|
24
|
+
close: () => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
|
|
28
|
+
*
|
|
29
|
+
* Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
|
|
30
|
+
* trust holds — the alias does not widen who may connect, only which address they may name.
|
|
31
|
+
*/
|
|
32
|
+
export declare function openLoopbackAlias(port: number): Promise<LoopbackAlias>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
|
|
3
|
+
*
|
|
4
|
+
* The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
|
|
5
|
+
* `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
|
|
6
|
+
* default bridge URL says `localhost`. So on the platform that is most of our installs, and the
|
|
7
|
+
* browser that is most of that, the documented default cannot connect — and the error text names the
|
|
8
|
+
* daemon, containers and WSL, none of which is the cause.
|
|
9
|
+
*
|
|
10
|
+
* This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
|
|
11
|
+
* WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
|
|
12
|
+
* second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
|
|
13
|
+
* only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
|
|
16
|
+
* leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
|
|
17
|
+
* precondition for serving.
|
|
18
|
+
*/
|
|
19
|
+
import net from 'node:net';
|
|
20
|
+
import { LOOPBACK_HOST } from '@reticlehq/core';
|
|
21
|
+
/** IPv6 loopback. The other half of what `localhost` can mean. */
|
|
22
|
+
export const IPV6_LOOPBACK = '::1';
|
|
23
|
+
/**
|
|
24
|
+
* Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
|
|
25
|
+
*
|
|
26
|
+
* Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
|
|
27
|
+
* trust holds — the alias does not widen who may connect, only which address they may name.
|
|
28
|
+
*/
|
|
29
|
+
export function openLoopbackAlias(port) {
|
|
30
|
+
/** Every socket this alias has open, so shutdown can end them instead of waiting them out. */
|
|
31
|
+
const live = new Set();
|
|
32
|
+
const server = net.createServer((inbound) => {
|
|
33
|
+
const outbound = net.connect(port, LOOPBACK_HOST);
|
|
34
|
+
live.add(inbound).add(outbound);
|
|
35
|
+
const drop = (socket) => {
|
|
36
|
+
live.delete(socket);
|
|
37
|
+
};
|
|
38
|
+
inbound.on('close', () => drop(inbound));
|
|
39
|
+
outbound.on('close', () => drop(outbound));
|
|
40
|
+
// A dead peer on either side must tear down the pair, not surface as an unhandled 'error'
|
|
41
|
+
// event — which in Node terminates the daemon this alias exists to make reachable.
|
|
42
|
+
inbound.on('error', () => outbound.destroy());
|
|
43
|
+
outbound.on('error', () => inbound.destroy());
|
|
44
|
+
inbound.pipe(outbound);
|
|
45
|
+
outbound.pipe(inbound);
|
|
46
|
+
});
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
/**
|
|
49
|
+
* Stop listening and END EVERYTHING, rather than draining.
|
|
50
|
+
*
|
|
51
|
+
* `server.close()` fires its callback only once every existing connection has gone, so a single
|
|
52
|
+
* held-open socket — a keep-alive, an SSE stream, exactly what this daemon serves — means the
|
|
53
|
+
* callback never fires. The daemon's shutdown chain awaits this, so an un-drained alias silently
|
|
54
|
+
* became a daemon that ignores SIGTERM: `reticle stop` timed out, the process stayed alive, and
|
|
55
|
+
* the port stayed held. Caught by the heartbeat spec, which is the one gate that watches a
|
|
56
|
+
* daemon actually die.
|
|
57
|
+
*
|
|
58
|
+
* A best-effort side listener must never be able to hold up the shutdown of the thing it exists
|
|
59
|
+
* to make reachable. We are exiting; ending these sockets is the correct outcome, not a
|
|
60
|
+
* compromise.
|
|
61
|
+
*/
|
|
62
|
+
const close = () => new Promise((done) => {
|
|
63
|
+
server.close(() => done());
|
|
64
|
+
for (const socket of live)
|
|
65
|
+
socket.destroy();
|
|
66
|
+
live.clear();
|
|
67
|
+
});
|
|
68
|
+
server.once('error', () => {
|
|
69
|
+
// Nothing was bound, so there is nothing to close — but the caller still gets a close it can
|
|
70
|
+
// call unconditionally, rather than a shape it has to branch on.
|
|
71
|
+
resolve({ opened: false, close: () => Promise.resolve() });
|
|
72
|
+
});
|
|
73
|
+
server.listen(port, IPV6_LOOPBACK, () => {
|
|
74
|
+
// Never the reason the process stays up: if the daemon is otherwise done, this must not be
|
|
75
|
+
// what keeps the event loop alive.
|
|
76
|
+
server.unref();
|
|
77
|
+
resolve({ opened: true, close });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -35,6 +35,20 @@ export interface ContradictionOptions {
|
|
|
35
35
|
* contain. See `findUnitMismatches` for the measured case this exists for.
|
|
36
36
|
*/
|
|
37
37
|
prior?: readonly ReticleEvent[] | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Event time at which the action that opened this window was dispatched — the attribution floor.
|
|
40
|
+
*
|
|
41
|
+
* `duplicate-request` claims "one user action was performed", and that claim is only sound over ONE
|
|
42
|
+
* action's window. `reticle_observe` takes a caller-supplied window that can be arbitrarily wide, so
|
|
43
|
+
* two legitimate separate saves to the same endpoint read as a double submit — and the finding then
|
|
44
|
+
* sat on every verdict for that tab. Undefined means nothing attributed the window to an action, and
|
|
45
|
+
* the rule stays silent rather than accusing an app of something nobody can show it did.
|
|
46
|
+
*
|
|
47
|
+
* ponytail: a NET_REQUEST is stamped when it COMPLETED, so a write dispatched before the action and
|
|
48
|
+
* landing after it counts as inside. Keying on the matching NET_PENDING would fix that; it has not
|
|
49
|
+
* been worth the second index.
|
|
50
|
+
*/
|
|
51
|
+
actionSince?: number | undefined;
|
|
38
52
|
/**
|
|
39
53
|
* DOM mutations observed INSIDE the target's own subtree, as the act tool measured them.
|
|
40
54
|
*
|
|
@@ -281,23 +281,32 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
281
281
|
});
|
|
282
282
|
}
|
|
283
283
|
}
|
|
284
|
-
// ── The same write fired more than once
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
284
|
+
// ── The same write fired more than once, inside ONE action's window ─────────────────────────
|
|
285
|
+
// Attribution is the whole rule here, not a refinement of it: counting `method + url` over whatever
|
|
286
|
+
// window the caller handed in turns two legitimate separate saves into a double submit. See
|
|
287
|
+
// ContradictionOptions.actionSince.
|
|
288
|
+
const actionSince = options.actionSince;
|
|
289
|
+
if (actionSince !== undefined) {
|
|
290
|
+
const writeCounts = new Map();
|
|
291
|
+
for (const event of events) {
|
|
292
|
+
if (event.type !== EventType.NET_REQUEST || event.t < actionSince)
|
|
293
|
+
continue;
|
|
294
|
+
const call = netCall(event);
|
|
295
|
+
if (!isMutating(call))
|
|
296
|
+
continue;
|
|
297
|
+
const key = `${call.method} ${call.url}`;
|
|
298
|
+
writeCounts.set(key, (writeCounts.get(key) ?? 0) + 1);
|
|
299
|
+
}
|
|
300
|
+
for (const [key, count] of writeCounts) {
|
|
301
|
+
if (count < 2)
|
|
302
|
+
continue;
|
|
303
|
+
found.push({
|
|
304
|
+
kind: ContradictionKind.DUPLICATE_REQUEST,
|
|
305
|
+
claim: 'one user action was performed',
|
|
306
|
+
counter: `the same write fired ${String(count)} times`,
|
|
307
|
+
detail: `${key} ×${String(count)}`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
301
310
|
}
|
|
302
311
|
// ── The UI advanced over a request that never came back ─────────────────────────────────────
|
|
303
312
|
// Gated on the UI having moved: an in-flight request while the app is still visibly waiting is
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ElementState, PredicateKind, type ElementQuery, type ReticleEvent } from '@reticlehq/core';
|
|
1
|
+
import { ElementState, PredicateKind, type ElementDescriptor, type ElementQuery, type ReticleEvent } from '@reticlehq/core';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
export type Predicate = {
|
|
4
4
|
kind: typeof PredicateKind.ELEMENT;
|
|
@@ -58,11 +58,34 @@ export type Predicate = {
|
|
|
58
58
|
kind: typeof PredicateKind.NOT;
|
|
59
59
|
predicate: Predicate;
|
|
60
60
|
};
|
|
61
|
+
export interface ResidualQueryChecks {
|
|
62
|
+
/** Dropped fields this side CAN check, as [field, wanted value] pairs. */
|
|
63
|
+
checks: [string, string][];
|
|
64
|
+
/** Dropped fields with no descriptor to check them against — refuse rather than ignore. */
|
|
65
|
+
unusable: string[];
|
|
66
|
+
}
|
|
61
67
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
68
|
+
* Split a query's dropped fields into the ones the server can still enforce and the ones it cannot.
|
|
69
|
+
*
|
|
70
|
+
* The alternative — refuse every dropped field — breaks calls that work today and that our own
|
|
71
|
+
* cheatsheet advertises (`{ role: "button", text: "Save" }`), and breaks them into no verdict at all.
|
|
72
|
+
* Enforcing what we can and refusing only the rest keeps those calls working AS WRITTEN, which is the
|
|
73
|
+
* outcome the caller was already assuming.
|
|
64
74
|
*/
|
|
75
|
+
export declare function residualQueryChecks(query: ElementQuery): ResidualQueryChecks;
|
|
76
|
+
/** Does this element satisfy every field the locator dropped? */
|
|
77
|
+
export declare function satisfiesResiduals(element: ElementDescriptor, checks: readonly [string, string][]): boolean;
|
|
78
|
+
/** How the element's own reading of a dropped field should be REPORTED back on a failure. */
|
|
79
|
+
export declare function describeResidual(element: ElementDescriptor, field: string): string;
|
|
65
80
|
export declare const PredicateSchema: z.ZodType<Predicate>;
|
|
81
|
+
/**
|
|
82
|
+
* The fields a given predicate kind accepts, read off the schema itself.
|
|
83
|
+
*
|
|
84
|
+
* Derived rather than listed so the two can never disagree: a rejection message that names a stale
|
|
85
|
+
* field set is worse than one that names none, because the agent trusts it and retries into the same
|
|
86
|
+
* wall. Empty for a kind that is not in the union.
|
|
87
|
+
*/
|
|
88
|
+
export declare function predicateFieldsFor(kind: string): readonly string[];
|
|
66
89
|
export interface EvalResult {
|
|
67
90
|
pass: boolean;
|
|
68
91
|
/**
|