diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index ab283896..032b95d8 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -41,5 +41,24 @@ | `mobile_read_display_fps` | Read FPS intervals and connection status | | `mobile_read_performance_frames` | Read Android jank/pacing statistics and page through exact display frames | | `mobile_display_fps_close` | Stop FPS collection and release the tracing connection | +| `mobile_appflow_start` | Start a non-blocking structure build or screen sweep; Fast (the default) uses the deterministic runtime, Auto tries Deep Link → Fast, and Thorough tries Deep Link → Exploration | +| `mobile_appflow_status` | Read current or persisted App Flow status | +| `mobile_appflow_result` | Read the current or persisted screen graph | +| `mobile_appflow_events` | Poll buffered progress and manual-recording events past a sequence cursor (app only) | +| `mobile_appflow_pause` | Pause a running sweep at a safe boundary (app only) | +| `mobile_appflow_cancel` | Cancel a run and any parked agent decision | +| `mobile_appflow_launch_app` | Launch the selected project without starting a sweep (app only) | +| `mobile_appflow_resolve_prompt` | Resolve a human-action gate after the user acts (app only) | +| `mobile_appflow_projects` | List the project folders App Flow can open (app only) | +| `mobile_appflow_choose_project` | Select a listed project or open the system folder picker (app only) | +| `mobile_appflow_manual_start` | Start recording a path to one screen (app only) | +| `mobile_appflow_manual_act` | Perform and record one manual path action (app only) | +| `mobile_appflow_manual_stop` | Save the active manual path recording (app only) | +| `mobile_appflow_manual_cancel` | Discard the active manual path recording (app only) | +| `mobile_appflow_manual_replay` | Replay a saved path to one screen (app only) | +| `mobile_appflow_runtime_replay` | Navigate to one screen through the running app's navigator, no recording needed (app only) | +| `mobile_appflow_next_decision` | Long-poll for the next active-agent sweep decision (model only) | +| `mobile_appflow_submit_decision` | Submit one role-valid JSON decision and wait briefly for the next (model only) | +| `mobile_appflow_screenshot` | Read an authorized App Flow PNG (app only) | When reactivated, the `agent-device` MCP server exposes the pinned runtime's official operations through compact, validated schemas, including `open`, `snapshot`, `press`, `fill`, `type`, `scroll`, `wait`, `find`, `get`, `is`, `close`, and debugging tools. Their input schemas describe each command. The source [control skill](../skills/agent-device/SKILL.md), currently excluded from the package, explains session ordering and links to the version-matched guide. diff --git a/docs/telemetry.md b/docs/telemetry.md index b223bbd5..1f6e62c0 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -45,6 +45,16 @@ exclusive ownership of the measured memory. Samples uploaded before a renderer crash can show preceding heap growth; an abrupt crash may lose the final buffered samples and is not itself reported by this collection. +App Flow uses the `app-flow` UI surface. `ui.app_flow.start` measures the start request, `ui.app_flow.start_to_progress` ends on the first progress event, and `ui.app_flow.graph_process` measures local graph layout/derivation. Fixed complete/error counters record terminal outcomes. `app_flow.decision_wait.duration` measures time parked for the active Codex agent, with a fixed outcome; this time is excluded from the engine's exploration budget. Deterministic Fast additionally records `app_flow.runtime.connect` with a fixed ready/recovered/unavailable outcome (a run that adopts an already-launched app probes its Metro port once before the sweep and records that check on this same series; `recovered` means the dev server had to be restarted first), `app_flow.runtime.command` for the `navigate`, `readiness`, `nav-state` and `links` boundaries, and `app_flow.runtime.state` counts for its persistent CDP connection lifecycle. `nav-state` is a new `operation` on the existing `command` series rather than a new name, so the cost of reading navigator state over the persistent connection stays comparable with the CLI read it replaces. Fast also records `app_flow.runtime.settle` (the readiness poll loop's duration, with a fixed stop reason of settled/stable-not-quiet/timeout/budget/no-answer), `app_flow.runtime.verify` (the arrival check's duration, with a fixed runtime/judge path and the verdict), and `app_flow.runtime.navigate` counts with a fixed exact/nested/refused outcome. These are one measurement per operation: the settle loop's individual polls, which run every 40ms, are deliberately not metered. The per-run Fast log file (`/runs/fast-*.jsonl`) is local diagnostic output only and never feeds telemetry. These measurements describe plugin runtime work, not device rendering or app performance. While a sweep runs, the engine republishes the graph document on a bounded interval and the panel re-reads it on a similar one so captures appear without ending the run; status, decision, and result polling are excluded from MCP trace sampling. Prompts, responses, screenshots, source hashes, runtime fingerprints, project paths, run/request IDs, graph content, device identifiers, route names, params, and token data are never attached to these measurements. UI timing windows flush on surface changes and disposal; provider timers are cleared on submit, cancellation, suspension, and server teardown. + +App Flow's UI-state pass opens the UI states a screen's own source proves it can open, photographs each one and rolls the app back. It runs in two places that share one per-screen primitive and therefore one set of series: inside the screen sweep, for each delivered screen, when the panel's per-project "Capture UI states" switch is on (the sweep's start request carries one `captureUiStates` boolean, default off, and the pass reuses the sweep's own CDP connection, device and capture context), and as the standalone `mobile_appflow_explore_ui` tool, which opens its own connection to an already-launched app. Folding it into the sweep moved the measurements with it: they now describe the sweep's active path and carry the same `surface: 'app-flow'` attribution as the rest of the sweep. It records `app_flow.runtime.presentation`, one millisecond measurement per opened presentation with a closed `outcome` (`captured`/`failed`) and `kind` (`state`/`control`/`mount`) pair; `app_flow.runtime.presentation_pass`, one millisecond measurement per screen's pass with a closed `outcome` (`completed`/`failed`/`cancelled`) — a deliberate change of meaning: it previously covered one whole standalone run, and a sweep with the switch on now emits one per delivered screen, covering that screen's binding collection, listing, open/settle/screenshot/rollback of every state and the final unwind, never the sweep's own work around it; and `app_flow.runtime.presentation_unbound`, a single bounded count per screen's pass (the live-binding collection runs per screen, because bindings only exist for what is mounted) of the live bindings Metro's source maps could not resolve onto a source site (attribute `stage`: `configure` is the unplaced total; `no-frames`, `foreign-frames` and `symbolicate-failed` are emitted beside it only when non-zero and say which stage of the join stopped, as counts of records with no readable bundle frame, frames from another server, and failed Metro lookups; the join also matches a stack's bundle URL by port rather than origin, since the device reaches Metro as `127.0.0.1` while the host names it `localhost`, and the first symbolication timeout is 15 seconds and the setup command's 20 seconds, deliberate changes to what the join counts as a failure; the run log's `uiStates.join` carries the same stage counts plus how many records were emitted and how many matches the device attached, and `uiStates.failure` now names the stage a failed pass stopped in — `setup-timeout`, `setup-failed`, `list-timeout`, `list-failed` or `pass-failed` — instead of always `pass-failed`), counted once per screen's pass rather than once per binding; and `app_flow.runtime.presentation_refused`, a bounded count per screen's pass and closed-set `reason` (`unbound`, `not-in-view`, `disabled`, `no-owner`, `ambiguous-owner`, `no-control`, `ambiguous-control`, `not-loaded`, `unsupported`, `no-binding`, `ambiguous-binding`, `ambiguous-callback`, `unmet-condition`, `already-open`, `owner-mismatch`, `already-mounted`, `target`, `empty`, `other`) of presentations the device refused, with a `stage` of `list` or `open`, so a screen that offers nothing can be told apart from one whose controls could not be matched to their source; `presentation_unbound` now also counts opener-JSX records (the entry leg) the source join could not place, not only hook bindings, which is a deliberate widening of what it counts; and `app_flow.runtime.presentation_open_session`, one millisecond measurement per sweep (or run) for scanning the project's static catalog and attaching to the runtime, with a closed `outcome` (`ready`/`unavailable`), so the once-per-project cost is visible apart from the per-screen cost. A failure to capture UI states never fails the sweep; the unexpected ones (a transport failure mid-pass, an unwind that did not reach level 0) go through the server error reporter under the operation `app_flow.capture_ui_states`, while benign ones (no runtime to attach to, no UI states in the source) are counted as expected outcomes, and a failed unwind additionally stops further UI-state capture for the rest of that sweep. State openers are now gated on their opener JSX being live on the focused screen exactly as controls are, so `unbound` now means that an opener's JSX was not bound to a live fiber (previously it also covered a state with no binding, which is the new `no-binding` reason); this is a deliberate change of what the refusal counts mean, and the five new reasons are closed-set. Mount previews now carry the reference's two export guards and a drawn-content check, which add four more closed-set reasons: `owner-mismatch` (the named export is not the named component), `already-mounted` (the app already renders that component, so a second cold copy would show none of the live provider state), `target` (the expected component did not mount) and `empty` (it mounted but nothing under it draws). The last two come from a new `presentation-content` command, billed under `app_flow.runtime.command` like the others, which returns only three numbers and never reads app content. The settle after an open is now bounded at four seconds instead of inheriting the thirty-second route-settle bound: that bound is sized for a deep link resolving a whole route, and a presentation opens over an already-settled screen, so the full thirty seconds was being paid on every screen whose state drew nothing and the blank frame was then photographed anyway. Openers reaching the same destination are listed once, and the run log's `uiStates` record gains `aliased` (those duplicates, included in `candidates`) and `listedKinds` (listed counts by kind). A control's dismissal is now confirmed by the presented content leaving the committed tree rather than by the controller's shape, which never changed and so made every control close run to its four-second deadline and fail. The local run log (never sent anywhere) gains a per-node `uiStates` record of counts and closed-set categories (candidates, refused at list and at open by reason, opened, captured, unplaced records, whether the unwind reached level 0 and how a dismissal was confirmed, and whether that screen latched capture off). Its runtime handshake and its four device commands are billed to the existing `app_flow.runtime.connect` and `app_flow.runtime.command` series — the commands under new `operation` values `presentation-setup`, `presentation-configure`, `presentations`, `presentation-open` and `presentation-rollback` — so the cost of driving the app stays comparable with the Fast reach path beside it rather than arriving under new names. These three `presentation*` names are a deliberate re-addition after the removal recorded below, not a revival of the removed series: the pass they describe opens states by calling the app's own setters over an app that is already on a screen, it taps nothing and asks no model anything, and none of the removed `app_flow.runtime.state_capture` / `app_flow.states.*` names is reused. Dashboards built on those removed names stay silent. Screen names, node ids, route names, recipe ids, component names, source paths, screenshots and device identifiers are never attached to any of these; the per-variant detail stays in the project's own saved flow document, which never leaves the machine. The panel side of that pass adds one UI count, `ui.app_flow.capture_ui_states_start`, once per user-started run that has the "Capture UI states" switch on (it replaces the count the removed "Start UI Explore" button made, and carries nothing beyond the surface attributes), and one UI gauge, `ui.app_flow.ui_states_rendered`: how many captured UI-state rows the rendered graph offers in total, set once per node-set change on the `app-flow` surface. It is a plain count of rows, so no screen, route, node, recipe or component name, source path or screenshot ever rides on it, and it is separate from the "Captured N of M screens" headline, which counts screens. Like the three `presentation*` names above it is a new series under a new meaning, not a revival of the removed `ui.app_flow.states_rendered` / `ui.app_flow.state_viewed` pair: it measures what the graph renders after a pass, not what a removed runtime pass captured, and dashboards built on the removed names stay silent. The captured states now also have a canvas of their own — screens as parent nodes, their states as children — selected from the panel's view picker, and both canvases carry two jump pickers (by screen, by UI state) that move the viewport onto a chosen item. That canvas adds `ui.app_flow.ui_states_graph_process` and `ui.app_flow.ui_states_graph_render`, the same local layout and render boundaries `ui.app_flow.graph_process` and `ui.app_flow.graph_render` measure for the flow graph, and one gauge, `ui.app_flow.ui_state_nodes`, how many state cards it draws, set once per node-set change. The pickers add two fixed counts, `ui.app_flow.focus_screen` and `ui.app_flow.focus_ui_state`, one per jump. All five are plain numbers on the `app-flow` surface: which screen or state was chosen, its route, node, recipe or component name, its source path and its screenshot are never attached, so the counts say the pickers are used without saying what they were pointed at. + +App Flow's manual path recorder — the panel's "Record path" button — now reports three bounded series on the same `app-flow` surface and `device_platform` attribution as the sweep beside it. `app_flow.manual.start` is one millisecond measurement per started recording, from the request to the first live wireframe, with a closed `device` attribute of `launched` (the recorder adopted the app the user had already launched, so it paid no native build, simulator boot or Metro cold start), `warm` (it reused a device a previous recording or replay on this project parked) or `booted` (it opened its own). That attribute is the point of the series: it says whether reuse is actually happening and what a start costs on each path. `app_flow.manual.act` is one measurement per recorded interaction, covering the device call and the route settle behind it, with a closed `action` (`tap`/`fill`/`scroll`) and `outcome` (`ok`/`failed`) pair — a failed interaction is a normal state of this surface, not an exception, and it is counted here rather than reported. `app_flow.manual.stop` is one count per saved recording with a closed `outcome` of `verified` (the recording published and replayed) or `fallback` (it was saved as a literal command list), plus the same `device` attribute. All three are one measurement per operation, never one per snapshot poll: the recorder's two-second wireframe refresh is deliberately not metered. Screen names, node ids, routes, slugs, element labels, typed text, recorded commands, screenshots, project paths and device identifiers are never attached to any of them, so these say how long a recording took and how it ended without saying what was recorded. + +The App Flow runtime state-variant pass has been removed from the product, and with it the `app_flow.runtime.state_capture`, `app_flow.states.node` and `app_flow.states.phase` server series and the `ui.app_flow.states_rendered` and `ui.app_flow.state_viewed` UI measurements introduced in 0.1.141. This is a deliberate change of meaning rather than a rename: the pass they described no longer runs, no replacement series stands in for them, and dashboards or alerts built on those five names will go permanently silent from this release. The `states` App Flow progress phase and the `stateCaptureMode` start option are gone for the same reason. Every other App Flow series above — `app_flow.runtime.{connect,command,settle,verify,navigate,state}`, `app_flow.decision_wait.duration` and the `ui.app_flow.*` start/progress/graph measurements — keeps its existing name, unit, attributes and boundaries, so sweep coverage stays comparable across this release. + +Since 0.1.141, the `path` attribute on `app_flow.runtime.verify` covers a wider set of arrivals on the `runtime` side. Deterministic Fast now accepts the runtime's own readiness snapshot whenever that snapshot is complete — the target screen found and focused, its parameters matching, content present, and the dispatched navigator path focused — regardless of whether the readiness poll ended on `settled` or gave up on a timeout. Screens that never go quiet (live feeds) previously fell through to the vision judge and were counted as `judge`; they are now counted as `runtime`. The series name, unit (milliseconds), attribute names and verdict values are unchanged, so durations stay comparable across releases while the runtime/judge split shifts towards `runtime` at this release. The judge path remains for incomplete evidence. No new data is collected. + Since 0.1.140, `android.emulator.adb_reconnects` counts adb transport reconnects for emulators that stay unauthorized, with a fixed `trigger` (`boot` while waiting for an AVD, `selection` for an already running emulator). It uses the simulator surface and Android emulator context and sends no serials, AVD names or adb output. Existing Android startup diagnostics and MCP tool errors cover the boot outcome. Since 0.1.139, `ios.accessibility_bridge.restarts` counts restarts of the simulator's CoreSimulatorBridge after a describe-UI read reported no accessibility data, with a fixed `outcome` (`recovered`, `unavailable` or `failed`). Failed restarts also report a handled `simulator.accessibility_bridge` error. The existing `inspection.tool` errors, sampled MCP traces and `ui.annotations.inspection` timings cover the retried read. No device identifiers, accessibility content or command output are sent. @@ -359,7 +369,7 @@ Agent Device telemetry is inactive while its MCP entry is disabled; the active M | Node responsiveness | Automatic event-loop delay, utilization and process uptime. | | UI JavaScript heap | `ui.memory.heap_used`, `ui.memory.heap_total` and `ui.memory.heap_limit` in bytes at startup and every 30 seconds while visible, when Chromium's `performance.memory` is available. `ui.memory.supported` distinguishes unsupported contexts. These estimates do not measure total renderer or GPU memory. | | UI responsiveness | Browser tracing captures available web vitals. Custom metrics record visible animation-frame intervals, intervals over 50 ms, Event Timing interaction durations, long tasks and long animation frames where supported. | -| Product surfaces | Metrics carry `surface=simulator`, `logs`, `performance`, `recording` or `comparison`, plus view, visible device layout and monitoring state. Log filter time, buffered/filtered rows, performance batch processing, canvas draw time and time to first video frame help explain slow surfaces. | +| Product surfaces | Metrics carry `surface=app-flow`, `simulator`, `logs`, `performance`, `recording` or `comparison`, plus view, visible device layout and monitoring state. App Flow readiness/graph work, log filter time, buffered/filtered rows, performance batch processing, canvas draw time and time to first video frame help explain slow surfaces. | | Frame capture | `ui.screenshot.capture` measures synchronous canvas PNG encoding and base64 extraction in milliseconds for Select captures and physical iOS screenshots. `ui.screenshot.encode` measures PNG decoding, bounded JPEG encoding, and base64 extraction for chat attachments in milliseconds. Screenshot tools retain sampled MCP traces and report handled capture, attachment, and clipboard failures without image content. | | Storage | Every five minutes, the agent-device launcher measures its own session state directory and the shared Apple runner cache in bytes. It skips symlinks and sends only the storage kind and size. | | Usage | Surface views and visible time, tool action outcomes, log searches, attachments and send-to-chat actions are counted without their content. | @@ -417,3 +427,46 @@ logs, arbitrary names and credentials remain excluded. Collection uses the existing centralized Sentry initialization, environment and telemetry opt-out. Native rebuilds retain matching Baguette dSYMs, and release uploads continue to upload those symbols with the plugin release. + +A Fast screen sweep now runs with no decision provider unless the panel's per-project "Judge unclear screens with AI" switch is on, so the judge path described above is reachable in Fast only when that switch asked for it; with it off, a screen whose runtime evidence is incomplete is recorded unreached instead of being sent to the judge, and `app_flow.runtime.verify` emits no `judge` arrivals for that run. The series, its unit and its attributes are unchanged — the runtime/judge split simply shifts further towards `runtime` for Fast runs started with the switch off. The panel adds one UI count beside `ui.app_flow.capture_ui_states_start` and with the same shape, `ui.app_flow.judge_unclear_screens_start`, once per user-started run that has the switch on, so how often Fast pays for the decision loop is visible; it carries nothing beyond the shared surface attributes — no project path, screen, route or verdict. A Fast run with the switch off is also started straight from the panel rather than through a chat message, so it records `ui.app_flow.start` where a chat-started sweep records none; that is the existing split between the two start paths, not a new measurement. + +Since 0.1.141, deterministic Fast harvests route parameters from the link +elements in the app's React tree, one read per captured screen while any +uncaptured route still has an unresolved parameter. The read is billed to the +existing `app_flow.runtime.command` series under a new `operation` value, +`links`, so its cost stays comparable with the `nav-state` read beside it rather +than arriving as a separate name. `app_flow.runtime.links` counts the outcome of +each read with a fixed value: `usable` when at least one destination produced a +stored record, `unmatched` when destinations were read but none matched a +parameterised route, `empty` when the tree named none, and `failed` when the read +threw (which latches the harvest off for the rest of the sweep). Counts and +durations only: the harvested destinations themselves — route names, URL paths +and parameter values — are never attached, matching the parameter store's own +refusal to log values, and the per-screen detail stays in the local sweep log. +No new data leaves the machine. + +A screen node now offers a second replay variant, runtime replay, which puts the +app on that screen by dispatching through the app's own navigator with its parsed +route and the parameter values previous sweeps observed, instead of re-walking a +recorded path. It reuses deterministic Fast's own connection and render port, so +its handshake, its `navigate`, `readiness` and `nav-state` commands, its settle +and its navigate outcome are billed to the existing +`app_flow.runtime.{connect,command,settle,verify,navigate,state}` series with +their existing names, units and attributes — a replay's runtime cost is therefore +directly comparable with a sweep's, which is the point of sharing the transport +rather than writing a second one. Two names are new: +`app_flow.runtime_replay`, a bounded count per replay, and +`app_flow.runtime_replay.duration`, one millisecond measurement per replay +covering device preparation, the handshake, the dispatch and the settle. Both +carry a single closed-set `outcome`: `ok`, `no-map` and `no-node` (a panel +pointing at a map that has been rebuilt or cleared), `unresolved-params` (the +route needs a parameter no sweep has ever observed a value for, which is refused +rather than dispatched with a guessed value), `unavailable` (no reachable +development runtime), `refused` (a reachable runtime that declined the route) and +`failed`. The panel side adds `ui.app_flow.runtime_replay`, its own round-trip +duration, and a count of the same name carrying the same closed-set outcome. Node +ids, route names, URL paths, parameter names and parameter values are never +attached to any of them, matching the parameter store's own refusal to log +values; the route and the reason a dispatch was refused appear only in the local +console line and in the error shown on the node, neither of which leaves the +machine. No new data is collected. diff --git a/package-lock.json b/package-lock.json index bd6e876f..e38e4da5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@openai/mcp-extensions": "0.1.0", "@sentry/node": "11.2.0", "@sentry/react": "11.2.0", + "@xyflow/react": "^12.12.0", "ajv": "8.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -5148,6 +5149,15 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -5178,6 +5188,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", @@ -5199,6 +5215,25 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -5280,6 +5315,48 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz", + "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.83", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5665,6 +5742,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -6071,6 +6154,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -6126,6 +6231,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -6171,6 +6285,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -10665,6 +10814,34 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index c906332e..171e69f7 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@openai/mcp-extensions": "0.1.0", "@sentry/node": "11.2.0", "@sentry/react": "11.2.0", + "@xyflow/react": "^12.12.0", "ajv": "8.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/scripts/build.mjs b/scripts/build.mjs index 7270d743..e205da2a 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -138,7 +138,7 @@ const server = await build({ format: "esm", platform: "node", target: "node22", minify: false, legalComments: "eof", metafile: true, sourcemap: "external", plugins: [sentryEsbuildPlugin({ project: "codex-mobile-dev-server", telemetry: false, sourcemaps: { disable: true }, release: { inject: false, create: false, finalize: false } })], - banner: { js: "import { createRequire as mobileDevBundleRequire } from 'node:module'; const require = mobileDevBundleRequire(import.meta.url);" }, + banner: { js: "import { createRequire as mobileDevBundleRequire } from 'node:module'; import { fileURLToPath as mobileDevFileURLToPath } from 'node:url'; import { dirname as mobileDevDirname } from 'node:path'; const require = mobileDevBundleRequire(import.meta.url); const __filename = mobileDevFileURLToPath(import.meta.url); const __dirname = mobileDevDirname(__filename);" }, }); await mkdir(".sentry/server", { recursive: true }); for (const name of ["server.mjs", "agent-device-server.mjs", "ios-mirror-service.mjs"]) { diff --git a/skills/mobile-dev/SKILL.md b/skills/mobile-dev/SKILL.md index 87d2eca2..05ad183f 100644 --- a/skills/mobile-dev/SKILL.md +++ b/skills/mobile-dev/SKILL.md @@ -86,6 +86,21 @@ Selecting a log and clicking Attach to chat puts its message, stack, source, lev The host needs an Apple Silicon Mac and Xcode 26 or later with an iOS simulator runtime. The plugin uses Codex's bundled Node runtime. If the backend fails, read its error before retrying `mobile_start_baguette`. Keep logs on stderr because stdout carries MCP messages. +### App Flow + +Use App Flow to map an Expo Router or React Navigation project. `mobile_appflow_start` is non-blocking. Pass the project folder and either `structure-only` for an unattended route graph or `screen-ui-and-structure` for a simulator sweep with captures and runtime navigation evidence. + +For `screen-ui-and-structure`, keep servicing the same run until it reaches a terminal or human-action state: + +1. Call `mobile_appflow_next_decision` with the returned `runId`. A `waiting` result means local build or device work is still progressing; poll again. +2. For a `pending` request, follow its role instructions and history, inspect its inline screenshot when present, and return exactly one strict JSON object through `mobile_appflow_submit_decision` with the same `runId` and `requestId`. +3. A successful submit may contain the next request. Continue directly from it; call `next_decision` again only when the submit reports `waiting`. +4. Stop when status is completed, failed, cancelled, or suspended, or when the run reports `needs-user-action`. + +Decision JSON is role-specific. Auth classification uses `{ "verdict": "auth" | "clear" | "uncertain", "reason": string }`; arrival and navigation requests must follow their supplied schema; runtime-screen classification uses `{ "distinct": boolean, "key"?: string, "info"?: string }`; state-trigger planning returns actions, a state name, destination locator, and nullable note. Do not wrap the object in Markdown. + +An auth/review prompt is deliberately separate from the agent's screenshot classification. When the run needs user action, tell the user to log in or review the simulator and stop the decision loop. Never claim that the human action happened and never call `mobile_appflow_resolve_prompt` on the user's behalf. The App Flow panel owns Stop, Continue anyway, and Continue controls. A paused or inactivity-suspended sweep can be resumed by starting the same project again; its checkpoint preserves completed screens. + ### TraceSift profile investigations When the user provides a local CPU profile or React DevTools profiling export and asks for a root cause, call `mobile_analyze_profile` with its path. Leave `profileType` as `auto` unless the user or file format makes `cpu` or `react` explicit. The plugin reuses a compatible local TraceSift service or starts `tracesift start --no-open`; if setup is missing, report the returned `tracesift init`, installation, version, or platform guidance rather than substituting a different analyzer. diff --git a/src/app-flow/ad-script.ts b/src/app-flow/ad-script.ts new file mode 100644 index 00000000..cfe5ee71 --- /dev/null +++ b/src/app-flow/ad-script.ts @@ -0,0 +1,241 @@ +/** + * Locating and editing a single step inside a recorded `.ad` script. + * + * Pure and separate from `capture.ts` for the same reason `replay-divergence.ts` is: `capture.ts` + * reaches `electron` through `./storage` and cannot be imported under the test runner. What lives + * here decides which line of a verified recording gets rewritten, so it is worth testing. + * + * Step numbers in agent-device's divergence reports are 1-based over the lines it *executes*, which + * is every line except `context`, blanks and comments — the `# agent-device:target-v1` identity + * records among them. Step 5 of the measured social-app seeds is therefore script line 8, and + * nothing may assume the two numberings agree. + */ + +/** The `# agent-device:target-v1 {...}` comment that records the identity of the step below it. */ +const TARGET_COMMENT = '# agent-device:target-v1' + +function isExecutable(line: string): boolean { + const trimmed = line.trim() + return trimmed.length > 0 && !trimmed.startsWith('#') && !trimmed.startsWith('context ') +} + +/** The 0-based file line index of a 1-based execution step, or `undefined` if the script is shorter. */ +export function lineIndexOfStep(lines: readonly string[], step: number): number | undefined { + let seen = 0 + for (const [index, line] of lines.entries()) { + if (!isExecutable(line)) continue + seen += 1 + if (seen === step) return index + } + return undefined +} + +/** + * The selector of a `press` step, unescaped, or `undefined` for any other kind of step. + * + * Deliberately narrow: a press is the only step the recovery paths know how to re-perform or + * re-bind, and every divergence measured on social-app is one. + */ +export function pressSelectorAtStep(lines: readonly string[], step: number): string | undefined { + const index = lineIndexOfStep(lines, step) + if (index === undefined) return undefined + const match = /^press\s+"(.*)"$/.exec(lines[index].trim()) + return match ? match[1].replace(/\\(["\\])/g, '$1') : undefined +} + +/** + * Drop the recorded identity comment above a press step, leaving the press itself untouched. + * + * This is the last lever available for the failure that costs this sweep every seed but Home. The + * recorded identity is *correct about the button* — `matchCount 1`, no competing candidate, every + * field matching except where the button sits — and wrong only about the tree around it, which + * reads `application/"Blue Sky"` on one snapshot and `other/"Home"` on the next of the same screen. + * Record-and-heal cannot win that: it re-recorded the identical ancestry and the healed script + * failed at the same step. Removing the annotation removes the guard built from it, so the step + * resolves on its selector alone — which already matched one element and the right one. + * + * What is given up is narrow and worth naming: that press no longer re-verifies *which* element it + * found. The destination `wait` after it keeps its own identity record, so arrival is still proved; + * only the press's own structural assertion goes. Callers must therefore strip on the evidence of + * an ancestry-only mismatch and never as a way to quiet a `SELECTOR_MISS` or an ambiguous match, + * where it would turn a caught mistake into a silent wrong tap. + * + * Returns `undefined` when there is nothing to strip — the step is not a press, or it carries no + * identity comment — so a caller can tell "already stripped" from "stripped now" and stop looping. + */ +export function stripRecordedIdentity( + lines: readonly string[], + step: number +): string[] | undefined { + const index = lineIndexOfStep(lines, step) + if (index === undefined) return undefined + if (!/^press\s+"/.test(lines[index].trim())) return undefined + if (index === 0 || !lines[index - 1].trim().startsWith(TARGET_COMMENT)) return undefined + return [...lines.slice(0, index - 1), ...lines.slice(index)] +} + +/** The timeout recorded on agent-device's own selector waits, reused for the ones we insert. */ +const INSERTED_WAIT_TIMEOUT_MS = 15_000 + +/** The recorded `target-v1` payload above a step, or `undefined` when there is none to read. */ +export function identityAtStep( + lines: readonly string[], + step: number +): Record | undefined { + const index = lineIndexOfStep(lines, step) + if (index === undefined || index === 0) return undefined + const previous = lines[index - 1].trim() + if (!previous.startsWith(TARGET_COMMENT)) return undefined + try { + const parsed: unknown = JSON.parse(previous.slice(TARGET_COMMENT.length).trim()) + return typeof parsed === 'object' && parsed !== null + ? (parsed as Record) + : undefined + } catch { + return undefined + } +} + +/** + * The recorded ancestry of an identity, as comparable `role/label` keys. + * + * Keys rather than objects because this exists to answer one question — do two recorded identities + * hang off the same node — and the divergence that motivates it names that node by exactly these + * two fields (`recorded=application/"Blue Sky"`). + */ +function ancestryKeys(identity: Record | undefined): string[] { + const ancestry = identity?.ancestry + if (!Array.isArray(ancestry)) return [] + return ancestry.flatMap((raw) => { + if (typeof raw !== 'object' || raw === null) return [] + const entry = raw as Record + const role = typeof entry.role === 'string' ? entry.role : '' + const label = typeof entry.label === 'string' ? entry.label : '' + return [`${role}/${label}`] + }) +} + +/** The leading `id="…"` alternative of a press selector, or `undefined` when it does not lead with one. */ +function idClause(selector: string): string | undefined { + const first = selector.split('||')[0].trim() + return /^id="[^"]+"$/.test(first) ? first : undefined +} + +/** The `wait "" ` line agent-device would have recorded for this id clause. */ +function waitLineFor(clause: string): string { + return `wait "${clause.replace(/"/g, '\\"')}" ${INSERTED_WAIT_TIMEOUT_MS}` +} + +/** The previous executable line before a file index, or `undefined` at the start of the script. */ +function previousExecutable(lines: readonly string[], index: number): string | undefined { + for (let i = index - 1; i >= 0; i--) if (isExecutable(lines[i])) return lines[i].trim() + return undefined +} + +export interface UnguardedPresses { + readonly lines: string[] + /** 1-based steps, in the *input* numbering, whose recorded identity was dropped. */ + readonly stripped: number[] + /** 1-based steps, in the *input* numbering, that gained a selector wait in front of them. */ + readonly guarded: number[] +} + +/** + * Drop every recorded identity that hangs off the node a divergence just proved unstable, and give + * each unguarded press a selector wait in its place. + * + * Two changes to `stripRecordedIdentity`'s one-step-at-a-time behaviour, both measured on the + * 15-53-19 sweep, where this path cost 17s across two replays and still published nothing. + * + * **All at once, not one per replay.** The caller used to strip the reported step, replay (~8s), + * read the next report, strip again. But the instability is a property of a *node*, not of a step: + * `ProfileFollowers` recorded `application/"Blue Sky"` in the ancestry of both its presses and the + * replay rejected both, the second only after a round trip that could have been predicted from the + * first. So every press sharing an ancestry entry with the proven-unstable one is stripped in the + * same pass. Steps whose ancestry is disjoint keep their guard: the evidence is about one node, and + * widening past it would start swallowing the moved-or-relabelled control that record-and-heal is + * the right answer for. An identity with no recorded ancestry at all proves nothing shareable, so + * only the reported step is touched. + * + * **A wait where the guard was.** `stripRecordedIdentity`'s doc names what a strip gives up — the + * press stops re-verifying *which* element it found — and treats the destination wait as enough to + * cover it. For `ProfileFollowers` step 7 it was not: that press had no wait in front of it at all, + * so dropping its identity turned `IDENTITY_MISMATCH` into `COMMAND_FAILED`, the bare selector + * evaluated against a tree whose header was still `other/"Profile banner placeholder"` — the same + * placeholder the earlier mismatch had named. The identity record had been doing double duty as a + * *temporal* guard. Replacing it with the selector wait agent-device records for its own presses + * restores that, for the better reason: the press now waits for the control to exist rather than + * asserting the shape of the tree around it. + * + * Only a press leading with an `id="…"` alternative gets a wait, and only when the step before it + * is not already that wait — an id is the one part of a recorded selector that is not derived from + * volatile display text (`label="1 follower"`), and a wait on the rest would be a guess. + * + * Inserting a wait renumbers every later step, so the result is not a base for a second round of + * report-driven stripping: the caller replays it once and restores the original if it still fails. + * + * Returns `undefined` when `step` is not a press carrying an identity — the same "nothing to do" + * signal `stripRecordedIdentity` gives, so an already-stripped script cannot be processed twice. + */ +export function unguardPressSteps( + lines: readonly string[], + step: number +): UnguardedPresses | undefined { + if (!stripRecordedIdentity(lines, step)) return undefined + + const unstable = new Set(ancestryKeys(identityAtStep(lines, step))) + + // Collected as (step, lineIndex) pairs up front, because editing invalidates both numberings. + const targets: { step: number; index: number }[] = [] + let seen = 0 + for (const [index, line] of lines.entries()) { + if (!isExecutable(line)) continue + seen += 1 + if (!/^press\s+"/.test(line.trim())) continue + if (index === 0 || !lines[index - 1].trim().startsWith(TARGET_COMMENT)) continue + const shares = ancestryKeys(identityAtStep(lines, seen)).some((key) => unstable.has(key)) + if (seen === step || shares) targets.push({ step: seen, index }) + } + + // Descending, so each edit leaves the indices of the ones not yet applied untouched. + let next = [...lines] + const stripped: number[] = [] + const guarded: number[] = [] + for (const target of [...targets].reverse()) { + const selector = pressSelectorAtStep(lines, target.step) + const clause = selector ? idClause(selector) : undefined + const wait = clause ? waitLineFor(clause) : undefined + const alreadyGuarded = previousExecutable(lines, target.index - 1) === wait + + next = [ + ...next.slice(0, target.index - 1), + ...(wait && !alreadyGuarded ? [wait] : []), + ...next.slice(target.index) + ] + stripped.unshift(target.step) + if (wait && !alreadyGuarded) guarded.unshift(target.step) + } + + return { lines: next, stripped, guarded } +} + +/** + * The selector a published script's trailing destination guard waits on, or `undefined`. + * + * `saveAndReplayRecording` appends that guard as the last thing it records, so for any script that + * reached publication the final executable line is `wait "" 15000` on a non-chrome + * landmark of the destination. Reading it back is what lets a caller that did not record the + * script — the saved-replay rung, verifying it for the first time — hand the same selector to the + * recovery tiers, which need somewhere to wait after they rewrite a step. + * + * Deliberately only the *last* line. A script is full of intermediate waits, and any of them would + * be accepted by a looser search while proving nothing about arrival. + */ +export function destinationGuardSelector(lines: readonly string[]): string | undefined { + for (let i = lines.length - 1; i >= 0; i--) { + if (!isExecutable(lines[i])) continue + const match = /^wait\s+"(.*)"\s+\d+$/.exec(lines[i].trim()) + return match ? match[1].replace(/\\(["\\])/g, '$1') : undefined + } + return undefined +} diff --git a/src/app-flow/app-flow-normalize.ts b/src/app-flow/app-flow-normalize.ts new file mode 100644 index 00000000..9c8f42a0 --- /dev/null +++ b/src/app-flow/app-flow-normalize.ts @@ -0,0 +1,104 @@ +/** + * Back-compat normalization for a document read from `app-flow.json` — factored out of + * `readAppFlow` (`storage.ts`) purely so it has a unit test: `storage.ts` value-imports `electron`, + * which loads the instant anything imports that module, so this logic could not otherwise run under + * plain `node --test`. + * + * Every new field Step 4 added is defaulted here value-driven — following the pre-existing + * `node.urlPath ?? null` pattern this function also carries forward — rather than gated on + * `manifest.formatVersion`. A build that is interrupted mid-write can leave a document whose + * manifest already claims a v4 `formatVersion` but whose nodes/edges predate this round; defaulting + * on whether each field is actually present is correct in that case, where branching on the version + * number would not be. A v3 document normalized here becomes a v4 document with empty runtime + * state, which is exactly right: a v3 sweep observed none of it. + */ +import type { AppFlowResult } from '../shared/app-flow.ts' +import { + inferChromeKind, + looksLikeChromeCaption, + navigatorChrome, + rebuildChromeEdges +} from './reach/chrome-inventory.ts' + +export function normalizeAppFlow(result: AppFlowResult): AppFlowResult { + // Two repairs beyond the `kind` back-fill, both applied on read so a document written by an + // earlier sweep heals instead of carrying its damage into every consumer that opens it. + // + // Items are re-filtered through `looksLikeChromeCaption`. That guard did not exist when these + // documents were written, and without it a real sweep recorded 97 items for one tab bar — whole + // post bodies matched against node vocabulary by token overlap, so that a feed caption about + // budget legislation was persisted as the control that opens `Support`. Re-running the guard + // here costs a string length check per item and removes every one of them. + // + // Records are deduped by id because a chrome id doubles as a graph node id now + // (`chromeVirtualNodes`), and a document written before `sameChrome` settled identical ids can + // hold two records under one — a real sweep produced two `chrome:tabs:MessagesConversation`. + // First wins: the earlier sighting is the one whose provenance the id already describes. + const healed = dedupeById( + (result.chrome ?? []).map((chrome) => ({ + ...chrome, + kind: chrome.kind ?? inferChromeKind(chrome.reveal), + items: chrome.items.filter((item) => looksLikeChromeCaption(item.label)) + })) + ) + const allNodes = result.nodes.map((node) => ({ + ...node, + urlPath: node.urlPath ?? null, + reach: node.reach ?? (node.urlPath ? 'deep-link' : 'navigation-only'), + deepLinkRejection: node.deepLinkRejection ?? null, + origin: node.origin ?? 'parsed' + })) + // Which of the recorded surfaces the screens' own captures corroborate — see `navigatorChrome`. + // The `chrome` array itself keeps every record it ever had, honest observation history and all; + // this is only about which of them earn a node and edges, and it has to be recomputed on read + // because a document written before `navigatorChrome` existed has virtual nodes for surfaces that + // were never navigator chrome at all — a conversation's overflow menu persisted as "Tab bar". + const navigators = navigatorChrome(healed, allNodes) + const navigatorIds = new Set(navigators.map((chrome) => chrome.id)) + const nodes = allNodes.filter((node) => node.origin !== 'chrome' || navigatorIds.has(node.id)) + return { + ...result, + manifest: { + ...result.manifest, + formatVersion: Math.max(result.manifest.formatVersion ?? 1, 1) + }, + nodes, + edges: result.edges.map((edge) => ({ + ...edge, + origin: edge.origin ?? 'parsed' + })), + // Filtered to the surfaces the captures corroborate, exactly as the sweep's own assembly does + // (`orchestrator.ts`) — a document written before `navigatorChrome` existed carries records for + // context menus that were never navigator chrome, and they must not survive a read. + runtimeEdges: rebuildChromeEdges(result.runtimeEdges ?? [], navigators, nodes), + // Per-item, like `node.origin` above, not just defaulting the whole array to `[]`: a document + // can have a real, non-empty `chrome` array and still predate the `kind` field on each entry. + // `inferChromeKind` is the same reveal-length rule a fresh detection already uses, so a + // back-filled record compares identically under `sameChrome` (`reach/chrome-inventory.ts`) to one + // that carried `kind` from the start. + // Two repairs beyond the `kind` back-fill, both applied on read so a document written by an + // earlier sweep heals instead of carrying its damage into every consumer that opens it. + // + // Items are re-filtered through `looksLikeChromeCaption`. That guard did not exist when these + // documents were written, and without it a real sweep recorded 97 items for one tab bar — + // whole post bodies matched against node vocabulary by token overlap, so that a feed caption + // about budget legislation was persisted as the control that opens `Support`. Re-running the + // guard here costs a string length check per item and removes every one of them. + // + // Records are deduped by id because a chrome id doubles as a graph node id now + // (`chromeVirtualNodes`), and a document written before `sameChrome` settled identical ids can + // hold two records under one — a real sweep produced two `chrome:tabs:MessagesConversation`. + // First wins: the earlier sighting is the one whose provenance the id already describes. + chrome: healed, + navigation: result.navigation ?? null + } +} + +function dedupeById(records: T[]): T[] { + const seen = new Set() + return records.filter((record) => { + if (seen.has(record.id)) return false + seen.add(record.id) + return true + }) +} diff --git a/src/app-flow/armed-recording.ts b/src/app-flow/armed-recording.ts new file mode 100644 index 00000000..5ec22b2e --- /dev/null +++ b/src/app-flow/armed-recording.ts @@ -0,0 +1,104 @@ +/** + * The one `--saveScript` transaction a device may have open at a time, and the rule every other + * `apps.open` in this module has to obey while it is open. + * + * agent-device publishes a recording only if exactly one successful open belongs to it. A second + * one — any `apps.open` issued between the arming open and `sessions.saveScript` / + * `sessions.close({ saveScript })` — kills the transaction, and the publish that follows fails + * with `This script recording was aborted by a second successful open and cannot be published`. + * Nothing in the device API asks before opening, so the only way to keep that from happening is + * for the sites that can open the app to know a recording is armed. + * + * Measured, session_2026-10-13_11-35-39: six walks armed a recording and six of them landed on the + * dev-client launcher, because the arming open is `relaunch: true` and a dev client restarted + * cold comes up on its launcher. `waitForAppReady` then cleared the launcher the only way it can, + * with an `apps.open` of the bundle URL, and every one of those walks died at the publish: + * + * [ 355.322] ProfileFollowers explore seeded at /profile/:name <- recording armed here + * [ 357.987] dev-client launcher on screen with a recording armed — waiting up to 20.0s + * [ 379.530] apps.open — xyz.blueskyweb.app bluesky://expo-development-client/?url=... + * [ 403.843] walked edge — Profile -> ProfileFollowers <- arrival verified + * [ 406.114] ProfileFollowers ... aborted by a second successful open and cannot be published + * + * The same shape at t=430.6/456.2, t=481.0, t=566.7/623.8, t=649.3/679.7 and t=704.3/743.1. + * `MyProfile` — the one walk whose arming open did not land on the launcher, so the one walk with + * no recovery open in its armed window — is the only node in that sweep that published + * (`flows/myprofile.ad`, t=153.4). The `go home` lines sitting just ahead of five of those + * failures are *downstream* of them, not the cause: they are `discardRecordingCandidate`'s soft + * home, reached from the catch that the publish failure throws into (the log line carries no + * `recorded steps kept at …` suffix, which is the branch where the candidate file was never + * written because the transaction had already been aborted). + * + * Two different answers follow, because the two openers want different things: + * + * - `goHome` is suppressed outright while armed (`suppressesSoftHome`). It is a convenience — + * put the app back on a known screen after an abandoned walk — and its own doc comment already + * records that the next caller to need a transaction restarts the app anyway. Skipping it costs + * a cosmetically wrong starting screen; issuing it costs the script. + * + * - the dev-client launcher recovery cannot be suppressed: the app is not on screen, and waiting + * it out is already tried first and already measured as hopeless on this kind of device (see + * `launcher-deferral.ts`). So it opens, marks the transaction dead on its way past + * (`noteOpenWhileArmed`), and `startReplayRecording` re-arms afterwards (`needsRearm`) on top + * of the app the recovery has just loaded — a soft open this time, so there is no cold start, + * no launcher and therefore no third open to abort the replacement. + * + * Carried on `CaptureDevice` rather than threaded through arguments for the same reason + * `pendingEntryGate` is: arming happens in the orchestrator's exploration setup and the opens that + * can abort it happen several frames down inside `waitForAppReady`, and there is exactly one + * recording per device at a time. + */ +export type ArmedRecording = { + /** Candidate path the live transaction will publish to. Carried for the log line only. */ + readonly scriptPath: string + /** Set once an `apps.open` has been issued behind this transaction's back. */ + aborted: boolean +} + +/** A freshly armed transaction. Nothing has opened the app behind it yet, by construction. */ +export function armRecording(scriptPath: string): ArmedRecording { + return { scriptPath, aborted: false } +} + +/** + * Whether a soft `goHome` must be skipped right now. + * + * Only while a transaction is armed *and still alive*: once something has already aborted it there + * is no publication left to protect, and the cosmetic value of putting the app back on its launch + * screen is the only thing left on the table — so the home is allowed through again rather than + * suppressed for the rest of a doomed walk. + */ +export function suppressesSoftHome(armed: ArmedRecording | null | undefined): boolean { + return armed !== null && armed !== undefined && !armed.aborted +} + +/** + * Mark the armed transaction as aborted by an open that is about to be issued, and report whether + * this call is the one that did it. + * + * Called *before* the open rather than after, because the thing being recorded is a decision the + * caller has already made and the open itself may never return — a recovery that throws has still + * opened the app as far as agent-device is concerned. + * + * The return value drives one log line, which is why it is "did this call change anything" rather + * than "is it aborted": the launcher recovery can issue two opens (reconnect, then relaunch) for + * one armed recording, and saying so twice would read as two lost scripts. + */ +export function noteOpenWhileArmed(armed: ArmedRecording | null | undefined): boolean { + if (!armed || armed.aborted) return false + armed.aborted = true + return true +} + +/** + * Whether `startReplayRecording` has to replace the transaction it just armed before handing the + * walk over. + * + * True exactly when the readiness wait that follows the arming open had to open the app itself. + * The transaction is already unpublishable at that point, and the walk about to be recorded into + * it would run to a verified arrival and then throw it away — which is the 5-in-6 failure the + * measured sweep spent 683s producing. + */ +export function needsRearm(armed: ArmedRecording | null | undefined): boolean { + return armed !== null && armed !== undefined && armed.aborted +} diff --git a/src/app-flow/cancel.ts b/src/app-flow/cancel.ts new file mode 100644 index 00000000..e249726f --- /dev/null +++ b/src/app-flow/cancel.ts @@ -0,0 +1,4 @@ +/** Throws if the sweep has been cancelled, so a long step can bail between its await points. */ +export function checkSignal(signal?: AbortSignal): void { + if (signal?.aborted) throw new Error('Cancelled') +} diff --git a/src/app-flow/capture.ts b/src/app-flow/capture.ts new file mode 100644 index 00000000..b1f3bd60 --- /dev/null +++ b/src/app-flow/capture.ts @@ -0,0 +1,3340 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { setTimeout as sleep } from 'node:timers/promises' +import { existsSync } from 'node:fs' +import { readdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, isAbsolute, join } from 'node:path' +import type { AppFlowNode, AppFlowPressable, AppFlowStep, ExplorationMode } from '../shared/app-flow.ts' +import { errorMessage } from '../shared/protocol.ts' +import { + acceptOpenInAppPrompt, + isUrlSchemePreapproved, + markUrlSchemeApproved, + preapproveIosUrlScheme, + shouldPreapproveUrlScheme +} from '../server/app-flow-deps/ios/scheme-approval.ts' +import { retryWhileRunnerBusy } from '../server/app-flow-deps/ios/runner-busy.ts' +import { + bundlerCliArgs, + findAvailablePort, + isExpoProject, + isMetroReady, + isReactNativeCliScript, + parseMetroPortFromOutput +} from '../server/app-flow-deps/metro/port.ts' +import { beginSessionMetroLog, endMetroLogFile, pipeChildToMetroLog } from '../server/app-flow-deps/metro/logs.ts' +import { + devClientReconnectUrl, + fingerprint, + isDevClientLauncher, + isFrameUnchangedSinceAction, + isLoadingScreen, + needsDevClientReconnect, + similarity +} from './screen-signals.ts' +import { quoteCliArg } from './cli-args.ts' +import { agentDeviceOpenCommand } from './walk-commands.ts' +import { launcherDeferralMs, type LauncherDeferralOutcome } from './launcher-deferral.ts' +import { + armRecording, + needsRearm, + noteOpenWhileArmed, + suppressesSoftHome, + type ArmedRecording +} from './armed-recording.ts' +import type { Phase, PhaseLedger } from './timing.ts' +import { + indexDeepLinkPath, + isAmbiguousSelectorMessage, + describeSessionLoss, + isSessionLostMessage, + serializeLocator +} from './exploration.ts' +import { PROBE_FRAME_ELEMENT_FLOOR } from './reach/probe-route.ts' +import { describeReplayDivergence, parseStructuralOnlyMismatchStep } from './replay-divergence.ts' +import { unguardPressSteps } from './ad-script.ts' +import { getProjectDir } from './storage.ts' +import { computeNativeBuildFingerprint } from '../server/app-flow-deps/native-build.ts' +import { createAppFlowNativeBuildStore } from '../server/app-flow-deps/native-build-store.ts' +import type { NativeBuildStore } from '../server/app-flow-deps/native-build.ts' +import { parseInteractionTarget, toAgentDeviceTarget } from '../server/app-flow-deps/profiling/transition-target.ts' +import { appFlowAgentDeviceStateDir, appFlowDataDir, getAppFlowHost, resolveAgentDeviceCommand } from './host.ts' + +export interface CaptureContext { + projectPath: string + projectId: string + scheme: string | null + appName: string | null + bundleId: string | null + slug: string | null + platform: 'ios' + sessionName: string + signal?: AbortSignal + /** + * Where this sweep's wall clock is tallied, or absent to measure nothing. + * + * Optional, and carried on the context rather than held in a module-level singleton, because a + * manual capture session runs alongside a sweep with its own device and its own clock — a global + * would silently bill one run's reads to the other's summary. Every device primitive worth timing + * already takes `ctx`, so this reaches all of them without threading a new parameter. + */ + timing?: PhaseLedger +} + +const LAUNCH_TIMEOUT_MS = 10 * 60 * 1000 +/** + * How long the frame from before a launch is disbelieved — and, only when there is no such frame to + * compare against, a flat sleep before the first readiness poll. + * + * Deliberately shorter than the launch itself takes: it was never sized to cover a cold start, only + * to stop the very first accessibility read landing on a process that has not drawn a single frame + * yet. Everything beyond that is `waitForAppReady`'s stability poll, which has the full + * `APP_READY_TIMEOUT_MS` to work with and catches a slow start by construction. + * + * It is no longer usually spent at all. A relaunch is nearly always preceded by a successful read, + * so the poll can tell a frozen pre-launch frame from the app's first real one by comparing them, + * and a warm relaunch — where the first read is already the new screen — pays nothing. This value + * then bounds only how long an *unchanged* frame is held, which is the same window the sleep was + * approximating. Measured on a warm simulator run: the first poll after this wait already read a + * fully drawn screen, i.e. the whole thing was dead time on the common path. + */ +const APP_LOAD_WAIT_MS = 1_500 +const APP_READY_TIMEOUT_MS = 90 * 1000 +/** + * How long a frame that has *settled below* `minInteractiveElements` is given to grow before the + * wait gives up on the floor and returns. + * + * Separate from `timeoutMs` because the two bound different failures. `timeoutMs` is sized for a + * cold 6130-module bundle that has not drawn anything yet; this is sized for an app that has drawn + * something and the caller does not believe it. Collapsing them would mean a genuinely sparse + * first screen — a login form is three controls — holding the sweep for the full 90s to learn + * what the first settle already said. + * + * Twenty seconds, from two launches of the same app that disagreed: the first went substantive + * 12.5s after the poll first settled on a splash, the second about 18s after. Fifteen was set from + * the first alone and the second missed it by seconds — the wait gave up at 2 elements and the + * auth gate's own wait, running straight afterwards, found 20 elements in 1.7s. + * + * That near-miss is also why this is sized generously rather than tightly. Expiring here is a + * handoff, not a failure: `AUTH_GATE_FRAME_WAIT_MS` holds the identical floor immediately + * downstream, so the cost of being slightly short is a re-paid settle handshake, while the cost of + * being long is a genuinely sparse first screen — a login form is three controls — waiting this + * out before anyone looks at it. Both are small; the second is the one that scales with nothing, + * so the number sits above the measurements rather than between them. + */ +export const SUBSTANTIVE_FRAME_GRACE_MS = 20 * 1000 +/** Quiet window `startReplayRecording`'s recorded `wait stable` requires before it calls the UI + * settled — agent-device's own default, which is tuned for exactly this open/relaunch case. */ +const APP_STABLE_QUIET_MS = 500 +/** Ceiling for that same wait. Far below `APP_READY_TIMEOUT_MS` because this one is best-effort: + * when it gives up, `waitForAppReady` is still ahead of it with the full budget, so spending the + * whole 90s here would only delay the wait that can actually rescue a slow start. */ +const APP_STABLE_TIMEOUT_MS = 20 * 1000 +/** + * How long the recorded entry gate waits for the walk's first press target — see + * `pendingEntryGate` on `CaptureDevice`. + * + * Two clocks share this one number, which is the point of it. Live it resolves on the first check, + * because the gate is only ever issued against an element the planner just read off the screen, so + * it costs the walk nothing. At *replay* time the same line becomes the budget for a cold app to + * draw the screen that element is on, which is the entire reason the gate exists, so it cannot be + * short. 15s matches the destination guard `saveAndReplayRecording` appends at the other end of the + * script: the two are the same mechanism pointed at opposite ends of a journey, and there is no + * reason for them to disagree about how long an app gets to render a screen. + */ +const ENTRY_GATE_TIMEOUT_MS = 15 * 1000 +/** + * How long a dev-client launcher recovery is left to take effect before `waitForAppReady` + * escalates to the next move. + * + * Sized for the slow half of what the first move has to do: reconnecting tells the dev client to + * fetch and evaluate a debug bundle from Metro, which on a cold Metro is a full transform of the + * app, not a reload. Too short and the poll relaunches the app out from under a reconnect that was + * working; the cost of too long is only that a hopeless launcher is declared hopeless later, and + * `waitForAppReady`'s own deadline still bounds that. + */ +const LAUNCHER_RECOVERY_GRACE_MS = 30 * 1000 +/** + * Sleep *between* readiness polls — not the interval between them. + * + * The distinction is the whole reason this is as low as it is: a poll is a `capturePressables`, + * which measured 0.9–1.7s against a simulator, so the real spacing between two consecutive reads + * is this value plus that. At the old 1_500 the effective spacing was ~3.1s and the cheapest + * possible settle — two matching reads — cost ~4.6s on every screen, of which only the two reads + * were doing any work. The question a second read answers is "is this still the same screen", and + * the read's own latency already provides most of the separation that question needs. + */ +const APP_READY_POLL_MS = 400 +/** + * How alike two consecutive polls must be for the screen to count as settled. Matches the + * exploration loop's own "nothing changed" bar, so the two agree on what one screen looks like + * over time. + */ +export const APP_READY_SETTLE_SIMILARITY = 0.98 +/** + * How alike a settled frame must be to the frame from *before* the action for the settle to be + * disbelieved as stale. Deliberately far below `APP_READY_SETTLE_SIMILARITY`, because the two + * comparisons ask different questions and 0.98 answers the wrong one. + * + * 0.98 asks "is this the identical render". The stale guard's question is "is this still the + * screen we came from" — and a frame caught *mid-transition* is neither: it is a composite, the + * outgoing screen with some of the incoming one drawn over it. Measured on + * `session_2026-10-13_14-33-40.log`, ProfileFollowers: the pre-open frame was Profile's own + * delivery read at t=235.945 (30 controls), the settle at t=241.663 agreed twice on **46** + * controls, and the delivery read 0.9s later found **9** — the real Followers screen. A 46-control + * composite sitting over a 30-control profile clears 0.98 trivially (it is 16 controls richer), so + * the guard was live, compared, and waved the straddle through; the screenshot written to + * `screens/profilefollowers.png` is the MyProfile screen, verified by eye. + * + * 0.88 is set between the two things that have to stay apart. Above it: a composite that still + * carries the old screen's controls, which is what this must catch. Below it: two genuinely + * different screens, which must not be held — the same run's ProfileFollowers pair (46 mid-flight + * vs 9 settled) and PostLikedBy's (52 vs 8) share almost no vocabulary at all. Being wrong in the + * "hold" direction costs at most `ROUTE_STALE_GRACE_MS` of polling on a screen that had in fact + * arrived; being wrong the other way costs a whole wrong capture, which is what this run paid + * eight times. + */ +export const APP_READY_STALE_SIMILARITY = 0.88 +/** Consecutive failing accessibility reads after which `waitForAppReady` stops waiting. */ +const APP_READY_MAX_READ_FAILURES = 3 +/** + * The router's own transition beat: how long the pre-open frame is disbelieved after a deep link. + * + * Was a flat sleep in front of the settle poll, and that is the only thing it ever covered. It used + * to be sized as if it were the entire wait — 2.5s — which stopped being true once + * `settleAfterRoute` put the stability poll behind it. Trimming it to 800ms left the last of the + * problem in place: a deep link that has already landed by the time the poll starts still paid it, + * which was 11 of these per sweep in an eight-screen run, ~9s of nothing. + * + * Now the window is measured instead. The poll compares what it reads against the frame from + * before the open (`isFrameUnchangedSinceAction`), so an arrived screen settles on the first two + * agreeing reads and only a screen that still looks like where it came from waits — after which + * an unchanged frame is accepted, because a deep link to the screen the app is already on is a + * legitimate no-op. + * + * What is left here is only the flat post-*tap* sleep `waitForRoute` pays (see that function). The + * deep-link guard's ceiling moved to `ROUTE_STALE_GRACE_MS`, because 800ms turned out to be too + * short for the guard and raising it here would have charged every tap in the sweep for it. + */ +const ROUTE_WAIT_MS = 800 +/** + * The ceiling on the *stale-frame* guard after a deep link — `ROUTE_WAIT_MS`'s other job, split + * off from it because the two numbers are now sized by different measurements. + * + * `ROUTE_WAIT_MS` is still the flat sleep `waitForRoute` pays after a tap (~30 per sweep), where + * raising it is a pure cost. This is the budget `settleAfterRoute` gives the guard to disbelieve a + * frame that still looks like where we came from, where 800ms was demonstrably too short. + * Measured on `session_2026-10-13_14-33-40.log`, PostLikedBy: `apps.open` at t=458.609, + * `waitForAppReady` entered at t=459.188, and the first agreeing pair landed at t=460.369 — 1.18s + * into the poll, past an 800ms grace that had therefore already expired before the guard had a + * single pair to judge. The guard was not wrong there; it was never consulted. That settle agreed + * on 52 controls, which is exactly PostThread's own count from its delivery read at t=454.565 — + * the previous screen, photographed as `screens/postlikedby.png` while the real Liked By screen + * (8 controls) arrived 0.8s later. + * + * 2000ms covers that case with room: every one of the eight straddles in that run had its real + * screen in hand within ~1.0s of the first (wrong) agreeing pair. The cost is bounded and paid + * only by a screen that genuinely has not moved, and only until it does — see + * `ROUTE_STALE_MIN_REJECTED_PAIRS`, which is the half of this budget a fast machine cannot + * silently opt out of. + */ +const ROUTE_STALE_GRACE_MS = 2_000 +/** + * Agreeing pairs the stale guard must reject before the wall-clock grace above is allowed to run + * out on it. + * + * This exists because a millisecond budget alone is a guard that quietly disables itself on fast + * hardware: the faster the accessibility read, the more likely the first agreeing pair lands after + * the grace rather than inside it, which is precisely how PostLikedBy's guard came to be dead on + * arrival at 1.18s against 800ms. Counting rejections instead states the intent directly — "do not + * accept a frame that looks like where we came from until you have disbelieved it at least twice" + * — and is invariant to how quick the device is. + * + * Two, not one: one rejection plus a poll interval is ~0.6s on the measured device, which is + * inside the window the eight straddles landed in. Two takes the guard past it while costing a + * genuinely-unchanged screen (a deep link to the screen the app is already on) two extra reads at + * ~0.18s each before the grace clock even starts counting. + */ +const ROUTE_STALE_MIN_REJECTED_PAIRS = 2 +/** + * How recently a URL must have been opened for `settleAfterRoute` to measure the stale guard's + * grace from it rather than from the wait itself. Generously longer than the ~0.5s gap it exists + * to recover (see `staleClockStartedAt`) and far shorter than anything the sweep does between two + * screens, so the stamp is either this settle's own open or ignored. + */ +const ROUTE_OPEN_CLOCK_MAX_AGE_MS = 5_000 +/** + * The quiet window agent-device must observe before it calls a post-action UI settled. + * + * Its default is 500ms and it is a flat cost on every tap and every text entry — ~30 of them per + * sweep, which is where the `press` phase's 3.60s average largely comes from. 200ms is still + * several frames of stillness at 60Hz, which is what the window is for: distinguishing "the screen + * stopped changing" from "the screen is mid-transition". It is shortened rather than removed + * because the thing it guards against, reading the pre-action frame as the result of the action, + * is the failure that costs a whole wrong capture. + */ +const SETTLE_QUIET_MS = 200 +/** Bound on the post-deep-link settle poll — see `settleAfterRoute`. Far below + * `APP_READY_TIMEOUT_MS` because a deep link that has not resolved within this long is a failed + * deep link, not a slow one, and the ladder has an exploration rung waiting behind it. */ +const ROUTE_SETTLE_TIMEOUT_MS = 30 * 1000 +/** Budget for `openDeepLink`'s one-read liveness probe — see its call site. Short because the only + * answer it is waiting for is "the process is drawing", and anything slower than this is the cold + * start the full `waitForAppReady` behind it exists to handle. */ +const APP_ALIVE_PROBE_TIMEOUT_MS = 5 * 1000 +/** + * How old a read may be and still describe the frame the app was on *before* the caller's action. + * + * Deliberately far more generous than `APP_ALIVE_REUSE_MS`, which this was first written to share. + * That one answers "is the process alive right now", where a ten-second-old yes is worthless. This + * one answers "what was on screen before we acted", and nothing between the two reads touches the + * device — the gap is almost always a model call — so age does not make the answer wrong. + * + * The asymmetry is what makes a loose bound safe. A before-frame that has gone stale can only + * cause a *false hold*, it can never cause a false settle, and a false hold requires the stale + * frame to still match what is on screen — which means it is the same screen after all. The bound + * exists only to rule out a read from so long ago that the app may have been relaunched out from + * under it. + * + * Sharing the 10s bar cost two launch sleeps per sweep in a measured run: every wait that followed + * a model call found its read "too old" and fell back to sleeping, which is exactly the case the + * comparison handles best. + */ +const FRAME_COMPARISON_MAX_AGE_MS = 60 * 1000 + +/** + * How recently `capturePressables` must have succeeded for `openDeepLink` to take its word for it + * instead of spending a snapshot on the liveness probe. + * + * The probe asks one question — "is this process alive and drawing" — and a read that came back + * with interactive elements a moment ago has already answered it. On the sweep's hot path the + * previous screen's delivery read is always that answer, so the probe was re-asking something the + * harness had just been told, at ~0.9s of accessibility snapshot per screen. + * + * Bounded rather than unbounded because the thing it guards against is a *cold* process, and the + * gap this covers is local bookkeeping — choosing delivery sources, copying a frame, writing the + * checkpoint — not anything that can start the app over. Ten seconds is comfortably longer than + * that gap and far shorter than a launch, so a reused answer is never one that had time to stop + * being true; anything slower falls through and pays for the real read. + */ +const APP_ALIVE_REUSE_MS = 10 * 1000 +// Match post-install CLI output only. xcodebuild prints "BUILD SUCCEEDED" before +// simctl install; treating that as done kills `expo run:ios` too early. +const NATIVE_INSTALL_SUCCESS_RE = + /successfully launched the app|installed the app|opening on |opening .+ on /i +const EXPO_GO_APP_NAME = 'Expo Go' +const EXPO_GO_VERSION = '57.0.9' + +function log(step: string, detail?: string): void { + console.log(detail ? `[app-flow] ${step} — ${detail}` : `[app-flow] ${step}`) +} + +/** + * Times `work` against the sweep's ledger when one is attached, and otherwise just runs it. + * + * The `?? work()` fallback is what keeps `ctx.timing` optional at every call site instead of making + * each primitive branch on it: a standalone or test context measures nothing and pays nothing, and + * the primitive reads the same either way. + */ +export function measurePhase( + ctx: CaptureContext, + phase: Phase, + work: () => Promise +): Promise { + return ctx.timing?.measure(phase, work) ?? work() +} + +function agentDeviceBootCommand(): string { + return 'agent-device boot --platform ios --target mobile' +} + +function agentDeviceInstallCommand(app: string, appPath: string): string { + return `agent-device install ${quoteCliArg(app)} ${quoteCliArg(appPath)} --platform ios` +} + +// Moved to `walk-commands.ts`, the pure module the sweep's own `pathToReach` emission is built +// from — this line is one of the two command forms both producers write, and a second copy here is +// exactly how the two would drift. Re-exported so every existing `capture.ts` importer is +// unchanged. +export { agentDeviceOpenCommand } from './walk-commands.ts' + +type AgentDeviceClient = Awaited> +type LaunchMode = 'native-run' | 'expo-go' + +export interface CaptureDevice { + client: AgentDeviceClient + deviceId: string + deviceName: string + launchProcess: ChildProcess | null + metroPort: number + metroLogPath: string | null + launchTarget: string + launchUrl: string | null + /** + * The URL that tells an Expo dev client which Metro server to load, or `null` when this app is + * not one (Expo Go, which carries its bundle URL in `launchUrl`, or a plain React Native build). + * + * Only ever used to recover from the dev client's launcher screen. It is deliberately *not* + * `launchUrl`: `launchUrl` is the URL every ordinary launch and relaunch goes through, and + * routing normal launches through the dev-client URL would reload the bundle on every session + * recovery instead of just resuming the app that is already running. + */ + devClientUrl: string | null + useCustomScheme: boolean + setupCommands: string[] + schemePromptChecked: boolean + /** + * The most recent accessibility read that came back from this device, and when. Written by every + * successful `capturePressables`; read only by `openDeepLink`'s liveness probe, which is a + * question a recent enough read already answers — see `APP_ALIVE_REUSE_MS`. + * + * Deliberately not a cache of the *screen*: nothing may use this to decide what the app is + * showing, only whether its process is up and drawing. The sweep moves the app constantly, so by + * the time anything reads this the contents are usually a screen the app has already left. + */ + lastRead: { at: number; pressables: AppFlowPressable[] } | null + /** + * When this device last had a deep-link URL handed to it (`apps.open` returning, in + * `openDeepLink`), or null before the first one. + * + * Read by `settleAfterRoute` alone, as the moment the stale-frame guard's grace should be + * measured from. It is on the device rather than passed as an argument because the open and the + * settle are two separate calls made by two different modules — `openDeepLink` is a `DevicePort` + * method and `waitForRoute` is another, with the deep-link strategy in between — so there is no + * call frame that holds both. See `staleClockStartedAt` for the ~0.5s this recovers. + */ + lastRouteOpenAt: number | null + /** + * Set by `startReplayRecording`, consumed by the next `pressWithLocator`: the recording has just + * relaunched the app, so the press about to happen is the one that will run against a cold app + * when this script is replayed, and it gets a recorded `wait` on its own target first. + * + * Carried on the device rather than passed down because the two functions are several frames + * apart — recording starts in the orchestrator's exploration setup and the press happens inside + * whatever `executeNavigationActions` call the planner's first decision produces — and there is + * exactly one recording per device at a time, which is what makes a single flag sufficient. + */ + pendingEntryGate: boolean + /** + * Whether a launcher deferral has already run its full length on this device without the + * launcher clearing. Written by `waitForAppReady`, read by it on every later call to decide + * whether deferring again is still worth the wait — see `launcher-deferral.ts`. + */ + launcherDeferral?: LauncherDeferralOutcome + /** + * The `--saveScript` transaction currently open on this device, or null/absent while there is + * none. Written by `startReplayRecording` and cleared by every site that finalises or discards + * one; read by `goHome` and by `waitForAppReady`'s launcher recovery, which are the two places + * in this module that can issue the "second successful open" agent-device refuses to publish + * after — see `armed-recording.ts` for the measured sweep that made this necessary. + */ + armedRecording?: ArmedRecording | null +} + +/** + * The action shape the navigation model exchanges during exploration. This is exactly `AppFlowStep` + * (`shared/app-flow.ts`) — an alias, not a copy, so the persisted counterpart the reach pipeline writes + * into `app-flow.json` (a recorded chrome's `reveal` steps, plan Step 4d) and the in-flight action + * this module executes stay one type. Aliased here, rather than renamed at every call site, because + * `AppFlowStep` had to move to `shared/app-flow.ts` (the renderer reads persisted steps too) while this + * module's many call sites — `exploration.ts` imports `AppFlowNavigationAction` from here, not from + * `shared/app-flow.ts` — did not need to change for that move. + */ +export type AppFlowNavigationAction = AppFlowStep + +/** + * A locator good enough to guard a saved recording with. Narrower than `AppFlowNavigationAction`'s + * locator on purpose: it excludes `by: 'text'`, because agent-device only accepts a destination + * guard whose *resolved node* carries an id or an accessibility label — see + * `AppFlowNavigationDecision` below and `saveAndReplayRecording`. + */ +export interface AppFlowDestinationLocator { + by: 'label' | 'id' + value: string +} + +export type AppFlowNavigationDecision = + | { decision: 'act'; action: AppFlowNavigationAction; reason: string } + /** + * `destinationLocator` names a durable landmark on the screen the walk landed on, so the + * recording of that walk can be published. agent-device refuses to write a script that has no + * "portable destination guard" after its last mutating action: a `wait` on a selector whose + * matched element has a recorded identity it can re-verify at replay time. Without one, + * `sessions.saveScript` throws and the whole journey — already judged correct — is unpublishable, + * which is exactly what happened to every exploration-based capture in the device run this field + * was added for. It is optional because a missing or unusable locator must never cost us the + * arrival itself; the caller falls back to a landmark read off the destination's own controls, + * and failing that publishes nothing and keeps the arrival. + */ + | { decision: 'arrived'; reason: string; destinationLocator?: AppFlowDestinationLocator } + | { decision: 'blocked'; reason: string } + +interface LoadAgentDeviceResult { + createAgentDeviceClient: (config?: Record) => AgentDeviceClient +} + +interface PackageJson { + scripts?: Record + dependencies?: Record + devDependencies?: Record +} + +/** + * Pins every agent-device daemon this app starts to one state directory. + * + * The sweep talks to agent-device two different ways: in-process through + * `createAgentDeviceClient`, and by shelling out to the `agent-device` CLI for boot/install/open + * (`spawnCommand` below). Each of those starts its own daemon, and a daemon holds the iOS runner + * lease — `sessions.close()` deliberately does not release it ("close keeps a healthy iOS runner + * warm by default"), so the lease outlives the session that took it. + * + * agent-device allows exactly two ways past a lease another daemon holds: a proxy logical-lease + * takeover, which needs a `proxy` lease provider we do not use, and a same-state-dir takeover, + * which compares `AGENT_DEVICE_STATE_DIR` against the owner's `ownerStateDir` (`runner-lease.js`). + * With the variable unset that comparison reads an undefined current dir and can never match, so + * *every* cross-daemon lease request is refused — the takeover path is not merely unlikely, it is + * unreachable. + * + * That was the first of two things stopping replay scripts from being saved. On the sweep before + * this fix every explored screen failed `saveAndReplayRecording`, HomeTab/Home naming the cause + * outright — "iOS runner for is already owned by another agent-device daemon" — and the + * rest surfacing it one level down as the replay's pre-action snapshot failing (`capture-failed`). + * The sweep after it confirmed the lease is no longer the obstacle: HomeTab/Home published a + * verified script, and the remaining failures had moved on to a different, later error inside a + * replay that now actually ran (see `startReplayRecording`'s recorded stable wait for that one). + * + * Setting one dir for the whole app makes the in-process client and every spawned CLI daemon + * same-state-dir peers, which is the takeover agent-device's own docs point at (its guidance for + * the opposite fix — stop the owning daemon — assumes a CI shape where the two are meant to be + * strangers; here they are the same app and should share). `spawnCommand` passes `process.env` + * straight through, so assigning it here covers the CLI path too. + */ +function pinAgentDeviceStateDir(): void { + if (process.env.AGENT_DEVICE_STATE_DIR?.trim()) return + process.env.AGENT_DEVICE_STATE_DIR = appFlowAgentDeviceStateDir() +} + +async function loadAgentDevice(): Promise { + // Before the import, not after: the module reads AGENT_DEVICE_STATE_DIR when it initialises, and + // a daemon started by an earlier CLI spawn must already agree with it. + pinAgentDeviceStateDir() + // agent-device is ESM-only; electron-vite emits CJS for the main process + // with dynamicImportInCjs enabled so this import() is preserved at runtime. + return getAppFlowHost().loadAgentDevice() as Promise +} + +let nativeStore: { dataDir: string; store: NativeBuildStore } | null = null +function nativeBuildStore(): NativeBuildStore { + const dataDir = appFlowDataDir() + if (!nativeStore || nativeStore.dataDir !== dataDir) { + nativeStore = { dataDir, store: createAppFlowNativeBuildStore(dataDir) } + } + return nativeStore.store +} + +async function closeExistingSession(client: AgentDeviceClient): Promise { + try { + await client.sessions.close() + } catch { + // No leftover session for this name. + } +} + +function detectPackageManager(projectPath: string): 'yarn' | 'pnpm' | 'bun' | 'npm' { + if (existsSync(join(projectPath, 'yarn.lock'))) return 'yarn' + if (existsSync(join(projectPath, 'pnpm-lock.yaml'))) return 'pnpm' + if (existsSync(join(projectPath, 'bun.lockb')) || existsSync(join(projectPath, 'bun.lock'))) { + return 'bun' + } + return 'npm' +} + +function runScriptCommand( + packageManager: string, + script: string, + extraArgs: string[] = [] +): string { + const extras = extraArgs.join(' ') + if (packageManager === 'npm') { + return extras ? `npm run ${script} -- ${extras}` : `npm run ${script}` + } + if (packageManager === 'bun') { + return extras ? `bun run ${script} -- ${extras}` : `bun run ${script}` + } + return extras ? `${packageManager} ${script} ${extras}` : `${packageManager} ${script}` +} + +async function readPackageJson(projectPath: string): Promise { + const pkgPath = join(projectPath, 'package.json') + if (!existsSync(pkgPath)) { + throw new Error(`No package.json found in ${projectPath}`) + } + return JSON.parse(await readFile(pkgPath, 'utf-8')) as PackageJson +} + +async function readPlatformScript( + projectPath: string, + platform: 'ios' | 'android' +): Promise { + const pkg = await readPackageJson(projectPath) + const script = pkg.scripts?.[platform] + if (!script) { + throw new Error( + `package.json is missing an "${platform}" script. Add one (for example "ios": "expo run:ios") and retry.` + ) + } + return script +} + +function classifyLaunchMode(script: string): LaunchMode { + if ( + /\b(expo\s+run:|react-native\s+run-|run-ios|run:ios|run-android|run:android)\b/.test(script) + ) { + return 'native-run' + } + return 'expo-go' +} + +function nativeDeviceArgs(script: string, deviceId?: string): string[] { + const reactNativeCli = isReactNativeCliScript(script) + if (!deviceId || /\s--(?:device|deviceId|udid)(?:\s|=|$)/.test(` ${script} `)) return [] + return [reactNativeCli ? '--udid' : '--device', deviceId] +} + +function attachAbort(child: ChildProcess, signal?: AbortSignal): () => void { + const abort = (): void => { + if (child.pid) child.kill('SIGTERM') + } + signal?.addEventListener('abort', abort, { once: true }) + return (): void => signal?.removeEventListener('abort', abort) +} + +async function spawnCommand( + command: string, + cwd: string, + signal?: AbortSignal +): Promise<{ child: ChildProcess; getOutput: () => string; getExitCode: () => number | null }> { + // The CLI starts its own daemon, which must land in the same state dir as the in-process + // client's — see `pinAgentDeviceStateDir`. `env: process.env` carries it through. + pinAgentDeviceStateDir() + const child = spawn('/bin/zsh', ['-lc', resolveAgentDeviceCommand(command)], { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + let output = '' + const append = (chunk: Buffer): void => { + output = (output + chunk.toString('utf-8')).slice(-12_000) + } + child.stdout?.on('data', append) + child.stderr?.on('data', append) + + let exitCode: number | null = null + child.on('exit', (code) => { + exitCode = code + }) + + const detachAbort = attachAbort(child, signal) + child.once('exit', detachAbort) + + return { + child, + getOutput: () => output, + getExitCode: () => exitCode + } +} + +async function waitWhile( + signal: AbortSignal | undefined, + predicate: () => Promise | boolean, + timeoutMessage: () => string +): Promise { + const deadline = Date.now() + LAUNCH_TIMEOUT_MS + log('waitWhile', `poll 1s, timeout ${LAUNCH_TIMEOUT_MS / 1000}s`) + while (Date.now() < deadline) { + if (signal?.aborted) throw new Error('Cancelled') + if (await predicate()) return + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + throw new Error(timeoutMessage()) +} + +async function installNativeApp( + ctx: CaptureContext, + script: string, + deviceId?: string +): Promise<{ child: ChildProcess | null; metroPort: number | null; metroLogPath: string }> { + const reactNativeCli = isReactNativeCliScript(script) + let metroPort: number | null = reactNativeCli ? await findAvailablePort() : null + const extraArgs = [ + ...bundlerCliArgs({ + reactNativeCli, + startBundler: true, + port: reactNativeCli ? metroPort! : 0 + }), + ...nativeDeviceArgs(script, deviceId) + ] + const command = runScriptCommand(detectPackageManager(ctx.projectPath), ctx.platform, extraArgs) + log('install native app', `${command} (timeout ${LAUNCH_TIMEOUT_MS / 1000}s)`) + const metroLogPath = await beginSessionMetroLog({ + userDataDir: appFlowDataDir(), + projectId: ctx.projectId, + sessionName: ctx.sessionName + }) + const { child, getOutput, getExitCode } = await spawnCommand(command, ctx.projectPath, ctx.signal) + pipeChildToMetroLog(metroLogPath, child) + + let launched = false + const watch = (chunk: Buffer): void => { + const text = chunk.toString('utf-8') + if (NATIVE_INSTALL_SUCCESS_RE.test(text)) launched = true + if (metroPort == null) { + const parsed = parseMetroPortFromOutput(text) + if (parsed) metroPort = parsed + } + } + child.stdout?.on('data', watch) + child.stderr?.on('data', watch) + + try { + await waitWhile( + ctx.signal, + async () => { + const exitCode = getExitCode() + if (exitCode !== null && exitCode !== 0) { + throw new Error( + `"${command}" exited with code ${exitCode}.\n${getOutput().trim() || '(no output)'}` + ) + } + if (!(exitCode === 0 || launched)) return false + if (metroPort == null) metroPort = parseMetroPortFromOutput(getOutput()) + return metroPort != null && (await isMetroReady(metroPort)) + }, + () => + `"${command}" timed out waiting for the native ${ctx.platform} install.\n${getOutput().trim() || '(no output)'}` + ) + } catch (error) { + if (child.pid) child.kill('SIGTERM') + throw error + } + + log('install native app', `app launched, Metro on ${metroPort} pid=${child.pid}`) + return { child, metroPort, metroLogPath } +} + +async function runUntilExit(command: string, cwd: string, signal?: AbortSignal): Promise { + const { child, getOutput, getExitCode } = await spawnCommand(command, cwd, signal) + try { + await waitWhile( + signal, + () => getExitCode() !== null, + () => `"${command}" timed out.\n${getOutput().trim() || '(no output)'}` + ) + } catch (error) { + if (child.pid) child.kill('SIGTERM') + throw error + } + + const exitCode = getExitCode() + if (exitCode !== 0) { + throw new Error( + `"${command}" exited with code ${exitCode}.\n${getOutput().trim() || '(no output)'}` + ) + } + return getOutput() +} + +function stripAnsi(text: string): string { + return text.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') +} + +function resolveMaybeHomePath(filePath: string, cwd: string): string { + if (filePath === '~') return homedir() + if (filePath.startsWith('~/')) return join(homedir(), filePath.slice(2)) + if (!isAbsolute(filePath)) return join(cwd, filePath) + return filePath +} + +function parseExpoGoDownloadPath(output: string, cwd: string): string | null { + const lines = stripAnsi(output) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + const marker = 'Expo Go downloaded to' + for (let i = 0; i < lines.length; i++) { + if (!lines[i].startsWith(marker)) continue + const rest = lines[i].slice(marker.length).trim() + const candidate = rest || lines[i + 1] + if (candidate) return resolveMaybeHomePath(candidate, cwd) + } + return null +} + +async function findDownloadedExpoGo( + cwd: string, + platform: 'ios' | 'android' +): Promise { + const entries = await readdir(cwd).catch(() => []) + const suffix = platform === 'android' ? '.apk' : '.app' + const hit = entries.find((entry) => entry.toLowerCase().endsWith(suffix)) + return hit ? join(cwd, hit) : null +} + +async function downloadExpoGo(ctx: CaptureContext): Promise { + const projectDir = getProjectDir(ctx.projectId) + const existing = await findDownloadedExpoGo(projectDir, ctx.platform) + if (existing && existsSync(existing)) return existing + + const command = `npx -y expo-go download ${ctx.platform} ${EXPO_GO_VERSION}` + log('download Expo Go', `${command} (timeout ${LAUNCH_TIMEOUT_MS / 1000}s)`) + const output = await runUntilExit(command, projectDir, ctx.signal) + const fromLog = parseExpoGoDownloadPath(output, projectDir) + if (fromLog && existsSync(fromLog)) return fromLog + + const fromDir = await findDownloadedExpoGo(projectDir, ctx.platform) + if (fromDir) return fromDir + + throw new Error( + `Could not determine the Expo Go download path after "${command}".\n${output.trim() || '(no output)'}` + ) +} + +async function ensureMetro( + ctx: CaptureContext +): Promise<{ child: ChildProcess; metroPort: number; metroLogPath: string }> { + const pkg = await readPackageJson(ctx.projectPath) + const packageManager = detectPackageManager(ctx.projectPath) + const expo = isExpoProject(pkg) + let metroPort = expo ? null : await findAvailablePort() + const portArgs = ['--port', expo ? '0' : String(metroPort)] + const command = pkg.scripts?.start + ? runScriptCommand(packageManager, 'start', portArgs) + : expo + ? 'npx expo start --port 0' + : `npx react-native start --port ${metroPort}` + log('start Metro', `${command} (poll 1s, timeout ${LAUNCH_TIMEOUT_MS / 1000}s)`) + + const metroLogPath = await beginSessionMetroLog({ + userDataDir: appFlowDataDir(), + projectId: ctx.projectId, + sessionName: ctx.sessionName + }) + const { child, getOutput, getExitCode } = await spawnCommand(command, ctx.projectPath, ctx.signal) + pipeChildToMetroLog(metroLogPath, child) + const watch = (chunk: Buffer): void => { + if (metroPort != null) return + const parsed = parseMetroPortFromOutput(chunk.toString('utf-8')) + if (parsed) metroPort = parsed + } + child.stdout?.on('data', watch) + child.stderr?.on('data', watch) + + try { + await waitWhile( + ctx.signal, + async () => { + const exitCode = getExitCode() + if (exitCode !== null && exitCode !== 0) { + throw new Error( + `"${command}" exited with code ${exitCode}.\n${getOutput().trim() || '(no output)'}` + ) + } + if (metroPort == null) metroPort = parseMetroPortFromOutput(getOutput()) + return metroPort != null && (await isMetroReady(metroPort)) + }, + () => `"${command}" timed out waiting for Metro.\n${getOutput().trim() || '(no output)'}` + ) + log('metro status', `running on ${metroPort}`) + return { child, metroPort: metroPort!, metroLogPath } + } catch (error) { + if (child.pid) child.kill('SIGTERM') + throw error + } +} + +/** + * Thrown when the adopted device has no reachable Metro dev server and one could not be restarted. + * + * A distinct type so the orchestrator's top-level handler (and tests) can tell this apart from the + * device failures that surround it; the message it carries is the one the user reads. + */ +export class MetroUnavailableError extends Error { + constructor(message: string) { + super(message) + this.name = 'MetroUnavailableError' + } +} + +export interface AdoptedMetroOutcome { + /** + * `live` — the adopted port answered. `recovered` — it did not, and a fresh dev server took its + * place (`metroPort` is the new one). `degraded` — there is no runtime at all, and the run's mode + * can finish without one. + */ + status: 'live' | 'recovered' | 'degraded' + metroPort: number + /** Set on `degraded` only: what to tell the user before the sweep carries on regardless. */ + warning?: string +} + +/** + * Validate — and if need be restore — the Metro dev server behind a device the sweep *adopted* + * rather than booted. + * + * `bootDevice` owns Metro on the fresh-boot path: `ensureMetro` spawns it, parses its port and + * polls `isMetroReady` before anything else runs. The adopt path has no such guarantee. It reuses + * the `CaptureDevice` the user's Launch produced, `metroPort` and all, and that port is only as + * fresh as the launch was — the user may have stopped their dev server, or restarted it on another + * port, in the minutes between pressing Launch and pressing Build. Nothing re-checked it. + * + * The cost of not checking is the whole run. Fast's only rung is `render`, which reaches every + * screen through the Metro runtime, so a dead port means `listMetroTargets` finds nothing and every + * node is skipped with "Runtime transport unavailable" — a 2026-10 sweep burned 25s discovering + * that nine separate times and captured nothing. One probe here answers it once. + * + * `probe`/`recover` are injected so this is testable without a device or a spawned dev server; they + * default to the same `isMetroReady` and `ensureMetro` the boot path uses, with the same timeouts. + */ +export async function ensureAdoptedMetro( + ctx: CaptureContext, + device: CaptureDevice, + options: { + explorationMode: ExplorationMode + probe?: (port: number) => Promise + recover?: ( + ctx: CaptureContext + ) => Promise<{ child: ChildProcess; metroPort: number; metroLogPath: string }> + } +): Promise { + const probe = options.probe ?? isMetroReady + const recover = options.recover ?? ensureMetro + const adoptedPort = device.metroPort + if (adoptedPort && (await probe(adoptedPort))) { + log('adopted metro', `port ${adoptedPort} still answering`) + return { status: 'live', metroPort: adoptedPort } + } + log( + 'adopted metro', + `port ${adoptedPort || 'none'} did not answer — restarting the dev server for this project` + ) + try { + const metro = await recover(ctx) + // The port we adopted is gone, so whatever process we were holding for it is too. Release it + // and its log before taking ownership of the replacement, or the run leaks both. + stopLaunchProcess(device.launchProcess) + await endMetroLogFile(device.metroLogPath) + device.launchProcess = metro.child + device.metroPort = metro.metroPort + device.metroLogPath = metro.metroLogPath + // Both URLs name the port, so a server on a different one invalidates them. Left stale, the + // dev-client reconnect would re-point the app at the dead server the probe above just rejected. + device.devClientUrl = device.devClientUrl + ? devClientReconnectUrl(ctx.scheme, true, metro.metroPort) + : null + if (device.launchUrl && adoptedPort) { + device.launchUrl = device.launchUrl.split(`:${adoptedPort}`).join(`:${metro.metroPort}`) + } + log('adopted metro', `recovered on ${metro.metroPort} pid=${metro.child.pid}`) + return { status: 'recovered', metroPort: metro.metroPort } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + const problem = `The Metro dev server for this project is not reachable${ + adoptedPort ? ` on port ${adoptedPort}` : '' + }, and restarting it failed: ${detail}` + // The mode decides whether this is fatal, and it genuinely differs. Fast runs a single rung — + // `render` — which can only reach a screen through the Metro runtime, so without a transport it + // captures exactly nothing; failing now is strictly better than sweeping to the same end + // slowly. Auto and thorough still have the deep-link and exploration rungs, which drive the + // device itself and need no bundler connection, so for them this is a warning and the run goes + // on. + if (options.explorationMode === 'fast') { + throw new MetroUnavailableError( + `${problem}\nFast capture reaches every screen through Metro, so it cannot run without it. Start the dev server for this project and build again.` + ) + } + return { + status: 'degraded', + metroPort: adoptedPort, + warning: `${problem} Continuing without the Fast runtime — deep links and on-device exploration do not need it.` + } + } +} + +function matchInstalledApp(apps: string[], candidates: Array): string | null { + const installed = apps.map((app) => app.trim()).filter(Boolean) + for (const candidate of candidates) { + if (!candidate) continue + const hit = installed.find( + (app) => app === candidate || app.toLowerCase() === candidate.toLowerCase() + ) + if (hit) return installedAppLaunchTarget(hit) + } + for (const candidate of candidates) { + if (!candidate) continue + const needle = candidate.toLowerCase() + const hit = installed.find((app) => app.toLowerCase().includes(needle)) + if (hit) return installedAppLaunchTarget(hit) + } + return null +} + +function installedAppLaunchTarget(app: string): string { + const formattedApp = app.match(/^.+\s+\(([A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+)\)$/) + return formattedApp?.[1]?.trim() || app +} + +async function listInstalledApps( + client: AgentDeviceClient, + platform: 'ios' | 'android' +): Promise { + return (await client.apps.list({ platform }).catch(() => [])) as string[] +} + +async function ensureExpoGoInstalled( + client: AgentDeviceClient, + ctx: CaptureContext +): Promise<{ launchTarget: string; installPath: string | null }> { + const apps = await listInstalledApps(client, ctx.platform) + const existing = matchInstalledApp(apps, [EXPO_GO_APP_NAME, 'host.exp.Exponent']) + if (existing) { + log('Expo Go already installed', existing) + return { launchTarget: existing, installPath: null } + } + + const appPath = await downloadExpoGo(ctx) + log('apps.install', appPath) + const result = await client.apps.install({ + appPath, + platform: ctx.platform + }) + return { + launchTarget: result.app || result.appId || EXPO_GO_APP_NAME, + installPath: appPath + } +} + +/** + * agent-device's client throws its `AppError` for every failed call, and that error's `.message` + * is not the only place session-loss wording can land. `replay.run` in particular wraps a divergent + * step in "Replay failed at step N (...): ", and when the divergence handler's own + * post-failure snapshot attempt is what actually hits the dead session, the cause it records is a + * generic `Could not capture a fresh snapshot to verify the recorded target before acting + * (capture-failed)` — the literal "no active session" wording `isSessionLostMessage` matches never + * reaches `.message` at all, only `AppError`'s `details.hint` ("Post-failure snapshot capture + * failed ()"), one level down, where the real wording is interpolated. + * + * This is NOT, however, what was stopping replay scripts from being saved — an earlier version of + * this comment claimed it was, and the 2026-09-23 sweep disproved it: not one replay recovered + * here, because the underlying message was "iOS runner ... is already owned by another + * agent-device daemon", a lease conflict rather than a dead session. That cause is addressed at + * its source by `pinAgentDeviceStateDir`. The hint check stays because a genuinely lost session + * still surfaces through this same wrapper and is worth one recovery attempt, but it is a + * secondary path, not the fix for the lease problem. + */ +function agentDeviceErrorHint(error: unknown): string | undefined { + const details = (error as { details?: unknown } | null | undefined)?.details + const hint = (details as { hint?: unknown } | undefined)?.hint + return typeof hint === 'string' ? hint : undefined +} + +/** + * What agent-device knows about a divergent replay step, flattened into one log line. + * + * `AppError.message` for a `REPLAY_DIVERGENCE` says which step failed and which of the two target + * codes it was, and that is all it says. The reason sits one level down in `details.divergence`, + * which agent-device's own error hint points at — "Read details.divergence (screen/suggestions) + * for repair context" — and which this project has been throwing away at every catch site. + * + * That cost two rounds of inference on the same failure. `IDENTITY_MISMATCH` on + * `press id="bottomBarProfileBtn"` was read as a readiness race, on the reasoning that the + * recorded identity is positional (`"sibling":7,"viewportOrder":0`) and a half-rendered tree would + * index differently; the fix was a recorded `wait stable` after the entry gate, and the 14-09-09 + * sweep recorded that wait, honoured it, and failed on the identical step with the identical code. + * Timing is therefore ruled out and the recorded settle has been reverted. `targetBinding` is the + * field that would have said so in one run rather than two: `mismatches` names the identity + * components that differed and `candidates` lists what else in the tree carried the recorded + * identity, so a positional drift and a genuinely duplicated control are no longer the same + * observation. + * + * Returns undefined rather than throwing or logging on anything unexpected: this runs inside catch + * blocks whose job is to report the original failure, and a diagnostic that can mask the error it + * was added to explain is worse than no diagnostic. + */ +function isMissingSessionError(error: unknown): boolean { + if (isSessionLostMessage(errorMessage(error))) return true + const hint = agentDeviceErrorHint(error) + return hint !== undefined && isSessionLostMessage(hint) +} + +async function openAppSession( + client: AgentDeviceClient, + app: string, + options?: { + url?: string | null + relaunch?: boolean + udid?: string + /** The sweep's ledger, when the caller has one. See `CaptureContext.timing`. */ + timing?: PhaseLedger + } +): Promise { + const launchTarget = installedAppLaunchTarget(app) + const url = options?.url?.trim() || null + // The scheme preapproval is inside the measurement on purpose. It is a `simctl` call of its own, + // it happens only on the open path, and billing it anywhere else would leave the sweep's single + // most repeated device action under-reported by however long it takes. + const open = async (): Promise => { + if (url && options?.udid && shouldPreapproveUrlScheme(url)) { + log('preapprove url scheme', `${url} → ${launchTarget}`) + await preapproveIosUrlScheme({ udid: options.udid, url, bundleId: launchTarget }) + } + log( + 'apps.open', + `${launchTarget}${url ? ` ${url}` : ''}${options?.relaunch ? ' --relaunch' : ''}` + ) + await client.apps.open({ + app: launchTarget, + platform: 'ios', + ...(url ? { url } : {}), + ...(options?.relaunch ? { relaunch: true } : {}) + }) + } + await (options?.timing?.measure('open-url', open) ?? open()) +} + +/** + * Clears the simulator's first-open "Open in ?" confirmation, when there is one to clear. + * + * Skipped outright once an open on this simulator has confirmed the scheme's launch-services + * approval is in force: there is then no prompt to find, and looking for one is not free. Writing + * the approval is deliberately *not* enough — `launchservicesd` can still be serving the old answer + * for the very open the write was for, which is exactly the first launch after a cold boot, so the + * first open after a write still probes and `markUrlSchemeApproved` promotes it. The probe + * runs an `alert` command, whose main-thread block enumerates SpringBoard while the app it just + * launched is still cold — the runner abandons that block when the enumeration outlives its budget, + * and then refuses every snapshot behind it with RUNNER_BUSY until it drains. On this sweep that + * cost the first deep link its whole route-settle window and every one after it too, until the + * runner passed its wedge threshold and was recycled. The pre-approval is what actually suppresses + * the prompt; this is only the fallback for when writing it failed. + */ +async function dismissOpenInAppPrompt(device: CaptureDevice, url?: string | null): Promise { + if (device.schemePromptChecked) return + if (!url || !shouldPreapproveUrlScheme(url)) return + if (isUrlSchemePreapproved(device.deviceId, url)) { + device.schemePromptChecked = true + log('ios open-in prompt', 'skipped — scheme preapproved') + return + } + const result = await acceptOpenInAppPrompt(device.client) + if (result === 'accepted' || result === 'absent') { + device.schemePromptChecked = true + // Either there was no sheet or it is gone, so the written approval is in force from here on and + // every later open on this simulator can skip the probe. + markUrlSchemeApproved(device.deviceId, url) + } + log('ios open-in prompt', result) +} + +/** + * Runs one `agent-device open` line from a stored `pathToReach`, prompt handling included. + * + * Exported for the manual recorder's Replay button, which walks a path against a `CaptureDevice` + * rather than the `ProfilingDevice` `reachStartScreen` expects. It exists so `openAppSession` and + * `dismissOpenInAppPrompt` can both stay private: the two always go together at the call sites + * inside this module, and a caller that took only the first would reintroduce the first-open + * "Open in ?" sheet that the second is there to clear. + */ +export async function openPathToReachApp( + device: CaptureDevice, + app: string, + url: string | null +): Promise { + await openAppSession(device.client, app, { url, udid: device.deviceId }) + await dismissOpenInAppPrompt(device, url) +} + +/** + * Brings the bound app back after a lost session, and does not return until it is usable again. + * + * The wait is the point. Closing an agent-device session tears down the XCUITest runner, and the + * app under test goes with it, so this is a *cold* launch however cheap the `apps.open` looks: the + * command returns in about two seconds while the JS bundle is still loading and no navigator is + * mounted yet. Both callers hand the app something to do the moment this resolves — one re-opens + * the deep link it was in the middle of, the other screenshots — and a URL delivered into that gap + * is not slow, it is *lost*: React Navigation rejects it ("the 'navigation' object hasn't been + * initialized yet"), the app finishes launching on its default screen, and the arrival judge + * truthfully reports a mismatch for a route that works perfectly well. A device run shows all three + * of its deep links failing exactly this way, each one 1.4s after this relaunch, which was enough + * to trip the breaker and send all 84 routes into full exploration walks. + * + * `waitForAppReady` returns as soon as the app settles, so a warm relaunch pays almost nothing and + * only a genuinely cold start spends the timeout it needs. + */ +export async function reopenBoundApp( + ctx: CaptureContext, + device: CaptureDevice, + timeoutMs = APP_READY_TIMEOUT_MS +): Promise { + await launchBoundApp(device, ctx.timing) + await waitForAppReady(ctx, device, ctx.signal, timeoutMs) +} + +/** + * The launch half of `reopenBoundApp`, without the wait that follows it. + * + * Split out for `waitForAppReady`, which needs to relaunch a stalled app from *inside* its own + * poll loop: calling `reopenBoundApp` there would re-enter `waitForAppReady` and nest one poll + * loop inside another for as long as the app kept stalling. + */ +/** + * Reconnect a dev client that is sitting on its launcher, by opening the bundle URL for it. + * + * This exists because `launchBoundApp` cannot do it. That function opens `device.launchTarget` + * with `relaunch: Boolean(device.launchUrl)`, and a native dev-client build has no `launchUrl` — + * `openInstalledApp` returns `launchUrl: null` for every `native-run` app, since its deep links + * are fired individually rather than at launch. So the recovery reduced to `apps.open` with no URL + * and no relaunch, against a process that was already frontmost: it brought the launcher to the + * front of itself. The 2026-09-23 sweep logged the result exactly — sixteen detections, sixteen + * `dev-client launcher still on screen after a relaunch`, no recoveries. + * + * A plain relaunch would not have fixed it either. The launcher is not a transient state the app + * recovers from on restart; it is what the dev client shows when it has no bundle to run, so + * restarting it shows the launcher again. What clears it is being told where Metro is, which is + * the one thing a person does by hand and the sweep never did. + * + * Returns whether a reconnect was attempted, so the caller can fall back to an ordinary relaunch + * for an app that has no dev client. + */ +async function reconnectDevClient(device: CaptureDevice, timing?: PhaseLedger): Promise { + if (!device.devClientUrl) return false + log('reconnect dev client', device.devClientUrl) + await openAppSession(device.client, device.launchTarget, { + timing, + url: device.devClientUrl, + // The dev client is already running and showing its launcher; the URL is what it needs, not a + // restart. Relaunching first would only throw away the process it is about to load into. + relaunch: false, + udid: device.deviceId + }) + await dismissOpenInAppPrompt(device, device.devClientUrl) + return true +} + +/** + * Restarts the already-running bound app so it comes up in a fresh state, without touching the + * installed build or Metro. + * + * No longer on the adopt path, and the reason is a warning for any future caller: this opens + * `device.launchUrl`, which is `null` for every `native-run` dev-client build (see + * `openInstalledApp`), so on those builds it is a bare `--relaunch` with no bundle URL — and a + * dev client restarted with nothing to load comes up on its *launcher*. The 2026-10-09 social-app + * sweep is the record: a warm, settled Bluesky build was relaunched here at t=65.1s and the sweep + * did not have a real screen again until t=93.6s, having burned the entry-chrome pass, two + * `arrival` model calls against the Expo launcher, and a cached auth verdict read off a dead app. + * `AppFlowBuilder`'s adopt branch now probes readiness and soft-homes instead. + * + * Kept because a caller that genuinely needs the process thrown away still has nothing else, and + * because it is correct for any build that does carry a launch URL (Expo Go). + */ +export async function relaunchBoundApp(ctx: CaptureContext, device: CaptureDevice): Promise { + log('relaunch bound app', device.launchTarget) + await openAppSession(device.client, device.launchTarget, { + url: device.launchUrl, + relaunch: true, + udid: device.deviceId + }) + await dismissOpenInAppPrompt(device, device.launchUrl) + await waitForAppReady(ctx, device, ctx.signal) +} + +/** + * Re-roots the already-running bound app at its launch URL *without* restarting the process — the + * cheapest way back to a known starting screen that exists on an agent-device harness. + * + * Identical to `relaunchBoundApp` above with the one flag flipped, and the flag is the whole + * point. `openAppSession` only passes `relaunch: true` when asked, so this is an `apps.open` that + * hands the running app a URL to navigate to instead of throwing its process away: no bundle + * reload, no splash, no `iOS Bundled 16079ms (6130 modules)` on a cold cache. The app ends up + * where a fresh launch would have put it, which is all a caller that just wants to stop being on + * some half-explored screen actually needs. + * + * Measured through the ledger like the other opens (`open-url`, supplied by `openAppSession`), + * because a soft home that turns out *not* to be cheap is exactly the kind of thing a sweep should + * be able to read off its own timings rather than argue about. + * + * It is not a substitute for `relaunchBoundApp` where state must genuinely be thrown away: an app + * carrying modal stacks, a dirty Redux store or a logged-in-as-someone-else session comes back + * from this with all of it intact. Callers that need a *clean* app still relaunch. + * + * @param options.reconnectDevClientWhenUnloaded + * For a build with no `launchUrl` of its own — which is every `native-run` dev client — re-root + * through `reconnectDevClient` instead. Without it this function is silently a no-op on exactly + * those builds: `openAppSession` with a null URL and no `--relaunch`, against a process that is + * already frontmost, brings the app to the front of itself (`reconnectDevClient` records sixteen + * consecutive recoveries that did nothing at all for this reason). + * + * Opt-in rather than automatic, because the two situations want opposite things from the same + * URL. Opening `devClientUrl` makes the dev client re-fetch and re-evaluate the bundle — that is + * a reload, with the splash and the `6130 modules` build behind it, which on the 2026-10-09 + * social-app sweep killed a warm app and cost 28s to climb back from. Only a caller that has + * already established the app is *not* loaded (the adopt path in `orchestrator.ts`, after a + * floored `waitForAppReady` came back false) may ask for it. `discardRecordingCandidate`, the + * other caller, is soft-homing a live app mid-sweep and must keep the no-op. + */ +export async function goHome( + ctx: CaptureContext, + device: CaptureDevice, + options?: { reconnectDevClientWhenUnloaded?: boolean } +): Promise { + // Suppressed, not reordered or removed. A soft home is an `apps.open` like any other, so issuing + // one inside an armed `--saveScript` window is the "second successful open" that makes the + // recording unpublishable — and this function's only caller mid-sweep + // (`discardRecordingCandidate`) wants a tidy starting screen, which is worth strictly less than + // the script. Its own doc comment already records why skipping is free here: the next site to + // need a transaction opens with `relaunch: true` of its own, so the app is going back to its + // launch screen regardless, just later and at nobody's extra expense. + // + // Only while the transaction is still *alive* — see `suppressesSoftHome`. A recording something + // has already aborted has no publication left to protect, and holding the home back for the rest + // of that walk would cost the tidy screen for nothing. + if (suppressesSoftHome(device.armedRecording)) { + log( + 'go home', + `deferred — a recording is armed (${device.armedRecording?.scriptPath}) and opening the app here would abort it` + ) + return + } + if (options?.reconnectDevClientWhenUnloaded && needsDevClientReconnect(device)) { + // Delegated rather than reimplemented with `url: device.devClientUrl`: that function also + // preapproves the scheme and clears the iOS "Open in?" sheet for this exact URL, and a second + // copy of that sequence is a second thing to keep in step with the dev client. + await reconnectDevClient(device, ctx.timing) + await waitForAppReady(ctx, device, ctx.signal) + return + } + log('go home', `${device.launchTarget}${device.launchUrl ? ` ${device.launchUrl}` : ''}`) + await openAppSession(device.client, device.launchTarget, { + timing: ctx.timing, + url: device.launchUrl, + relaunch: false, + udid: device.deviceId + }) + await dismissOpenInAppPrompt(device, device.launchUrl) + await waitForAppReady(ctx, device, ctx.signal) +} + +/** + * Ends whatever agent-device session is live, swallowing the "there wasn't one" case. + * + * Every `--save-script` call opens a transaction, and agent-device refuses to arm a second one + * over a live first — `stripIdentityAndReplay` documents the refusal it had to work around + * ("would refuse to be replaced"). Since `saveAndReplayRecording` stopped closing the session it + * publishes from (see the `else` branch there), a recording transaction can still be live when the + * next node arms its own, so every site that arms one now ends the previous one here first. + * + * It is free wherever it is called from, which is why it can be unconditional: each of those sites + * continues with a `replay.run` or an `apps.open --relaunch`, and every recorded `.ad` opens with + * `open --relaunch` of its own — so the app was going to be restarted by the very next call + * regardless of whether this closed it. + */ +export async function endLiveSession(device: CaptureDevice): Promise { + await device.client.sessions.close().catch(() => undefined) + // Whatever `--saveScript` transaction that session owned went with it, unfinalized. Clearing the + // marker is what lets `goHome` open the app again — holding the suppression past the end of the + // recording it was protecting would cost every later soft home for nothing. + device.armedRecording = null +} + +async function launchBoundApp(device: CaptureDevice, timing?: PhaseLedger): Promise { + log( + 'reopen bound app', + `${device.launchTarget}${device.launchUrl ? ` ${device.launchUrl}` : ''} --relaunch=${Boolean(device.launchUrl)}` + ) + await openAppSession(device.client, device.launchTarget, { + timing, + url: device.launchUrl, + relaunch: Boolean(device.launchUrl), + udid: device.deviceId + }) + await dismissOpenInAppPrompt(device, device.launchUrl) +} + +async function openInstalledApp( + client: AgentDeviceClient, + ctx: CaptureContext, + mode: LaunchMode, + options: { + expoGoTarget?: string + udid: string + metroPort: number + /** + * The dev-client reconnect URL to launch the native app with, or null to launch it bare. + * + * Only set when the native build was *skipped*. A build that actually ran has already launched + * the app against the Metro it started, so opening it again with a URL would only throw that + * process away. A skipped build has not: the dev client would cold-launch having never been + * told where Metro is this session, land on its own launcher, and pay the recovery round trip + * (reconnect, then relaunch) that the skip was meant to save. + */ + devClientUrl?: string | null + } +): Promise<{ launchTarget: string; launchUrl: string | null; useCustomScheme: boolean }> { + if (mode === 'expo-go') { + const launchTarget = options.expoGoTarget ?? EXPO_GO_APP_NAME + const launchUrl = `exp://127.0.0.1:${options.metroPort}` + await openAppSession(client, launchTarget, { + url: launchUrl, + udid: options.udid, + timing: ctx.timing + }) + return { launchTarget, launchUrl, useCustomScheme: false } + } + + const apps = await listInstalledApps(client, ctx.platform) + const nativeTarget = matchInstalledApp(apps, [ctx.bundleId, ctx.appName, ctx.slug]) + if (!nativeTarget) { + const lookedFor = + [ctx.bundleId, ctx.appName, ctx.slug].filter(Boolean).join(', ') || 'an app name' + const installed = apps.filter(Boolean).slice(0, 20).join(', ') || '(none)' + throw new Error( + `Native ${ctx.platform} app was not installed after running the ${ctx.platform} script. Looked for ${lookedFor}. Installed: ${installed}.` + ) + } + + await openAppSession(client, nativeTarget, { + url: options.devClientUrl ?? null, + udid: options.udid, + timing: ctx.timing + }) + return { launchTarget: nativeTarget, launchUrl: null, useCustomScheme: Boolean(ctx.scheme) } +} + +/** + * The launch target of an installed native build that still matches the project's native sources, + * or `null` when the build has to be run. + * + * `npm run ios` is a full native build, install and launch on every invocation with no + * short-circuit of its own, and on a warm machine it was still the single most expensive step in + * the whole sweep — a measured 25-30s to reinstall a binary that was already on the simulator and + * byte-identical to the one it replaced. It is only genuinely required when the *native* inputs + * changed: a debug dev client fetches its JS bundle from Metro at runtime, which is why editing a + * screen does not invalidate the installed app. So when the fingerprint still matches, Metro is + * started directly and the app is opened against it, which is the state the build would have + * handed back anyway. + * + * Both halves have to hold. The fingerprint says the binary would come out the same; the device + * listing says that binary is still there — a simulator can be erased, or the record can describe + * an app the user deleted by hand, and a stale record is worse than a slow build because every + * later step would fail against an app that is not installed. + * + * `AI_HARNESS_FORCE_NATIVE_BUILD=1` opts out, for the case where the fingerprint is right and the + * binary still is not — a half-finished install, or a native input this does not know to look at. + * + * Ported from the profiling pipeline, which has run this exact check for a while; the shared + * fingerprint/record module now lives in `../native-build` so neither pipeline owns it. The records + * are shared too, in one `userData/native-builds.json` keyed by project path, platform and device — + * so a build this sweep pays for is reused by profiling and debugging, and clearing one pipeline's + * results no longer throws away the other's install. See `createNativeBuildStore`. + */ +async function resolveReusableNativeBuild( + client: AgentDeviceClient, + ctx: CaptureContext, + deviceId: string +): Promise { + if (process.env.AI_HARNESS_FORCE_NATIVE_BUILD === '1') { + log('checkInstalledBuild', 'AI_HARNESS_FORCE_NATIVE_BUILD=1 — rebuilding') + return null + } + const fingerprint = await computeNativeBuildFingerprint(ctx.projectPath, ctx.platform) + if (!fingerprint) { + log('checkInstalledBuild', `no ${ctx.platform}/ sources to fingerprint — rebuilding`) + return null + } + const known = await nativeBuildStore().has(ctx.projectPath, ctx.platform, deviceId) + const recorded = await nativeBuildStore().read(ctx.projectPath, ctx.platform, deviceId, fingerprint) + if (!recorded) { + log( + 'checkInstalledBuild', + known + ? 'native sources changed since the last install — rebuilding' + : 'no install recorded for this device yet — building once' + ) + return null + } + const apps = await listInstalledApps(client, ctx.platform) + const installed = matchInstalledApp(apps, [recorded]) + if (!installed) { + log('checkInstalledBuild', `${recorded} is no longer installed — rebuilding`) + return null + } + return installed +} + +/** + * Boots the simulator this project was last built on, falling back to the daemon's own choice. + * + * Asking for a specific device is what makes the build record worth keeping: the record is keyed by + * device, so letting the daemon pick afresh each run means reuse holds only while that pick happens + * to be stable. It is a preference and never a requirement — the remembered simulator can have been + * deleted, renamed or be missing from a different Xcode, and none of that is a reason to fail a + * sweep that an unpinned boot would have run fine. + */ +async function bootPreferredDevice( + client: AgentDeviceClient, + ctx: CaptureContext +): Promise<{ device: string; id: string }> { + const preferred = await nativeBuildStore().preferredDevice(ctx.projectPath, ctx.platform) + if (preferred) { + log('devices.boot', `platform=ios target=mobile udid=${preferred} (last built here)`) + try { + return (await client.devices.boot({ + platform: 'ios', + target: 'mobile', + udid: preferred + })) as { device: string; id: string } + } catch (error) { + log( + 'devices.boot', + `${preferred} unavailable (${errorMessage(error)}) — letting the daemon pick` + ) + } + } else { + log('devices.boot', 'platform=ios target=mobile') + } + return (await client.devices.boot({ + platform: 'ios', + target: 'mobile' + })) as { device: string; id: string } +} + +export async function bootDevice(ctx: CaptureContext): Promise { + const script = await readPlatformScript(ctx.projectPath, ctx.platform) + const mode = classifyLaunchMode(script) + log('bootDevice', `mode=${mode} session=${ctx.sessionName}`) + + const { createAgentDeviceClient } = await loadAgentDevice() + const client = createAgentDeviceClient({ + session: ctx.sessionName, + cwd: ctx.projectPath, + responseLevel: 'digest' + }) + + log('sessions.close', 'drop leftover session if any') + await closeExistingSession(client) + + const bootResult = await bootPreferredDevice(client, ctx) + log('devices.boot', `device=${bootResult.device} id=${bootResult.id}`) + + const setupCommands = [agentDeviceBootCommand()] + let expoGoTarget: string | undefined + let launchProcess: ChildProcess | null = null + let metroPort: number + let metroLogPath: string | null = null + let reusedNativeBuild = false + try { + if (mode === 'native-run') { + const reusable = await resolveReusableNativeBuild(client, ctx, bootResult.id) + if (reusable) { + log('installNativeApp', `skipped — ${reusable} already installed and unchanged`) + reusedNativeBuild = true + const metro = await ensureMetro(ctx) + launchProcess = metro.child + metroPort = metro.metroPort + metroLogPath = metro.metroLogPath + } else { + const installed = await installNativeApp(ctx, script, bootResult.id) + launchProcess = installed.child + metroPort = installed.metroPort ?? 0 + metroLogPath = installed.metroLogPath + if (!metroPort) { + throw new Error('Native install finished but Metro did not report a port.') + } + } + } else { + const expoGo = await ensureExpoGoInstalled(client, ctx) + expoGoTarget = expoGo.launchTarget + if (expoGo.installPath) { + setupCommands.push(agentDeviceInstallCommand(expoGo.launchTarget, expoGo.installPath)) + } + const metro = await ensureMetro(ctx) + launchProcess = metro.child + metroPort = metro.metroPort + metroLogPath = metro.metroLogPath + log('ensureMetro', `started pid=${metro.child.pid} port=${metroPort}`) + } + const devClientUrl = devClientReconnectUrl(ctx.scheme, mode === 'native-run', metroPort) + const { launchTarget, launchUrl, useCustomScheme } = await openInstalledApp(client, ctx, mode, { + expoGoTarget, + udid: bootResult.id, + metroPort, + devClientUrl: reusedNativeBuild ? devClientUrl : null + }) + if (mode === 'native-run' && !reusedNativeBuild) { + // Hash the sources *after* the build, not before it. `expo run:ios` runs `pod install` and can + // rewrite `Podfile.lock` and the Xcode project as part of the build, so a fingerprint taken up + // front never matches again and no later session would ever skip. + // + // `openInstalledApp` has just proved the app is installed — it throws otherwise — so this + // records a launch target that is known to be on the device. + await nativeBuildStore().record( + ctx.projectPath, + ctx.platform, + bootResult.id, + await computeNativeBuildFingerprint(ctx.projectPath, ctx.platform), + launchTarget + ) + } + log( + 'bootDevice done', + `launchTarget=${launchTarget} launchUrl=${launchUrl ?? 'none'} devClientUrl=${devClientUrl ?? 'none'} metroPort=${metroPort} metroLog=${metroLogPath ?? 'none'} useCustomScheme=${useCustomScheme} nativeBuild=${mode === 'native-run' ? (reusedNativeBuild ? 'reused' : 'built') : 'n/a'}` + ) + + const device: CaptureDevice = { + client, + deviceId: bootResult.id, + deviceName: bootResult.device, + launchProcess, + metroPort, + metroLogPath, + launchTarget, + launchUrl, + devClientUrl, + useCustomScheme, + setupCommands, + schemePromptChecked: false, + lastRead: null, + lastRouteOpenAt: null, + pendingEntryGate: false + } + await dismissOpenInAppPrompt(device, launchUrl) + return device + } catch (error) { + // Everything after Metro starts can throw — the install race, the cold-start wait, the first + // open — and when it does there is no `CaptureDevice` for anyone to hand to `closeDevice`, so + // the packager we spawned would outlive the failed launch and hold its port against the retry + // the user is about to make. Own it here, where it is still reachable. + stopLaunchProcess(launchProcess) + await endMetroLogFile(metroLogPath) + throw error + } +} + +function buildUrlPath(node: AppFlowNode, params: Record): string { + if (!node.urlPath) throw new Error(`Screen ${node.id} has no deep link`) + const pattern = node.urlPath + /** + * A stand-in here opens whatever record the app happens to have at that id, and the screen that + * comes back is photographed and judged as if it were the one that was asked for — or the 'not + * found' that follows is indistinguishable from a route this build withholds. That is the one + * failure a URL can produce that looks exactly like a finding, and it is why the param store's + * rule is that a value is observed or it does not exist. + * + * Callers already decline to deep-link a route whose params are unfilled (`unresolvedParams`), so + * reaching this is a caller bug, and throwing is what makes it one. A thrown deep link is a + * failed rung, which the ladder falls through exactly as it falls through a skipped one. + */ + const fill = (key: string): string => { + const value = params[key] + if (value === undefined) { + throw new Error( + `Screen ${node.id} has no observed value for :${key} — ${pattern} cannot be opened by URL` + ) + } + return value + } + const urlPath = pattern + .split('/') + .map((seg) => { + if (seg.startsWith(':')) return fill(seg.slice(1)) + if (seg.startsWith('[') && seg.endsWith(']')) { + return fill(seg.slice(1, -1).replace(/^\.\.\./, '')) + } + return seg + }) + .join('/') + + // "/" does not switch the current tab, so a root-path screen needs a stand-in route — and + // `indexDeepLinkPath` only ever returns a grounded one (see its doc comment for the invented + // `bluesky://src` this used to produce). Null means this screen has no URL to open, which is + // `deepLinkStrategy`'s cue to skip; reaching here with null means a caller bypassed that check. + if (urlPath === '/' || urlPath === '') { + const indexPath = indexDeepLinkPath(node) + if (!indexPath) { + throw new Error( + `Screen ${node.id} sits at "/" and this app declares no route group to open instead — it has to be reached through the UI` + ) + } + return indexPath + } + + return urlPath +} + +async function wait(signal: AbortSignal | undefined, ms: number, label: string): Promise { + log(label, `${ms / 1000}s`) + const deadline = Date.now() + ms + while (Date.now() < deadline) { + if (signal?.aborted) throw new Error('Cancelled') + await sleep(Math.min(500, deadline - Date.now())) + } +} + +export async function waitForAppLoaded(signal?: AbortSignal): Promise { + await wait(signal, APP_LOAD_WAIT_MS, 'wait for app to load') +} + +/** + * Waits until the first real screen has mounted, rather than sleeping for a fixed time. + * `APP_LOAD_WAIT_MS` alone is far too short for a cold dev-client launch: the app is still + * bundling, so the screenshot (and any screenshot we hand an agent) is a splash screen. + * + * Polls the accessibility tree and returns once it holds interactive elements that are not + * loading chrome and that stopped changing between two polls. On timeout it returns instead + * of throwing — a slow app should still be captured, just with a warning in the log. + * + * Returns whether the app actually settled. Most callers ignore it and capture regardless, which is + * the right default. It matters to callers that are about to draw a *conclusion* from the frame: + * "the app never settled" and "the app settled on the wrong screen" look identical in a screenshot, + * and only the second is evidence about anything. See the deep-link probe in `orchestrator.ts`, + * where conflating the two switched deep links off for an entire 84-route sweep. + */ +export async function waitForAppReady( + ctx: CaptureContext, + device: CaptureDevice, + signal?: AbortSignal, + timeoutMs = APP_READY_TIMEOUT_MS, + options?: { + /** + * How long to sleep before the first poll *when there is no recent read to compare against*. + * Defaults to `APP_LOAD_WAIT_MS`, which is right after a launch the caller just issued. + * + * No longer the usual path: a read from moments ago makes this sleep measurable instead of + * assumed — see `staleGraceMs` below — and the sleep is then skipped entirely. It remains the + * fallback for the one case nothing can be measured in, a first launch with no prior read, and + * a caller that knows the app is already up (`settleAfterRoute`) still passes 0. + */ + initialWaitMs?: number + /** + * How long an unchanged frame is disbelieved before being accepted, when a read from just + * before the caller's action is available to compare against. Defaults to `APP_LOAD_WAIT_MS`, + * the launch case; `settleAfterRoute` passes `ROUTE_WAIT_MS`, the router-transition case. + * + * This is the budget that replaced both flat sleeps. The sleeps were never waiting for the app + * to load — the poll below does that, with a real deadline — they were waiting out the window + * in which the poll could read the *previous* screen twice and call it the result of the + * action. That window is now detected rather than slept through, so a screen that has already + * arrived pays nothing and only a screen that genuinely has not moved pays anything at all. + * + * A floor, and a live one. Reaching the check at all costs two accessibility reads, and a read + * is cheap: the phase ledger of `session_2026-10-09_17-22-18.log` measures 0.18s each (120 + * calls, 21.9s total). Two agreeing reads therefore take roughly 0.4–0.5s, which is well under + * either grace, so the comparison is not dormant — an unchanged frame genuinely is disbelieved + * for the full grace before it is accepted. That is the intent: it is the only thing standing + * between a fast accessibility read and the bug the sleeps existed for, and it means a screen + * that legitimately does not change, such as the four identical 404s this app's gated routes + * render, pays the whole grace. An earlier version of this comment put a read at 0.9–1.7s and + * concluded the check never fired; that figure predates removing `customActions` from the + * snapshot (see the note in `waitForAppReady`) and was wrong by roughly 8x, as was the + * conclusion drawn from it. Raising either grace raises that cost on every unchanged screen. + */ + staleGraceMs?: number + /** + * The overlap at which a settled frame counts as "still the frame from before the action". + * Defaults to `APP_READY_SETTLE_SIMILARITY`, which is what every caller but `settleAfterRoute` + * wants: after a launch there is no mid-transition composite to catch, only a screen that has + * or has not been replaced. + * + * `settleAfterRoute` passes `APP_READY_STALE_SIMILARITY` instead — see that constant for the + * measured straddle 0.98 let through. + */ + staleSimilarityFloor?: number + /** + * When the stale guard's grace clock starts. Defaults to this call's own start, which is wrong + * by exactly the gap between the action and the wait for the one caller that has an action to + * point at: `apps.open` returned at t=240.461 in `session_2026-10-13_14-33-40.log` and this + * function was entered at t=240.932, so ~0.5s of an 800ms grace was spent before the first + * read. `settleAfterRoute` passes the moment the URL was actually opened + * (`device.lastRouteOpenAt`), so the grace covers the router's transition rather than + * whatever the harness did between the open and the wait. + */ + staleClockStartedAt?: number + /** + * Agreeing pairs the stale guard must reject before `staleGraceMs` is allowed to expire. + * Defaults to 0 — the grace is a pure wall-clock budget, as it was for every caller before + * this. `settleAfterRoute` passes `ROUTE_STALE_MIN_REJECTED_PAIRS`; see that constant for why + * a wall clock alone is a guard that disables itself on fast hardware. + */ + staleMinRejectedPairs?: number + /** + * Interactive-element count a settled frame must reach before it counts as ready. Absent for + * every caller that only wants "two reads agree"; set by a caller that has already decided a + * small frame is not an answer (the deep-link probe's `PROBE_FRAME_ELEMENT_FLOOR`). + * + * This exists because the alternative — calling this function in a loop and checking the count + * between calls — is the wrong shape for a function built to return the moment two reads + * agree. A dev client's loading screen is perfectly stable, so every call returned in about a + * second with the same 2-element frame, and the caller immediately re-entered. Measured on the + * social-app cold start: the same three log lines (`poll 400ms`, `frame is still the one from + * before this action`, `settled with 2 interactive elements after 3 reads in 1.0s`) repeated + * thirteen times between t=45.9s and t=59.6s, 13.7s in all. Each restart discarded the poll + * state and re-paid the stale-frame handshake, and none of the thirteen could ever return + * anything but the answer the caller had just rejected. One call that keeps polling past a + * too-small settle has the same deadline, the same reads and none of that overhead. + * + * The frame still has to settle as well as be large enough: a count that is high only because + * the screen is mid-render is not ready either. And it is a bound, not a promise — on timeout, + * or when `minInteractiveElementsGraceMs` runs out, this returns false like any other unsettled + * wait, and the caller decides what a frame that never grew means. + */ + minInteractiveElements?: number + /** + * How long to keep polling once the frame has *settled* below `minInteractiveElements`, + * measured from that first undersized settle rather than from the call. + * + * Defaults to no limit beyond `timeoutMs`, which is what every caller before this one wanted: + * the deep-link probe would rather spend its whole 30s than judge the transport off a splash. + * A caller that cannot distinguish "still loading" from "legitimately sparse" passes this + * instead — see `SUBSTANTIVE_FRAME_GRACE_MS` and the launch-readiness call in + * `orchestrator.ts`, where the alternative is a login screen holding the sweep for 90s. + */ + minInteractiveElementsGraceMs?: number + /** + * How long to tolerate the dev-client launcher on screen before recovering from it. + * + * Recovery opens the app again, and agent-device aborts an armed recording's publication on + * "a second successful open" — so for the one caller that has a recording armed, the recovery + * below is not a repair, it is what guarantees the script can never be published. Measured on + * social-app: `startReplayRecording` armed at t=67.2s, the relaunch landed on the launcher, + * this wait reconnected at t=69.6s, and the publish 16s later failed with exactly that + * message. Every sweep since has shipped an empty `flows/` directory for the same reason. + * + * Deferring rather than disabling, because the launcher genuinely does not clear itself in the + * case this recovery was written for (a dev client pointed at no Metro, where nobody is going + * to tap the server URL). Spending this long first costs a slow recording the delay and + * nothing else: when the deferral expires the recovery runs exactly as it does today, so the + * worst case is the publication failure we already have, reached later. + */ + deferLauncherRecoveryMs?: number + } +): Promise { + let deadline = Date.now() + timeoutMs + const initialWaitMs = options?.initialWaitMs ?? APP_LOAD_WAIT_MS + // Taken before the first poll, because the poll's own reads overwrite `device.lastRead`. Absent + // only on a first launch, where there is nothing to compare against and the flat sleep below is + // all that is left. + const recentRead = device.lastRead + const frameBeforeAction = + recentRead && Date.now() - recentRead.at <= FRAME_COMPARISON_MAX_AGE_MS + ? fingerprint(recentRead.pressables) + : null + const staleGraceMs = options?.staleGraceMs ?? APP_LOAD_WAIT_MS + const staleSimilarityFloor = options?.staleSimilarityFloor ?? APP_READY_SETTLE_SIMILARITY + const staleMinRejectedPairs = options?.staleMinRejectedPairs ?? 0 + const minInteractiveElements = options?.minInteractiveElements ?? 0 + const minInteractiveElementsGraceMs = options?.minInteractiveElementsGraceMs ?? Infinity + const requestedLauncherDeferralMs = options?.deferLauncherRecoveryMs ?? 0 + const deferLauncherRecoveryMs = launcherDeferralMs( + requestedLauncherDeferralMs, + device.launcherDeferral ?? 'untested' + ) + // Said once per call rather than once per poll: the launcher is read every 400ms and the fact + // being reported ("we are choosing to wait this one out") does not change between reads. + let loggedLauncherDeferral = false + // Tracked apart from `loggedLauncherDeferral` so that a call which defers and *then* runs out + // says both things. Sharing one flag would swallow the second line on exactly the path the + // deferral was built for. + let loggedLauncherRecoveryCause = false + const startedAt = Date.now() + // The stale guard's clock, which is the caller's action rather than this call — see + // `staleClockStartedAt`. Clamped to `startedAt` so a stamp from the future (or from a previous + // sweep, if a caller ever passed a stale one) can only shorten nothing and lengthen nothing. + const staleClockStartedAt = Math.min(options?.staleClockStartedAt ?? startedAt, startedAt) + let heldStaleFrame = false + // Agreeing pairs the stale guard has rejected on this call. Counted separately from + // `sleeps.staleFrame` even though the two move together today, because one is the guard's own + // state and the other is a diagnostic tally, and a later sleep added to this branch would + // silently change what the guard means. + let staleRejectedPairs = 0 + // The element count last reported while holding a settled frame below the floor, so the log says + // each distinct thing once (2, then 2 again, then 15) instead of once per poll. + let lastUndersizedCount: number | null = null + // When the frame first settled below the floor, so `minInteractiveElementsGraceMs` runs from the + // moment there was something to disbelieve rather than from the call — a cold start that spends + // 20s drawing nothing at all has not used any of the grace up. + let undersizedSettledAt: number | null = null + // Only on a first launch, where there is nothing to compare against and a flat sleep is all that + // is left. With a frame in hand the app was alive moments ago, so the poll below starts reading + // immediately and the stale-frame guard — not a sleep — is what keeps it off the pre-action frame. + // + // A one-poll-interval delay was tried here on the strength of the pre-`customActions` sweep, where + // settles landed on exactly 3 reads and 5.7s, matching `1.56 x 3 + 0.4 x 2` to the tenth: the + // signature of a first read that agreed with nothing. Skipping that read looked worth ~1.96s for + // 0.4s. Removing `customActions` from the snapshot then took a read from 1.56s to 0.31s, and the + // measured distribution did not move — 25 of 38 settles still took 3 reads — so the delay bought + // no read at all, while its share of a settle grew from 7% of 5.7s to 20% of 2.0s. The premise was + // specific to reads being expensive, and it is not worth re-deriving from a sweep older than that. + if (!frameBeforeAction && initialWaitMs > 0) { + await wait(signal, Math.min(initialWaitMs, timeoutMs), 'wait for app to load') + } + log('waitForAppReady', `poll ${APP_READY_POLL_MS}ms, timeout ${timeoutMs / 1000}s`) + let previous: ReadonlySet | null = null + // A read that *errors* is not the same as a read that returns nothing: an empty tree means the + // app has not drawn yet and waiting is exactly right, while a failing read means the device is + // not answering us at all (a lost session most often), and no amount of further waiting will + // change that — the caller's next device call is what will surface the real error. Polling on + // regardless would spend this whole timeout per call, which is the difference between a deep + // link costing a second and costing half a minute once a session has dropped. + let consecutiveReadFailures = 0 + // Reads, not polls: the settle's cost is dominated by the accessibility snapshot, and "settled + // with 9 elements" alone never said whether that took the cheapest possible two reads or twenty. + // The two cases want opposite fixes — a faster read versus a screen that will not hold still — + // and the line could not tell them apart. + let reads = 0 + // What each inter-read sleep was waiting on. The `settle-poll` row of the phase table is exactly + // these sleeps (one per read but the last), so a sweep whose row doubled — 51.6s to 160.6s, with + // 245 calls — could say how long it waited and never what it waited *for*. Four causes, four + // different remedies: a launcher still clearing, a pre-action frame still on screen, a screen + // too small to be the app, or one that simply has not stopped changing. + const sleeps = { launcher: 0, staleFrame: 0, undersized: 0, unsettled: 0 } + const sleepSummary = (): string => + `sleeps: ${sleeps.staleFrame} stale-frame, ${sleeps.undersized} undersized, ${sleeps.launcher} launcher, ${sleeps.unsettled} unsettled` + let launcherRecoveries = 0 + let lastLauncherRecoveryAt = 0 + while (Date.now() < deadline) { + if (signal?.aborted) throw new Error('Cancelled') + const pressables = await capturePressables( + ctx, + device, + Math.max(1_000, Math.min(30_000, deadline - Date.now())), + // The poll already clamps each read to what is left; this makes the busy-retry backoff + // respect the same deadline instead of sleeping 42s of waits inside a 30s window. + { deadlineAt: deadline } + ).catch(() => { + consecutiveReadFailures += 1 + return null + }) + reads += 1 + if (pressables !== null) consecutiveReadFailures = 0 + else if (consecutiveReadFailures >= APP_READY_MAX_READ_FAILURES) { + log('waitForAppReady', `device stopped answering after ${consecutiveReadFailures} reads`) + return false + } + // The dev client's launcher is a settled, interactive screen, so every check below would + // happily call it the app and hand it to a judge. It is also the one screen that will never + // resolve itself by waiting — nobody is going to tap the server URL — so the only useful + // response is to put the app back on screen ourselves. + // + // Two different moves, in the order a person would try them. First tell the dev client where + // Metro is, which is what actually clears this screen and what the human fix (tapping a + // "Recently Opened" row) does. Only then fall back to relaunching the app, which is all that + // is available for a launcher-*like* screen this detector caught in an app with no dev client + // to address. A launcher still on screen after both is a genuine failure to report, not retry. + // + // Each move is given `LAUNCHER_RECOVERY_GRACE_MS` before the next one is considered, because + // neither takes effect by the time the very next poll reads the screen: a reconnect has to + // fetch and evaluate the bundle, and until it does the launcher is still what is drawn. + // Escalating on that first read would relaunch the app out from under the reconnect that was + // working, and then report a failure caused by the escalation itself. + if (pressables?.length && isDevClientLauncher(pressables)) { + const deferringRecovery = Date.now() - startedAt < deferLauncherRecoveryMs + const waitingOnRecovery = Date.now() - lastLauncherRecoveryAt < LAUNCHER_RECOVERY_GRACE_MS + if (deferringRecovery) { + if (!loggedLauncherDeferral) { + loggedLauncherDeferral = true + log( + 'waitForAppReady', + `dev-client launcher on screen with a recording armed — waiting up to ${( + deferLauncherRecoveryMs / 1000 + ).toFixed( + 1 + )}s for it to clear, because opening the app again here would abort publication` + ) + } + // The caller's budget was not sized for a bundle build any more than it was for a + // recovery, so the deferral extends the deadline the same way issuing one does. + deadline = Math.max(deadline, startedAt + deferLauncherRecoveryMs) + } else if (!waitingOnRecovery) { + if (requestedLauncherDeferralMs > 0 && !loggedLauncherRecoveryCause) { + loggedLauncherRecoveryCause = true + // Two different facts, and the log has to tell them apart: a deferral that has just run + // out on this call, versus one that was never spent because an earlier call already + // proved this launcher does not clear itself. + log( + 'waitForAppReady', + deferLauncherRecoveryMs > 0 + ? `dev-client launcher did not clear in ${(deferLauncherRecoveryMs / 1000).toFixed(1)}s — recovering, which will abort the armed recording` + : 'dev-client launcher on screen with a recording armed — recovering immediately, an earlier deferral on this device already ran out without it clearing' + ) + } + // Recorded whether or not this call spent the wait: the point is that this device's + // launcher has been observed not to clear on its own, and nothing about the next call + // makes that less true. + if (requestedLauncherDeferralMs > 0) device.launcherDeferral = 'expired' + // Marked before the open, not after: as far as agent-device is concerned the transaction + // is dead the moment the open succeeds, and a recovery that throws on the way back has + // still opened the app. `startReplayRecording` reads this flag the moment this wait + // returns and re-arms on top of whatever the recovery loaded, so the walk that follows is + // recorded into a transaction that can actually publish — see `armed-recording.ts`. + if (noteOpenWhileArmed(device.armedRecording)) { + log( + 'waitForAppReady', + `the recovery below aborts the armed recording (${device.armedRecording?.scriptPath}); it will be re-armed once the app is back` + ) + } + if (launcherRecoveries >= 2) { + log( + 'waitForAppReady', + `dev-client launcher still on screen after ${launcherRecoveries} recovery attempts` + ) + return false + } + const reconnected = + launcherRecoveries === 0 && (await reconnectDevClient(device, ctx.timing)) + if (reconnected) { + log('waitForAppReady', 'dev-client launcher on screen — reconnecting to Metro') + launcherRecoveries = 1 + } else { + log('waitForAppReady', 'dev-client launcher on screen — relaunching the bound app') + await launchBoundApp(device, ctx.timing) + // A relaunch is the last move available, whether it ran first (no dev-client URL to try) + // or second: counting it out here stops the loop asking for a third. + launcherRecoveries = 2 + } + lastLauncherRecoveryAt = Date.now() + // The caller's budget was sized for whatever it was originally waiting on — a router + // transition is 30s, and `settleAfterRoute` passes exactly that. None of those budgets + // were sized for a bundle build, so without this the call would issue the recovery and + // then time out while it was still working, handing the caller a launcher frame to judge + // and a walk to spend on the launcher's own controls. Extended only once a recovery has + // actually been issued, and only far enough to see it through. + deadline = Math.max(deadline, lastLauncherRecoveryAt + LAUNCHER_RECOVERY_GRACE_MS) + } + previous = null + // Deliberately not `APP_READY_POLL_MS`. That interval is tuned for the settle case, where + // the answer can arrive on the very next read and polling fast is what makes it cheap. Here + // the answer cannot arrive for `LAUNCHER_RECOVERY_GRACE_MS` by construction — the recovery + // above has just been issued and the loop is only waiting it out — so every read in that + // window is known-useless, and the runner is documented as fragile under back-to-back + // accessibility snapshot loops (see `openDeepLink`). Pinned to the cadence this branch had + // before the settle poll was sped up. + sleeps.launcher += 1 + await measurePhase(ctx, 'settle-poll', () => sleep(Math.max(APP_READY_POLL_MS, 1_500))) + continue + } + if (pressables?.length && !isLoadingScreen(pressables)) { + const current = fingerprint(pressables) + // Compared by token overlap, not by exact equality of the control list. Exact equality asks + // the screen to be byte-identical between two polls a second and a half apart, which a static + // settings page manages and a *feed* never does: a post loads, a like count ticks, an avatar + // resolves, and the list differs forever. Every content screen therefore ran the full 90s and + // logged "still loading" while sitting fully rendered in front of the poll. Overlap answers + // the question actually being asked — is this still the same screen — and the same 0.98 bar + // the exploration loop already uses for "that action changed nothing" applies here. + if (previous && similarity(previous, current) >= APP_READY_SETTLE_SIMILARITY) { + // Two reads agreeing is necessary but not sufficient: the screen from *before* the action + // is the most stable thing there is, precisely because nothing is happening on it. See + // `isFrameUnchangedSinceAction`. + if ( + frameBeforeAction && + isFrameUnchangedSinceAction({ + before: frameBeforeAction, + settled: current, + // The grace clock is not allowed to run at all until the guard has actually rejected + // `staleMinRejectedPairs` agreeing pairs — see that option. Reporting 0 elapsed is how + // that is said through a function whose contract is "elapsed versus grace", and it + // keeps `isFrameUnchangedSinceAction` free of a second, device-shaped concept. + elapsedMs: + staleRejectedPairs >= staleMinRejectedPairs ? Date.now() - staleClockStartedAt : 0, + graceMs: staleGraceMs, + similarityFloor: staleSimilarityFloor + }) + ) { + if (!heldStaleFrame) { + heldStaleFrame = true + log( + 'waitForAppReady', + `frame is still the one from before this action — waiting up to ${staleGraceMs / 1000}s for it to change` + ) + } + staleRejectedPairs += 1 + previous = current + sleeps.staleFrame += 1 + await measurePhase(ctx, 'settle-poll', () => sleep(APP_READY_POLL_MS)) + continue + } + // Stable but too small to be the app — see `minInteractiveElements`. Checked after the + // stale-frame guard so the two holds compose: a frame must be both new and substantial. + // `previous` is kept, so the moment the count clears the floor the very next read settles + // without a fresh agreeing pair. + if (pressables.length < minInteractiveElements) { + undersizedSettledAt ??= Date.now() + const heldForMs = Date.now() - undersizedSettledAt + if (heldForMs >= minInteractiveElementsGraceMs) { + // Out of grace with a settled frame in hand. Returning false rather than true keeps + // the floor honest: the caller asked whether it got a substantive frame, and it did + // not. Callers that capture regardless are unaffected — they ignore the return. + log( + 'waitForAppReady', + `stable at ${pressables.length} interactive elements for ${(heldForMs / 1000).toFixed(1)}s without reaching ${minInteractiveElements} — treating this as the app's own screen` + ) + return false + } + if (lastUndersizedCount !== pressables.length) { + lastUndersizedCount = pressables.length + log( + 'waitForAppReady', + `stable at ${pressables.length} interactive elements, below the ${minInteractiveElements} required — still waiting for the app to finish loading` + ) + } + previous = current + sleeps.undersized += 1 + await measurePhase(ctx, 'settle-poll', () => sleep(APP_READY_POLL_MS)) + continue + } + log( + 'waitForAppReady', + `settled with ${pressables.length} interactive elements after ${reads} reads in ${((Date.now() - startedAt) / 1000).toFixed(1)}s (${sleepSummary()})` + ) + return true + } + previous = current + } else { + previous = null + } + sleeps.unsettled += 1 + await measurePhase(ctx, 'settle-poll', () => sleep(APP_READY_POLL_MS)) + } + log( + 'waitForAppReady', + `still loading after ${timeoutMs / 1000}s and ${reads} reads, capturing anyway (${sleepSummary()})` + ) + return false +} + +/** + * A flat post-action sleep. Deliberately still a sleep: no caller of this function is followed by + * the before/after frame comparison that `settleAfterRoute` relies on to do without one. + * + * `ROUTE_WAIT_MS` is a *bound* only for `settleAfterRoute`, because that path alone goes through + * `waitForAppReady`, which captures `device.lastRead` as the pre-action frame. Every caller of + * this function skips that: `executeNavigationActions` sleeps after each tap, input and swipe and + * then returns to the next action or the next screenshot with no readiness poll in between; + * `probeUnmatchedRoute` follows it with a single `capturePressables`; and the manual-mode replay + * and session recorders do the same. Removing the sleep there would not hand the decision to a + * comparison, it would hand it to nothing, so the screenshot or read after a tap could land on the + * frame from before the tap and the vision judge would report a wrong screen — the failure that + * costs a full exploration, against 0.8s per tap saved. + * + * Even for `settleAfterRoute` the comparison is live (see `staleGraceMs`: a read costs 0.18s per + * `session_2026-10-09_17-22-18.log`, so two reads finish well inside the grace), but it is + * evidence that a *deep link* does not need the sleep, not that a tap does. The ~30 `wait for route — 0.8s` lines per sweep are all this + * function, from `executeNavigationActions`. Replacing them safely needs a post-tap settle that + * compares against the pre-tap frame, which is a behavioural change in the exploration loop and + * not something this sleep can be shortened into. + */ +export async function waitForRoute(signal?: AbortSignal, timeoutMs = ROUTE_WAIT_MS): Promise { + await wait(signal, Math.min(ROUTE_WAIT_MS, timeoutMs), 'wait for route') +} + +/** + * What to wait for after opening a deep link, before judging where it landed. + * + * A flat sleep is only ever right when the app was already on screen and settled — then the router + * has one transition to run and a couple of seconds covers it. It is wrong in the case this sweep + * hits constantly: a reach that lost its session leaves the next node's deep link firing into an + * app the recovery path has just relaunched, so when the sleep ends the frame is the splash screen + * or the home feed the cold start landed on, the judge honestly reports a mismatch, and a route + * that works perfectly from a terminal gets recorded as broken. So this is the stability poll + * `waitForAppReady` owns, with no launch sleep in front of it. + * + * The router beat is still accounted for, but as a bound rather than a cost: the settled *previous* + * screen is exactly what the poll would otherwise lock onto, and `waitForAppReady` now recognises + * it by comparison instead of being slept past it. + * + * Returns `waitForAppReady`'s own verdict — whether the app settled — for callers about to judge + * the frame that follows. See that function's doc comment. + */ +export async function settleAfterRoute( + ctx: CaptureContext, + device: CaptureDevice, + signal?: AbortSignal, + timeoutMs = ROUTE_SETTLE_TIMEOUT_MS, + options?: { minInteractiveElements?: number } +): Promise { + // Only an open from moments ago can be what this settle is waiting on. Not every caller of this + // function follows an `openDeepLink` — the deep-link probe's launch-readiness wait runs *before* + // its open — and anchoring the guard's clock to a URL opened a minute ago would start the grace + // already expired, which is the exact failure this change exists to remove. + const openedAt = device.lastRouteOpenAt + const staleClockStartedAt = + openedAt !== null && Date.now() - openedAt <= ROUTE_OPEN_CLOCK_MAX_AGE_MS ? openedAt : undefined + return waitForAppReady(ctx, device, signal, timeoutMs, { + minInteractiveElements: options?.minInteractiveElements, + // No sleep in front of the poll, and none needed: the pre-open frame is what the sleep was + // guarding against being mistaken for the destination, and `waitForAppReady` now compares + // against it directly. `ROUTE_WAIT_MS` survives as the ceiling on that comparison — the + // router beat it was always sized for — rather than as a cost every route pays. + initialWaitMs: 0, + staleGraceMs: ROUTE_STALE_GRACE_MS, + // The three things `session_2026-10-13_14-33-40.log` showed the guard needed and did not have: + // a floor that recognises a mid-transition composite (0.98 did not — see + // `APP_READY_STALE_SIMILARITY`), a clock that starts at the open rather than at this wait + // (~0.5s of the old 800ms went to the gap between them), and a minimum number of rejections so + // a fast read cannot settle before the guard has judged anything at all (PostLikedBy's first + // agreeing pair landed at 1.18s, past the whole grace). + staleSimilarityFloor: APP_READY_STALE_SIMILARITY, + staleClockStartedAt, + staleMinRejectedPairs: ROUTE_STALE_MIN_REJECTED_PAIRS + }) +} + +async function openUrlInSession( + ctx: CaptureContext, + device: CaptureDevice, + app: string, + url: string | null, + options?: { relaunch?: boolean } +): Promise { + try { + await openAppSession(device.client, app, { + url, + relaunch: options?.relaunch, + udid: device.deviceId, + timing: ctx.timing + }) + } catch (error) { + if (!isMissingSessionError(error)) throw error + log( + 'openApp', + `no active session, recovering — while opening ${app}${url ? ' with a URL' : ''} — ${describeSessionLoss(errorMessage(error), agentDeviceErrorHint(error))}` + ) + // `reopenBoundApp` waits for the app to be ready before returning, which is what makes the + // retry below a delivery rather than a URL thrown at a launching process — see its comment. + await reopenBoundApp(ctx, device) + await openAppSession(device.client, app, { + url, + relaunch: options?.relaunch, + udid: device.deviceId, + timing: ctx.timing + }) + } + await dismissOpenInAppPrompt(device, url) +} + +export function resolveDeepLink( + ctx: CaptureContext, + device: CaptureDevice, + node: AppFlowNode, + params: Record +): { app: string; url: string } { + const urlPath = buildUrlPath(node, params) + if (device.useCustomScheme && ctx.scheme) { + return { + app: device.launchTarget, + url: `${ctx.scheme}://${urlPath.replace(/^\//, '')}` + } + } + if (!device.launchUrl) { + throw new Error(`Screen ${node.id} has no usable native URL scheme`) + } + return { + app: device.launchTarget, + url: `exp://127.0.0.1:${device.metroPort}/--${urlPath}` + } +} + +export function buildAgentDevicePathToReach( + ctx: CaptureContext, + device: CaptureDevice, + node: AppFlowNode, + params: Record, + opened?: { app: string; url: string } +): string[] { + const target = opened ?? resolveDeepLink(ctx, device, node, params) + return [...device.setupCommands, agentDeviceOpenCommand(target.app, target.url)] +} + +/** + * What one deep-link open produced: where it was sent, and what was on screen just before it went. + * + * `preOpenPressables` is not new work — it is the liveness probe's own read (or the recent read + * that stood in for it), which this function already had in hand and threw away. Returning it is + * what lets the caller ask the question nothing in the sweep could previously answer: whether the + * frame it is about to photograph is the destination or the screen the app was already on. See + * `reach/frame-stability.ts`. Null when the probe read failed, which is not evidence either way. + */ +export interface DeepLinkOpenResult { + app: string + url: string + preOpenPressables?: AppFlowPressable[] | null +} + +export async function openDeepLink( + ctx: CaptureContext, + device: CaptureDevice, + node: AppFlowNode, + params: Record +): Promise { + const urlPath = buildUrlPath(node, params) + log('openDeepLink', `${node.id} → ${urlPath}`) + + // Make sure the app is actually up before handing it a URL. A deep link delivered to a process + // that is still cold-starting is the one the app drops: it finishes its own launch and lands on + // whatever it opens by default, which is why these routes open correctly from a terminal (where + // the app is already running) and mismatch here. The sweep reaches this state routinely — a lost + // session makes the delivery capture relaunch the app, and the very next node's deep link is + // then the first thing to touch it. + // + // One read, not a settle loop. The question here is only "is this process alive and drawing", + // and a single snapshot that comes back with interactive elements answers it — settling is the + // job of the `settleAfterRoute` poll that runs *after* the URL is opened, which is the one whose + // answer anything depends on. Running a full `waitForAppReady` here as well made this the most + // expensive thing in the sweep that nothing reads: two back-to-back polling loops around every + // deep link, each firing accessibility snapshots every 1.5s. That is precisely the load that + // blows the iOS runner's execution watchdog, so the harness was reliably wedging the runner just + // before the read it actually cares about — on device, every single deep link logged + // `runner busy` retries on the second poll and then gave up with "still loading after 30s". + // + // The cold-start case this exists for is still covered: an empty or failed read falls through to + // the full wait, which is the only situation that ever needed it. + // + // `retries: 0` is load-bearing, and its absence undid everything the paragraph above describes. + // A read inherits the busy-retry schedule by default, so this 5s probe was quietly sleeping + // 5 + 12 + 25 = 42s whenever the runner was busy — which, on the path this exists for, is + // *always*: the previous screen's activity is what wedged the runner in the first place. The + // probe then fell through to the 30s wait anyway, so the cheap pre-check cost 72s. A device log + // shows it eating a 60s probe budget whole before the URL was even opened, leaving the arrival + // judge no time to run and the deep-link probe permanently "inconclusive". + // + // Zero retries is the right number rather than a smaller schedule: a busy runner already means + // "not ready", which is exactly the fallback branch below, and that branch's own poll retries + // against a real deadline. + // The dev client's launcher is fully rendered and interactive — "Development Servers", + // "Recently Opened", "Enter URL manually" are all real pressables — so a bare "did we get + // anything back" check calls it alive and hands it the deep link below. The launcher has no + // router behind it to receive that link, so the app then "opens" straight into whatever the + // launcher itself does with an arbitrary URL tap, which is not the route this call asked for. + // `alive` is already in hand from the probe above, so this costs no extra device call, and + // falling through to `waitForAppReady` is correct because that is the function that owns + // launcher recovery (reconnect to Metro, then relaunch — see its comment above). + // + // The read itself is skipped when one from moments ago is already in hand — see + // `APP_ALIVE_REUSE_MS`. The checks below then run against that read instead, unchanged: a reused + // answer is held to exactly the same bar as a fresh one, so a stale launcher or loading screen + // still falls through to the full `waitForAppReady`. + const recent = device.lastRead + const reusable = recent && Date.now() - recent.at <= APP_ALIVE_REUSE_MS ? recent.pressables : null + if (reusable) log('openDeepLink', 'app was alive moments ago, skipping the liveness probe') + const alive = + reusable ?? + (await capturePressables(ctx, device, APP_ALIVE_PROBE_TIMEOUT_MS, { + retries: 0 + }).catch(() => null)) + if (!alive?.length || isLoadingScreen(alive) || isDevClientLauncher(alive)) { + await waitForAppReady(ctx, device, ctx.signal, ROUTE_SETTLE_TIMEOUT_MS, { initialWaitMs: 0 }) + } + // The last thing read before the URL goes out, which is the probe's own frame on the hot path + // and `waitForAppReady`'s final poll on the cold one. Taken here rather than reconstructed by + // the caller, because by the time the caller sees anything the app has already moved. + const preOpenPressables = device.lastRead?.pressables ?? alive ?? null + // Stamped on the way out of each `apps.open` below rather than once up here: the grace this + // feeds is the *router's* transition beat, and the probe and launcher recovery above can spend + // seconds before the URL is ever handed over. See `CaptureDevice.lastRouteOpenAt`. + + if (device.useCustomScheme && ctx.scheme) { + const deepPath = urlPath.replace(/^\//, '') + const url = `${ctx.scheme}://${deepPath}` + try { + await openUrlInSession(ctx, device, device.launchTarget, url) + device.lastRouteOpenAt = Date.now() + return { app: device.launchTarget, url, preOpenPressables } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!/failed to open/i.test(message)) throw error + } + } + + if (!device.launchUrl) { + throw new Error(`Screen ${node.id} has no usable native URL scheme`) + } + + const metroUrl = `exp://127.0.0.1:${device.metroPort}/--${urlPath}` + const app = device.launchTarget + await openUrlInSession(ctx, device, app, metroUrl) + device.lastRouteOpenAt = Date.now() + return { app, url: metroUrl, preOpenPressables } +} + +/** + * One bounded read of the current screen, answering only "is this the dev-client launcher right + * now" — nothing more. This exists because `waitForAppReady`'s boolean return conflates two + * different failures: "the app never came up" and "the app came up on the wrong screen". A caller + * outside `waitForAppReady` that gets `false` back cannot tell which one happened, and the states + * loop in orchestrator.ts needs exactly that distinction to decide whether to escalate to the user + * (a stuck launcher, which a person can clear in seconds by tapping into the app) or to treat it as + * an ordinary readiness timeout. + * + * A failed read is reported as "not the launcher", never as "is the launcher": a device that will + * not answer at all is a different, unrelated problem (a lost session, most often), and the + * caller's next device call is what should surface that error rather than this helper papering + * over it with a launcher diagnosis it has no evidence for. + */ +export async function isOnDevClientLauncher( + ctx: CaptureContext, + device: CaptureDevice, + timeoutMs = APP_ALIVE_PROBE_TIMEOUT_MS +): Promise { + const pressables = await capturePressables(ctx, device, timeoutMs, { retries: 0 }).catch( + () => null + ) + return !!pressables?.length && isDevClientLauncher(pressables) +} + +/** + * How long `startReplayRecording` waits for the dev-client launcher to clear on its own before + * letting `waitForAppReady` recover from it the usual way. + * + * Sized off the bundle load the recovery exists to avoid racing: the cold-cache figure + * `PROBE_LAUNCH_READY_BUDGET_MS` is built from is `iOS Bundled 16079ms (6130 modules)`, and a warm + * relaunch on the measured run cleared in about 2.5s. Twenty seconds covers the cold case with + * room over it, and is spent only when the launcher is actually on screen. + */ +const RECORDING_LAUNCHER_PATIENCE_MS = 20 * 1000 + +/** + * Start a clean agent-device recording. Exactly one app open is recorded. + * + * **One caller now: the manual recorder** (`manual/session.ts`). The sweep used to open every + * exploration walk with this, purely so the recorded script's first line would be + * `open --relaunch` — a cold start, a bundle reload and the launcher deferral below, paid once per + * walk for the artefact rather than for the walk, which on the 2026-10-13 sweep cost ~173s of 683s + * and published one script out of seven explorations. Exploration now writes down the commands it + * walked instead (`walk-commands.ts`) and resets with a soft re-root (`DevicePort.resetToLaunch`), + * so nothing in the sweep arms a recording any more. + * + * Everything below — the relaunch, the recorded `wait stable`, `RECORDING_LAUNCHER_PATIENCE_MS`, + * the re-arm, `armed-recording.ts` and `launcher-deferral.ts` — therefore survives for the manual + * recorder alone, which still publishes a real `.ad` script and still needs every one of them. + * None of it is reachable from a sweep. + */ +export async function startReplayRecording( + ctx: CaptureContext, + device: CaptureDevice, + scriptPath: string, + launchUrl: string | null = device.launchUrl, + timeoutMs = APP_READY_TIMEOUT_MS +): Promise { + try { + // Load-bearing, and more so now that the sweep leaves sessions open: a publish + // (`saveAndReplayRecording`'s non-repair branch) and a discard (`discardRecordingCandidate`) + // both hand this function a live, still-armed recording, and agent-device will not arm the + // `saveScript` open below over an existing transaction. This close is what discards it — + // unfinalized, so nothing is written — and it is also the only place the abandoned recordings + // those two paths leave behind are reliably cleaned up. + // + // The `relaunch: true` that follows is equally load-bearing, and for a reason that has nothing + // to do with getting back to a known state: it is the *recorded* first line of the candidate. + // A script whose opening step is `open --relaunch` replays standalone from any device state, + // which is the property every seed and every saved-replay rung depends on. Replacing it with a + // soft `goHome` would record a script that only replays when the app happens to already be + // running, so the cold start here is the price of the artefact, not of this walk. + await device.client.sessions.close() + } catch { + // The first recording attempt may not have an active session yet. + } + // The relaunch restarts the app cold, so the first screen is a splash again: the navigation + // actions that follow would tap into a loading screen and fail every attempt. + // + // Two waits, because they solve the same problem on two different clocks. `waitForAppReady` + // below polls accessibility from this process and is what makes the *live* walk safe — but it + // is our code, not a session command, so agent-device never sees it and it leaves no trace in + // the recorded script. At replay time the script therefore went straight from `open --relaunch` + // to the walk's first `press`, into an app that was still starting, and every explored journey + // died with "the recorded target no longer matches any current element" on its first action. + // + // `command.wait({ stable: true })` is a session command, so it *is* recorded, emitting a + // `wait stable ` line right behind the open — which is precisely what + // agent-device documents it for ("the fallback for open/relaunch/navigation"). It deliberately + // does not replace the destination guard `saveAndReplayRecording` appends: a stable wait is not + // a portable destination guard, and the guard has to be a selector wait on an identified + // landmark. This one only has to keep the replay from acting on a splash screen. + // + // Best-effort on purpose. A UI that never goes quiet within the budget — an indefinite spinner, + // a looping splash animation — throws `wait_stable_timeout` here, and letting that propagate + // would abandon a recording that `waitForAppReady` may still be able to rescue. Swallowing it + // costs nothing: the line is simply absent from the script, which is exactly where we were. + async function recordStableWait(): Promise { + try { + await measurePhase(ctx, 'device-wait', () => + device.client.command.wait({ + platform: ctx.platform, + stable: true, + quietMs: APP_STABLE_QUIET_MS, + timeoutMs: Math.max(1_000, Math.min(timeoutMs, APP_STABLE_TIMEOUT_MS)) + }) + ) + } catch (error) { + log('startReplayRecording', `stable wait not recorded — ${errorMessage(error)}`) + } + } + /** + * The open that arms the transaction, plus the recorded stable wait that has to sit behind it. + * + * A closure only so the re-arm below can reuse it without a second copy of the + * `saveScript`/`force` call and the `wait stable` line every recorded script needs in front of + * its first press. `relaunch` is the one thing that differs between the two uses, and the one + * thing that matters — see the re-arm's own comment. + */ + const arm = async (relaunch: boolean): Promise => { + await measurePhase(ctx, 'open-url', () => + device.client.apps.open({ + app: installedAppLaunchTarget(device.launchTarget), + platform: ctx.platform, + udid: device.deviceId, + ...(relaunch ? { relaunch: true } : {}), + saveScript: scriptPath, + force: true, + ...(launchUrl ? { url: launchUrl } : {}) + }) + ) + // Set the moment the open returns, so every `apps.open` this module issues from here on knows + // it would be the second one — `goHome` suppresses itself and the launcher recovery marks the + // transaction dead on its way past. See `armed-recording.ts`. + device.armedRecording = armRecording(scriptPath) + await dismissOpenInAppPrompt(device, launchUrl) + await recordStableWait() + } + await arm(true) + // The one caller that must not let this wait recover from the dev-client launcher: the open + // above armed the recording, and the recovery's own `apps.open` is the "second successful open" + // that aborts publication. See `deferLauncherRecoveryMs`. + // + // The substantive-frame floor is here for the reason it is everywhere else — stable is not + // loaded — but this was the last open in the sweep still without it, and the cost was visible in + // the 14-33-20 log: the arming open `settled with 3 interactive elements after 2 reads in 0.6s`, + // the planner was handed that splash as its first screenshot, and it spent a model call + // (1.5s, 1653 tokens) answering `{"type":"wait","duration":2000}` — buying with a round trip + // exactly what this wait is supposed to provide for free. Every recorded walk paid it. + // + // Graced rather than held to `timeoutMs` for the same reason as `manual/replay.ts`: a walk may + // legitimately begin on a sparse screen, and refusing to record one at all is worse than + // recording one that starts thin. + await waitForAppReady(ctx, device, ctx.signal, timeoutMs, { + deferLauncherRecoveryMs: RECORDING_LAUNCHER_PATIENCE_MS, + minInteractiveElements: PROBE_FRAME_ELEMENT_FLOOR, + minInteractiveElementsGraceMs: SUBSTANTIVE_FRAME_GRACE_MS + }) + // The deferral above buys time; it does not buy the outcome. On a dev client pointed at no Metro + // the launcher never clears on its own, so the wait runs out and recovers with an `apps.open` of + // the bundle URL — and from that moment the transaction armed a few lines up is dead, whatever + // the walk that follows manages to prove. That is the whole of the 2026-10-13 sweep's failure: + // six armed walks, six launcher recoveries, five verified arrivals thrown away at the publish + // with `aborted by a second successful open` (t=406.1, 456.2, 623.8, 679.7, 743.1), and the one + // node whose arming open happened to skip the launcher (`MyProfile`, t=153.4) the only one that + // produced a script. + // + // So the dead transaction is replaced rather than walked into. The close discards it without + // writing anything (the same discard this function opens with), and a second `saveScript` open + // arms a fresh one on top of the app the recovery has just finished loading. + // + // `relaunch: false` for that second open, and it is the only honest choice available: a + // relaunch is what put the dev client on its launcher in the first place, so re-arming with one + // would land on the launcher again, recover again, and abort again — the loop this is here to + // break. The cost is the property the opening comment of this function calls load-bearing: the + // re-armed script starts `open ` rather than `open --relaunch `, so it replays against + // a running app rather than from any device state. That is a weaker artefact than the cold-start + // script, and a strictly better one than the empty `flows/` directory this path has shipped on + // every sweep so far. It is also confined to exactly the devices that cannot produce the strong + // one: a launcher that clears within the deferral never reaches this branch at all. + if (needsRearm(device.armedRecording)) { + log( + 'startReplayRecording', + `re-arming ${scriptPath} after the launcher recovery — its open aborted the first transaction; the replacement records "open" rather than "open --relaunch"` + ) + try { + await device.client.sessions.close() + } catch { + // Same tolerance as the opening close: an aborted transaction may have taken the session + // with it, and there is nothing to discard if it did. + } + await arm(false) + // Without a relaunch there is no cold start to wait out, so this is only the substantive-frame + // floor the walk's first screenshot needs — and the launcher deferral is deliberately not + // requested again: the app is already loaded, and a launcher appearing here would mean the + // re-arm itself failed, which the walk's own first observation reports better than a second + // 20s wait would. + await waitForAppReady(ctx, device, ctx.signal, timeoutMs, { + minInteractiveElements: PROBE_FRAME_ELEMENT_FLOOR, + minInteractiveElementsGraceMs: SUBSTANTIVE_FRAME_GRACE_MS + }) + } + // The walk's first press is the one that has to survive a cold replay — see `pendingEntryGate`. + device.pendingEntryGate = true +} + +/** + * Press one locator, narrowing and retrying once if agent-device reports it ambiguous. + * + * agent-device refuses an ambiguous selector rather than guessing, and a caller deriving locators + * from `capturePressables` cannot see the ambiguity coming: that list deduplicates by the exact + * `{text,label,identifier,role}` object it builds, so two controls identical in all four — which + * Bluesky's two `label="Open drawer menu"` buttons are — collapse into one entry. Every locator + * derived from it therefore looks unique to us and is ambiguous on device. `hittable=true` is the + * narrowing that matches how the list was built in the first place (one of a rendered-but-off-canvas + * pair is not tappable), and it is tried only after the plain locator has actually failed this way, + * so the common single-match tap keeps its existing one-call cost and behaviour. + * + * Shared by `executeNavigationActions` (the sweep) and the manual recorder, which issues one press + * per user click and hits the same ambiguity on the same screens. + */ +export async function pressWithLocator( + ctx: CaptureContext, + device: CaptureDevice, + locator: { by: 'text' | 'label' | 'id'; value: string }, + timeoutMs = 10_000 +): Promise { + if (device.pendingEntryGate) await recordEntryGate(ctx, device, locator) + const pressWith = (qualifiers?: Readonly>): Promise => + measurePhase(ctx, 'press', () => + device.client.interactions.press({ + platform: ctx.platform, + ...toAgentDeviceTarget(parseInteractionTarget(serializeLocator(locator, qualifiers))), + settle: true, + settleQuietMs: SETTLE_QUIET_MS, + timeoutMs: Math.max(1, Math.min(10_000, timeoutMs)) + }) + ) + try { + return didSettle(await pressWith()) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!isAmbiguousSelectorMessage(message)) throw error + log( + 'pressWithLocator', + `${serializeLocator(locator)} matched several elements, retrying with hittable=true` + ) + return didSettle(await pressWith({ hittable: true })) + } +} + +/** + * Whether agent-device actually observed the UI go quiet after the action, rather than merely + * having been asked to. + * + * `settle` is documented as best-effort and never fails the action, so a `settle: true` request is + * a request and the result is the answer. The distinction is the whole basis for skipping the flat + * post-action sleep in `executeNavigationActions`: a settle that happened has already waited for + * exactly what the sleep was approximating, and a settle that did not leaves nothing behind it, so + * the sleep is still the only thing standing between the next screenshot and the pre-tap frame. + */ +function didSettle(result: unknown): boolean { + return (result as { settle?: { settled?: boolean } } | null)?.settle?.settled === true +} + +/** + * Writes a `wait` on this press's own target into the recording, immediately ahead of the press. + * + * `startReplayRecording` already explains why `waitForAppReady` leaves no trace in a recorded + * script and why a `wait stable` line was put behind the relaunch to stand in for it. The measured + * run says that stand-in is not enough: three of four explored journeys still died at their + * script's *first* press with "the recorded target no longer matches any current element", and + * `profile.ad` — a script that had just replayed successfully — failed the same way twenty seconds + * later when the next screen tried to seed from it. An unchanged script failing intermittently + * against an unchanged app is a readiness race, not locator drift. + * + * Stability is simply the wrong question, and `probe-route.ts` already paid to learn it: a splash + * screen is perfectly stable, and this app's first readiness check settled on a dev-client loading + * frame carrying 2 interactive elements. `wait stable` is satisfied by that frame, the recorded + * press fires into it, and there is no tab bar there to press. A wait on the press's own target + * cannot be satisfied early, because the element it names is not on a splash screen. + * + * Why the press's own target and not a landmark of the entry screen: a landmark has to be *chosen*, + * and the obvious choice — the first identified control on the settled frame — is routinely a piece + * of feed content whose id does not exist on the next launch. That gate would expire on every + * replay and cost the full timeout before pressing anyway. The press target needs no choosing and + * is exactly the element the replay is about to require. + * + * Only the first press of a recording is gated. It is the one that runs against a cold app, and + * the measured failures are all on it; every later press happens on a screen the previous action + * already brought up. Gating all of them would also put this timeout in front of every genuinely + * stale locator during a flailing walk, which is a real cost for no measured benefit. + * + * Best-effort, like the stable wait it backs up: when the wait itself fails the press still runs, + * because the press's own error is the more informative one and swallowing this leaves the script + * exactly where it already was. + */ +async function recordEntryGate( + ctx: CaptureContext, + device: CaptureDevice, + locator: { by: 'text' | 'label' | 'id'; value: string } +): Promise { + // Cleared before the attempt, not after it: this is the first press either way, and a failed + // gate must not leave the flag set for the next press on a screen that never needed one. + device.pendingEntryGate = false + const selector = serializeLocator(locator) + try { + await measurePhase(ctx, 'device-wait', () => + device.client.command.wait({ + platform: ctx.platform, + selector, + timeoutMs: ENTRY_GATE_TIMEOUT_MS + }) + ) + log('pressWithLocator', `entry gate ${selector}`) + } catch (error) { + log('pressWithLocator', `entry gate ${selector} not recorded — ${errorMessage(error)}`) + } +} + +export async function executeNavigationActions( + ctx: CaptureContext, + device: CaptureDevice, + actions: readonly AppFlowNavigationAction[], + timeoutMs?: number +): Promise { + const deadline = Date.now() + (timeoutMs ?? 12_500 * Math.max(1, actions.length)) + for (let index = 0; index < actions.length; index++) { + if (ctx.signal?.aborted) throw new Error('Cancelled') + if (Date.now() >= deadline) throw new Error('Navigation action budget exhausted') + const action = actions[index] + // Whether the action itself waited for the UI to go quiet. `press` and `fill` ask for a settle + // and report back; `scroll` has no settle option at all, so a swipe always falls through to the + // sleep below. + let settled = false + switch (action.type) { + case 'tap': { + if (!action.locator) throw new Error('Tap action requires a locator') + settled = await pressWithLocator( + ctx, + device, + action.locator, + Math.max(1, Math.min(10_000, deadline - Date.now())) + ) + break + } + case 'input': + if (!action.locator || action.value == null) + throw new Error('Input action requires locator and value') + settled = didSettle( + await device.client.interactions.fill({ + platform: ctx.platform, + ...toAgentDeviceTarget(parseInteractionTarget(serializeLocator(action.locator))), + text: action.value, + settle: true, + settleQuietMs: SETTLE_QUIET_MS, + timeoutMs: Math.max(1, Math.min(10_000, deadline - Date.now())) + }) + ) + break + case 'swipe': + await device.client.interactions.scroll({ + platform: ctx.platform, + direction: action.direction ?? 'up' + }) + break + case 'wait': + await measurePhase(ctx, 'device-wait', () => + device.client.command.wait({ + platform: ctx.platform, + durationMs: Math.max( + 0, + Math.min(action.duration ?? 1000, 10_000, deadline - Date.now()) + ) + }) + ) + break + } + // The sleep is what stops the next screenshot or accessibility read from landing on the frame + // from *before* this action — a mistake that costs a wrong capture and a full exploration, + // against 0.8s saved. So it is skipped only on the evidence that something equivalent already + // happened: agent-device reporting that it watched the UI go quiet after the action + // (`settleQuietMs` above). A settle that was requested but not observed, and a swipe, which + // cannot request one, both still pay it. + if (action.type !== 'wait' && !settled) { + await waitForRoute(ctx.signal, Math.max(0, deadline - Date.now())) + } + } +} + +// `verifyAndSaveReplay` lived here: save the live recording, close the session and replay the file +// to prove it. Its one caller was the runtime state-variant pass in `orchestrator.ts`, which has +// since been removed. + +/** + * Drop the recorded identity of every press that hangs off the node a divergence proved unstable, + * give each unguarded press a selector wait in its place, and replay the result once. + * + * The only recovery tier there is. It used to be the second of two, reached once agent-device's + * record-and-heal had declined, and the heal is gone because every firing of it this repo recorded + * was a failure — the 20-05-23 evidence below, and the same three-seed result on the 15-07-13 + * sweep (`replay-divergence.ts`). There was never a fallback chain to lose, either: the two tiers + * were selected by complementary predicates, so at most one of them could ever run on a given + * divergence. See `unguardPressSteps` for why re-recording provably cannot fix this class, what the + * strip gives up and what the inserted wait puts back. + * + * One pass, where this used to loop a strip and a replay per diverging step. The loop was charging + * a full replay for information the first report already carried: instability belongs to a *node*, + * and `ProfileFollowers` recorded that same node in the ancestry of both its presses. Measured on + * the 15-53-19 sweep it spent 269-286s on two rounds, published nothing, and left the screen that + * depended on it to walk itself from a cold launch. `unguardPressSteps` now acts on every press + * sharing the proven ancestry at once, so there is one replay to pay for and nothing to iterate. + * + * Still evidence-gated, never optimistic. `parseStructuralOnlyMismatchStep` returns a step only + * for an `IDENTITY_MISMATCH` with `matchCount === 1`, no competing candidate and no mismatched + * field outside `ancestry[...]` and `position:` — the two that describe where the node sits rather + * than what it is, and the two a replay cannot influence. Anything else — a `SELECTOR_MISS`, an + * ambiguous match, a label that moved — declines with the original failure intact, as does + * `unguardPressSteps` finding nothing to do, which is what stops a script already stripped from + * being processed twice. + * + * `position:` is why this tier sees the walks that used to die in the heal. On the 20-05-23 sweep + * `MyProfile`, `Profile` and `ProfileFollowers` all failed their tab-bar press with a position + * mismatch reported alongside the ancestry one, which left the divergence looking heal-shaped: + * all three were healed, all three heals reproduced the failure, and all three candidates were + * discarded — 117s, 39% of that sweep, that published nothing. Counting position as structural + * routes them here instead, because dropping the annotation stops pre-action verification running + * and takes the position check with it. + * + * Only a failure carrying no divergence at all is retried, and only once: a divergence is the app + * disagreeing with the script and repeating it changes nothing, whereas a bare `Daemon request + * timed out` is the plumbing, and discarding a candidate over it reports a failure the app never + * had. Measured: `Profile` lost its seed to exactly that, after a strip nothing suggests was wrong. + * + * The original bytes are restored on failure. A preserved `.failed` candidate is read to find out + * what the walk recorded, and leaving an edited script there would misreport that. + */ +async function stripIdentityAndReplay( + ctx: CaptureContext, + device: CaptureDevice, + scriptPath: string, + destinationSelector: string, + error: unknown, + deadline: number +): Promise { + const step = parseStructuralOnlyMismatchStep(error) + if (step === undefined) return false + + const original = await readFile(scriptPath, 'utf8').catch(() => undefined) + if (original === undefined) return false + + const unguarded = unguardPressSteps(original.split('\n'), step) + if (!unguarded) return false + + // The failed replay above was armed with `saveScript`, so it is holding a repair transaction and + // would refuse to be replaced. A bare close ends the transaction without writing anything, which + // is exactly right here: the script on disk is the one being edited, not one to be published. + await device.client.sessions.close().catch(() => undefined) + + const attempt = async (): Promise => { + await runRepairedReplay(ctx, device, scriptPath, deadline) + await measurePhase(ctx, 'device-wait', () => + device.client.command.wait({ + platform: ctx.platform, + selector: destinationSelector, + timeoutMs: 15_000 + }) + ) + } + + log( + 'saveAndReplayRecording', + `dropped recorded identity at step(s) ${unguarded.stripped.join(', ')} of ${scriptPath}` + + (unguarded.guarded.length > 0 + ? `; inserted a selector wait before step(s) ${unguarded.guarded.join(', ')}` + : '') + ) + await writeFile(scriptPath, unguarded.lines.join('\n'), 'utf8') + + let transientRetry = true + while (Date.now() < deadline) { + try { + await attempt() + log( + 'saveAndReplayRecording', + `verified ${scriptPath} with ${unguarded.stripped.length} recorded identity(ies) dropped` + ) + return true + } catch (replayError) { + const divergence = describeReplayDivergence(replayError) + log( + 'saveAndReplayRecording', + `${scriptPath} still diverges after unguarding — ${errorMessage(replayError)}${ + divergence ? ` — divergence: ${divergence}` : '' + }` + ) + if (divergence !== undefined || !transientRetry) break + transientRetry = false + log('saveAndReplayRecording', `no divergence reported — retrying ${scriptPath} once`) + } + } + + await writeFile(scriptPath, original, 'utf8').catch(() => undefined) + log('saveAndReplayRecording', `restored ${scriptPath}; stripping did not make it replayable`) + return false +} + +async function runRepairedReplay( + ctx: CaptureContext, + device: CaptureDevice, + scriptPath: string, + deadline: number +): Promise { + const replay = await measurePhase(ctx, 'replay', () => + device.client.replay.run({ + path: scriptPath, + platform: ctx.platform, + udid: device.deviceId, + keepSession: true, + timeoutMs: Math.max(1_000, deadline - Date.now()) + }) + ) as { sessionActive: boolean } + if (!replay.sessionActive) + throw new Error(`Repaired replay ${scriptPath} did not keep an active session`) + await waitForAppReady( + ctx, + device, + ctx.signal, + Math.max(0, Math.min(APP_READY_TIMEOUT_MS, deadline - Date.now())) + ) +} + +/** + * Save a recorded journey and replay it, leaving the verified session available to inspect. + * + * `destinationSelector` is not optional and not a nicety: agent-device's `sessions.saveScript` + * refuses to publish an active-session script unless, after the last action it classifies as + * mutating (every tap, fill and gesture the walk made), there is a `wait` on a selector whose + * matched element it captured a re-verifiable identity for. That is the "portable destination + * guard" in its error message, and `force: true` does not bypass it — force only governs + * overwriting an existing file, while the guard scan runs before anything is written. So the wait + * below is the only reason a saved exploration script can exist at all; without it every call here + * throws and an already-verified journey is lost. + * + * The selector must resolve to an element bearing an id or an accessibility label, because + * agent-device declines to record landmark evidence for a node with neither and then rejects the + * guard for having no evidence. `serializeLocator` on a `by: 'label' | 'id'` locator guarantees + * that by construction; `by: 'text'` does not, which is why callers must not pass one. + * + * `repairArmed` selects which of agent-device's two mutually exclusive publication paths to use, + * and getting it wrong does not degrade gracefully — it throws. A session opened by + * `replay.run({ saveScript })` is *repair-armed* (ADR 0012 decision 6: that option "arms + * agent-supervised re-record repair from this replay attempt onward"), and such a session refuses + * `sessions.saveScript` outright with "This session has an active .ad repair transaction and + * cannot use ordinary active-session publication." It publishes only through its own finalize, + * which is `sessions.close({ saveScript })` — the case agent-device's own `SessionCloseResult` + * doc calls "a repair-armed session's finalize". + * + * This mattered far more than one failed script. A sweep's seeded walks all start with + * `replay.run({ saveScript })` on a parent's verified replay, so every one of them was repair-armed + * and every one failed to publish. A screen with no saved replay cannot become a seed for the next + * screen, so the seed pool froze at whatever had been captured before the first seeded walk: in the + * measured run that was three screens at t=246s, and all twelve subsequent bounded hops seeded from + * the same stale `Profile` script, each paying a ~10s replay plus a full multi-step walk for a hop + * that should have been one tap from its real parent. + */ +export async function saveAndReplayRecording( + ctx: CaptureContext, + device: CaptureDevice, + scriptPath: string, + destinationSelector: string, + timeoutMs = 120_000, + { + repairArmed = false, + verifyByReplay = true + }: { repairArmed?: boolean; verifyByReplay?: boolean } = {} +): Promise { + const deadline = Date.now() + timeoutMs + await measurePhase(ctx, 'device-wait', () => + device.client.command.wait({ + platform: ctx.platform, + selector: destinationSelector, + timeoutMs: Math.max(1_000, Math.min(15_000, deadline - Date.now())) + }) + ) + // Publication is the end of the transaction either way — it writes the file on one path and + // finalizes the session on the other — and a publication that *throws* has ended it just as + // surely (an abort is what the throw usually says). So the marker comes down before the attempt + // rather than after a successful one: from here on there is nothing left for `goHome` to + // protect, and the discard the caller runs on failure wants its soft home back. + device.armedRecording = null + if (repairArmed) { + // One call, not two: the close *is* the publication here, so there is no `saveScript` step to + // precede it. `force` carries the same meaning on both paths — overwrite an existing target + // rather than refuse — and the candidate path is passed again even though the arming + // `replay.run` already named it, because close's own `saveScript` is what decides where the + // healed script lands and leaving it to the documented `.healed.ad` default would put + // the file somewhere `publishScript` is not looking for it. + await device.client.sessions.close({ saveScript: scriptPath, force: true }) + } else { + // `saveScript` writes the file; nothing after it is publication. The bare `sessions.close()` + // that used to follow was pure teardown — and on this harness teardown is not free: closing an + // agent-device session tears down the XCUITest runner and takes the app under test with it + // (see `reopenBoundApp`), so every successful walk ended by killing the app it had just proven + // a journey through. The next node then opened on a dead process: a deep-link rung paying a + // full cold start (`iOS Bundled 16079ms (6130 modules)` on a cold cache, ~2.5s warm) for a + // navigation the live app would have answered immediately. + // + // Leaving the session open costs nothing it did not already cost. The script is on disk, so + // nothing further is owed to it; the recording stays armed and keeps absorbing whatever device + // commands come next, and that buffer is never read — the next walk's `startReplayRecording` + // opens with its own `sessions.close()` (which discards an unfinalized transaction without + // writing), and every other site that arms a `--save-script` transaction now ends this one + // first through `endLiveSession`, which is what keeps agent-device from refusing the new + // arming over a live one. + // + // The close is only skipped on this path. `repairArmed` above still closes because there the + // close *is* the publication, and the `verifyByReplay` path below still closes because + // `verifyAndRepairReplay` arms a repair transaction of its own — it calls `endLiveSession` + // itself, where a close is free because the replay it is about to run relaunches the app from + // the script's leading `open --relaunch` anyway. + await device.client.sessions.saveScript({ path: scriptPath, force: true }) + } + + // `verifyByReplay: false` stops here, with the script on disk, nothing replayed, and — since + // the save above no longer closes — the app still running on the destination screen. + // + // The replay below re-runs the journey that has just been walked, and its script opens with + // `open --relaunch`, so every verification is a cold app start plus the whole walk again. On the + // 20-05-23 sweep that was 11 replays, 54.4s, 18% of the run — and not one of the scripts it + // verified was ever used as a seed, because nothing downstream of those screens was reached + // before the sweep ended. + // + // What the replay proves is real and is not reproduced anywhere else: the live walk dispatched + // its presses against the tree it was reading, with no identity verification, so a replay is the + // first time the recorded `target-v1` evidence is re-read against a fresh tree. That is the + // entire IDENTITY_MISMATCH class, and on the same sweep it caught three genuinely broken + // scripts. It is not redundant — it is early. The caller that skips it is not giving the check + // up, it is moving it to the first replay that was going to happen anyway: a seeded walk opens + // with `replay.run({ path: seed, saveScript })`, which replays the seed and arms the next + // recording in one call, so the seed's verification is already paid for there. A script nothing + // ever seeds off is published unverified and says so (`replayVerified: false`), which is a + // better artefact than the verified one we were paying a cold relaunch to produce and then + // never reading. + // + // The recovery tier goes with it, necessarily — `stripIdentityAndReplay` is driven by a + // divergence report, and there is no report without a replay. It runs unchanged at the deferred + // verification instead. + if (!verifyByReplay) return + + await verifyAndRepairReplay(ctx, device, scriptPath, destinationSelector, deadline) +} + +/** + * Replay a script that is already on disk, and repair it in place if it diverges. + * + * The second half of what `saveAndReplayRecording` used to do inline, lifted out because it is no + * longer only that function's business. Verification now happens at first use rather than at + * publication, so the caller that discovers a divergence is usually the saved-replay rung, and it + * needs the strip — otherwise deferring verification would quietly mean giving repair up, and the + * one script the strip rescued on the 20-05-23 sweep (`SearchTab/Search`) would have stayed broken + * for every sweep that followed. + * + * `destinationSelector` is where the strip waits after rewriting a step. A caller that recorded the + * script has it in hand; one that did not reads it back off the script's trailing guard with + * `destinationGuardSelector`. + * + * `replayError` is the divergence the caller is already holding, and passing it is what skips the + * opening replay below. The callers genuinely differ here, so it is optional rather than required: + * `saveAndReplayRecording`, `savedReplayStrategy` and the manual replay button have *not* replayed + * the script — for them this call is the replay, and its success is what leaves the app on the + * destination with a live session. Only `replaySeedBase` arrives after a failed armed replay of + * this same script, and for it the opening replay re-derived, at a measured ~6.2s, a divergence it + * already had in hand. Nothing downstream needs the session that replay opened: the strip's first + * act is to close whatever session is live (it must, to release the caller's armed repair + * transaction) and its `runRepairedReplay` relaunches from the script's leading `open --relaunch`, + * carrying its own `waitForAppReady`. + */ +export async function verifyAndRepairReplay( + ctx: CaptureContext, + device: CaptureDevice, + scriptPath: string, + destinationSelector: string, + deadline: number, + replayError?: unknown +): Promise { + // Whatever session the caller was holding ends here, before the armed replay below asks for one + // of its own. Two callers can arrive with a live session now: `saveAndReplayRecording`'s + // non-repair path, which stopped closing after `saveScript` so the app survives into the next + // node, and the saved-replay rung, which may be reached with the previous node's recording still + // armed. Either way agent-device will not arm a second `--save-script` transaction over a live + // one — `stripIdentityAndReplay` names the refusal — and the close is free here regardless, + // because `runReplay` immediately replays a script whose first line is `open --relaunch`. + await endLiveSession(device) + // The script is on disk by this point, so recovery below only has to redo the replay, never the + // walk that produced it: `runReplay` is what a retry re-runs after the app is relaunched. + // + // `saveScript` here is not a second publication — the script was already written above — it is + // what arms the repair transaction, and arming is the whole reason a divergence is survivable: + // an unarmed replay reports the divergence and is done with it, while an armed one holds the + // session open at the failed step. On the clean path the arming costs nothing: nothing finalizes + // the transaction with `sessions.close({ saveScript })`, and a transaction nobody finalizes + // simply never writes. + const runReplay = async (): Promise => { + const replayTimeoutMs = Math.max(1_000, deadline - Date.now()) + const replay = await measurePhase(ctx, 'replay', () => + device.client.replay.run({ + path: scriptPath, + platform: ctx.platform, + udid: device.deviceId, + keepSession: true, + saveScript: scriptPath, + force: true, + timeoutMs: replayTimeoutMs + }) + ) as { sessionActive: boolean } + if (!replay.sessionActive) + throw new Error(`Replay ${scriptPath} did not keep an active session`) + await waitForAppReady( + ctx, + device, + ctx.signal, + Math.max(0, Math.min(APP_READY_TIMEOUT_MS, deadline - Date.now())) + ) + } + + let error = replayError + if (error === undefined) { + try { + await runReplay() + return + } catch (firstFailure) { + error = firstFailure + } + } + + if (await stripIdentityAndReplay(ctx, device, scriptPath, destinationSelector, error, deadline)) + return + // See `isMissingSessionError`'s doc comment: a daemon replaced mid-walk kills whatever session + // `replay.run` opens for itself, and that death can surface as this generic "could not capture + // a fresh snapshot" wrapper instead of the literal "no active session" wording — the same + // underlying cause `captureScreenshot` above recovers from. One retry only: if the replay still + // cannot get a session back after `reopenBoundApp`, this is a real replay failure and must + // propagate rather than loop. + if (!isMissingSessionError(error)) throw error + log( + 'saveAndReplayRecording', + `no active session mid-replay, recovering — ${describeSessionLoss(errorMessage(error), agentDeviceErrorHint(error))}` + ) + await reopenBoundApp(ctx, device) + await runReplay() +} + +// `recordStateFromReplay` lived here: replay a base screen's `.ad` armed with `saveScript`, run a +// fixed action list, and finalize the repair transaction into a state-variant script. Same caller, +// same reason it is gone — see the note on `verifyAndSaveReplay` above. + +/** The one-line `pathToReach` form for a published `.ad`. Only the manual recorder produces one + * now; `buildAgentDeviceScreens` still reads it for a capture that carries a manual script. */ +export function replayPathToReach(device: CaptureDevice, scriptPath: string): string[] { + return [...device.setupCommands, `agent-device replay ${quoteCliArg(scriptPath)} --keep-session`] +} + +/** + * `relaunched: true` means the missing-session recovery branch fired: `reopenBoundApp` relaunches + * the app at its launch URL before the screenshot is taken, so the frame this call returns is the + * *launch screen*, not whatever the caller had navigated to. Callers must not record that frame as + * the target's capture — see `decideDelivery` in `reach/delivery.ts`, which is what this flag exists + * to feed. The other recovery call sites (`openUrlInSession`) re-open the original URL afterwards + * and so never need this signal. + */ +export async function captureScreenshot( + ctx: CaptureContext, + device: CaptureDevice, + path: string +): Promise<{ relaunched: boolean }> { + log('screenshot', path) + try { + // A screenshot is a read: re-running it whole is safe, and a busy runner is the normal + // aftermath of dozens of taps and accessibility snapshots on an animating screen — exactly + // what exploration does right before the screenshot that confirms arrival, making this the + // call in the sweep most likely to hit the watchdog and the one that least deserves to lose a + // verified arrival over it. Mirrors `capturePressables` below. + await measurePhase(ctx, 'screenshot', () => + retryWhileRunnerBusy(() => device.client.capture.screenshot({ path }), { + signal: ctx.signal, + onRetry: (attempt, waitMs) => + log('screenshot', `runner busy; retry ${attempt} in ${Math.round(waitMs / 1000)}s`) + }) + ) + log('screenshot', 'saved') + return { relaunched: false } + } catch (error) { + if (!isMissingSessionError(error)) throw error + // `path` names the caller's frame (`verify-deep-link-check.png`, `explore.png`, a delivery + // capture), which is the only record of what the sweep was in the middle of when the session + // turned out to be gone. + log( + 'screenshot', + `no active session, recovering — while saving ${basename(path)} — ${describeSessionLoss(errorMessage(error), agentDeviceErrorHint(error))}` + ) + // Waits for the relaunched app to settle. The frame itself is still reported as `relaunched` + // and must not be recorded as the target's capture, but everything the sweep does *after* this + // recovery — the next deep link, above all — now finds an app that can act on it. + await reopenBoundApp(ctx, device) + await measurePhase(ctx, 'screenshot', () => device.client.capture.screenshot({ path })) + return { relaunched: true } + } +} + +// A path no router can match. Long and self-describing so it is recognisable in Metro logs. +const UNMATCHED_PROBE_PATH = '__app-flow-probe-no-such-route' + +/** + * Opens a deep link that cannot match any route and reports what the app renders for it. + * + * That screen is the reference for "this capture never arrived": a route whose capture looks the + * same as an impossible route's either did not resolve, or never got the chance because something + * intercepted the navigation. This is screenmap's bogus-param probe one level up — route instead + * of param — and it needs to know nothing about what the intercepting screen *is*. + * + * Returns null when the app has no usable URL scheme, in which case arrival cannot be checked and + * callers must treat captures as unverified rather than guess. + */ +export async function probeUnmatchedRoute( + ctx: CaptureContext, + device: CaptureDevice +): Promise { + const url = + device.useCustomScheme && ctx.scheme + ? `${ctx.scheme}://${UNMATCHED_PROBE_PATH}` + : device.launchUrl + ? `exp://127.0.0.1:${device.metroPort}/--/${UNMATCHED_PROBE_PATH}` + : null + if (!url) return null + log('probeUnmatchedRoute', url) + try { + await openUrlInSession(ctx, device, device.launchTarget, url) + await waitForRoute(ctx.signal) + return await capturePressables(ctx, device) + } catch (error) { + log('probeUnmatchedRoute', `failed: ${error instanceof Error ? error.message : String(error)}`) + return null + } +} + +export async function capturePressables( + ctx: CaptureContext, + device: CaptureDevice, + timeoutMs = 30_000, + options?: { + /** + * Absolute timestamp past which this read is of no use to the caller, so the busy-retry backoff + * can stop short instead of sleeping into it — see `RunnerBusyRetryOptions.deadlineAt`. Callers + * that are themselves on a clock (`waitForAppReady`) pass theirs; a one-off read has no deadline + * beyond `timeoutMs` and omits it, keeping the full retry schedule. + */ + deadlineAt?: number + /** + * How many times a busy runner is retried. Defaults to the full schedule. A caller whose read + * is a *question about whether the device is answering at all* passes 0: for it, `RUNNER_BUSY` + * is not an obstacle in front of the answer, it is the answer. + */ + retries?: number + } +): Promise { + // A snapshot is a read: re-running it whole is safe, and a busy runner is the normal aftermath + // of a watchdogged capture on a heavy or animating screen rather than a failure of this screen. + const snapshot = await measurePhase(ctx, 'device-read', () => + retryWhileRunnerBusy( + () => + device.client.capture.snapshot({ + platform: ctx.platform, + // The client defaults to the compact digest response level for routine App + // Flow commands. Snapshot extraction needs the typed node payload instead. + responseLevel: 'default', + interactiveOnly: true, + // No `customActions`. It was requested on every read in this module and consumed by + // none of them: the node loop below keeps `value`, `label`, `identifier` and `role`, + // and nothing downstream of `AppFlowPressable` has ever had a field to put an + // accessibility custom action in. On the iOS simulator the flag is not free — it + // selects a capability the snapshot backend then has to be admitted for, and enumerates + // the custom actions of every merged element — so this was per-element device work on + // all 114 reads of the reference sweep in service of a field that was dropped on the + // floor. How much of the measured 1.56s it was buying is exactly what the next sweep + // will say; the case for removing it does not depend on the answer. + forceFull: true, + noRecord: true, + timeoutMs + }), + { + signal: ctx.signal, + deadlineAt: options?.deadlineAt, + retries: options?.retries, + onRetry: (attempt, waitMs) => + log('capturePressables', `runner busy; retry ${attempt} in ${Math.round(waitMs / 1000)}s`) + } + ) + ) as { nodes?: unknown } + + if (!Array.isArray(snapshot.nodes)) { + throw new Error('agent-device snapshot did not return a nodes array') + } + + const seen = new Set() + const pressables: AppFlowPressable[] = [] + + for (const nodeValue of snapshot.nodes) { + if (!nodeValue || typeof nodeValue !== 'object') continue + const node = nodeValue as { + visibleToUser?: boolean + enabled?: boolean + role?: string + type?: string + value?: string + label?: string + identifier?: string + } + // The snapshot is already scoped to interactive elements. React Native Pressable + // children are commonly exposed by iOS as StaticText without a button role or a + // truthy hittable field, even though agent-device can press them by text/label. + // Filtering those fields here would discard the exact selector candidates we need. + if (node.visibleToUser === false || node.enabled === false) continue + + const semanticType = `${node.role ?? node.type ?? ''}` + .toLowerCase() + .replace(/^xcuielementtype/, '') + if (['application', 'window', 'scrollview', 'scroll-view'].includes(semanticType)) continue + + const pressable: AppFlowPressable = { + ...(node.value ? { text: node.value } : {}), + ...(node.label ? { label: node.label } : {}), + ...(node.identifier ? { identifier: node.identifier } : {}), + ...(node.role || node.type ? { role: node.role ?? node.type } : {}) + } + if (![pressable.text, pressable.label, pressable.identifier].some((value) => value?.trim())) { + continue + } + + const identity = JSON.stringify(pressable) + if (seen.has(identity)) continue + seen.add(identity) + pressables.push(pressable) + } + + // Every successful read, not just the ones a caller keeps. This is the only funnel for + // accessibility reads in this module, so stamping it here is what lets `openDeepLink` answer + // "is the process alive and drawing" without spending a snapshot of its own. + device.lastRead = { at: Date.now(), pressables } + return pressables +} + +function stopLaunchProcess(child: ChildProcess | null): void { + if (!child?.pid) return + try { + child.kill('SIGTERM') + } catch { + // Process already exited. + } +} + +export async function closeDevice(device: CaptureDevice): Promise { + log('closeDevice', 'stop Metro if we started it, sessions.close --shutdown') + stopLaunchProcess(device.launchProcess) + await endMetroLogFile(device.metroLogPath) + try { + await device.client.sessions.close({ shutdown: true }) + log('sessions.close', 'done') + } catch (error) { + console.error('Failed to close agent-device session:', error) + } +} diff --git a/src/app-flow/cdp/agent-runtime.ts b/src/app-flow/cdp/agent-runtime.ts new file mode 100644 index 00000000..8de0a02b --- /dev/null +++ b/src/app-flow/cdp/agent-runtime.ts @@ -0,0 +1,4339 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, no-var, @typescript-eslint/no-unused-vars, prefer-rest-params, @typescript-eslint/no-this-alias -- + `any`: everything below reads objects owned by the app under test (fibers, navigation state, + props). None of it has a type we control, and a fake hierarchy would only pretend otherwise. + `var`: how the existing functions are written, see the syntax rules below; `let`/`const` are + allowed now but rewriting the existing bodies would be churn with no benefit. Unused catch bindings: `catch (error)` is kept + over the newer binding-less form for the same reason. `arguments` and `this` aliasing: the + patch wrappers forward whatever the app passed, of any arity, with its own receiver. */ +/** + * The in-app agent: the code that runs inside the app under test's JS runtime, as real typed + * source. `runtime-source.ts` injects it by calling `fn.toString()` on each function, so the + * functions are unit-testable here rather than being opaque template-string constants. + * + * ## SYNTAX RULES -- read before "modernising" anything in this file + * + * The source below reaches Hermes' debugger evaluator (`Runtime.evaluate`) verbatim: nothing + * lowers it on the way out. **What you write here is what Hermes gets.** Inside every function in + * `AGENT_FUNCTIONS`: + * + * - **ES2015 and below is fine**: `class`, `for...of`, template literals, spread/rest, + * destructuring, default parameters, arrow functions, `let`/`const`, `Set` and `Map`. + * - **Optional chaining (`?.`) is fine** — the reference ships it raw to the same evaluator. + * - **No `async` / `await`, and so no promises-as-control-flow**, and **no nullish coalescing + * (`??`)**: unlowered, these reach Hermes as written, and nothing here needs them. + * + * `runtime-source.ts` refuses to build if it finds those tokens. The scan is textual and cannot + * tell a comment from code, so do not spell them out inside a function body — comments ride along + * into the injected text. + * + * ## Other rules that follow from "this is toString()'d" + * + * - **A function may reference only other `AGENT_FUNCTIONS` and the app's globals.** It is + * re-created in a fresh scope in the app, so a module constant or imported helper does not exist + * there and fails as a `ReferenceError` on first use. Configuration crosses the boundary as an + * argument (`AgentLimits`); the test suite evaluates the assembled source in an empty `node:vm` + * context and calls every function, so such a reference surfaces in the test run instead. + * - **Names are load-bearing.** Functions call each other by declared name, so a rename would + * silently break the call. Every name carries an `agent` prefix, and `runtime-source.ts` checks + * `fn.name` against the registry and throws rather than inject a renamed function. + * - **No recursion by name, no closures over module state.** The helpers that would naturally + * recurse (`agentStyleFlags`, `agentComponentName`) are iterative. + * + * ## One navigator search, shared + * + * The reader and the writer must share a single search so they can never disagree about which + * navigator is real: `agentFindNavigator` is the only place a navigator is found, and `nav-state` + * and `readiness` both call it. The old string path in `runtime-navigator.ts` is still in use by + * `nav-state.ts`; `agent-runtime.test.ts` runs both against the same fake tree so they cannot + * drift apart unnoticed. + * + * ## The lease + * + * `agentInstall` arms a watchdog refreshed by every command. If the host stops talking for + * `leaseMs` the watchdog calls `restore()` and deletes the agent global, so a dead harness cannot + * leave the app permanently instrumented. It is deliberately fail-closed: with no working timer + * the install refuses, and a failure to re-arm restores immediately. + * + * `restore()` runs the `undo` list in reverse, each entry isolated so one failing undo cannot + * strand the others. The list holds the reversal of every patch the agent applies to the app's + * globals — `fetch`, `XMLHttpRequest.prototype.send` and the DevTools hook. + * + * ## Readiness lives in the app + * + * Two identical snapshots sampled from Node cannot prove a quiet window or a painted frame once + * the round trip is faster than a frame, so the verdict is decided where the evidence is: + * + * - **React commits** are timestamped by chaining `__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot`. + * - **In-flight network** is counted by wrapping `fetch` and `XMLHttpRequest.prototype.send`. + * - **A quiet window** is the time since the last commit and the last network completion, compared + * against thresholds the host passes in (`quietMs`, `idleMs`). + * - **A paint check** is two nested `requestAnimationFrame` callbacks with no commit in between. + * + * The reply carries the verdict plus its evidence, and `confident`/`missing` say which signals + * could not be installed, so a verdict resting on fewer signals is distinguishable. + * + * ## Patching the app is the risky part + * + * Each patch goes through `agentPatchProperty`, which owns the rules: pass arguments, `this` and + * results through untouched; keep bookkeeping in its own try block so it cannot reach the app; + * register the reversal on `undo`; refuse (rather than throw) when the target is missing or not + * writable; and, if something else wrapped the property after us, neutralise our wrapper instead of + * overwriting theirs on restore. + */ + +import type { NavRoute } from '../nav-state.ts' + +/** Node ceiling for the fiber walk. Generous by design; the deadline below is the real bound. */ +export const WALK_BUDGET = 60_000 +/** How long the walk may occupy the app's JS thread before abandoning the search. */ +export const WALK_DEADLINE_MS = 1_000 +/** Ceiling for the walk beneath the focused screen when sampling readiness. */ +export const INNER_WALK_BUDGET = 40_000 +/** How long the host may go quiet before the app restores itself. Matches the reference's 10s. */ +export const AGENT_LEASE_MS = 10_000 + +/** + * Default time with no React commit before the tree counts as quiet. + * + * 500ms is ~30 frames at 60Hz. It has to cover the gap between a response landing and the commit + * that renders it (a few frames) and the gap from that commit to a dependent request starting (a + * waterfall: the next request bumps the in-flight count, so only the commit-to-request hop needs + * covering). It is shorter than the reference implementation's 1500ms on purpose: every capture in + * a sweep pays this wait, hundreds of times, and the old path accepted a 570ms sample spacing. The + * margin the 1500ms buys is mostly sampling error, which an exact in-app timestamp does not have. + * The cost of being too short is a premature capture; the host's own settle budget and the arrival + * judge are the backstops. Not measured on a device: tune it from capture results, and note a screen + * that commits more often than this (a ticking clock, a live feed) never reports quiet. + */ +export const QUIET_MS = 500 +/** Default time with no request in flight and none completed before the network counts as idle. */ +export const IDLE_MS = 500 +/** + * How long to wait for the two animation frames before reporting `painted: false`. A frame that has + * not arrived in 500ms (~30 frames) means the app is backgrounded or its UI thread is stalled, and + * waiting longer only burns the host's 1500ms command timeout. + */ +export const PAINT_TIMEOUT_MS = 500 + +/** The walk bounds, passed into the app because module constants do not survive `toString()`. */ +export interface AgentLimits { + walkBudget: number + walkDeadlineMs: number + innerWalkBudget: number + /** Default `quietMs` for a readiness command that does not carry its own. */ + quietMs: number + /** Default `idleMs` for a readiness command that does not carry its own. */ + idleMs: number + paintTimeoutMs: number +} + +export const DEFAULT_AGENT_LIMITS: AgentLimits = { + walkBudget: WALK_BUDGET, + walkDeadlineMs: WALK_DEADLINE_MS, + innerWalkBudget: INNER_WALK_BUDGET, + quietMs: QUIET_MS, + idleMs: IDLE_MS, + paintTimeoutMs: PAINT_TIMEOUT_MS +} + +/** Commands the host may send. `restore` is also what the lease watchdog does on its own. */ +export type AgentCommand = + | { type: 'heartbeat' } + | { type: 'restore' } + | { type: 'nav-state' } + | { + type: 'navigate' + route: string[] + params: Record | null + /** Expo Router routes by pathname rather than React Navigation's nested route names. */ + expoPath: string | null + /** + * Ancestor navigator names the host believes may register `route[0]`, from the parser's own + * grouping. Tried against the root's `routeNames` only when the live tree has no match, + * which is what an unvisited tab looks like: it has no mounted state to search. + */ + candidates?: string[] | null + } + | { + type: 'links' + /** Candidate cap for this call; defaults to `LINK_CANDIDATE_CAP`. */ + max?: number + } + | { + type: 'readiness' + route: string + expectedParams: Record | null + /** Override the commit quiet window for this call. */ + quietMs?: number + /** Override the network idle window for this call. */ + idleMs?: number + } + /** + * Hands the agent the source presentation catalog and binds its state sites to live hooks. + * Opens the bounded hook-patch window, so the host allows it 5000ms — the reference's own + * timeout for the same command. + */ + | { + type: 'presentation-setup' + states: PresentationStateSite[] + actions: PresentationAction[] + /** Metro's project root, used to resolve a catalog's relative file against a module path. */ + projectRoot?: string | null + } + /** + * Labels live bindings with the source sites the host's symbolication join proved they belong + * to (`presentations-bindings.ts`). The in-app pass can only attribute a binding whose owner is + * a candidate for exactly one site; everything else needs Metro's source maps, which only the + * host can read. Pure bookkeeping — nothing is rendered, opened or patched — so it keeps the + * list command's 2000ms. + */ + | { type: 'presentation-configure'; matches: { binding: string; site: string }[]; checked?: string[] } + /** Which catalog entries are openable right now. The reference allows this 2000ms. */ + | { type: 'presentations' } + /** Opens one catalog entry and pushes its undo checkpoint. */ + | { type: 'presentation-open'; id: string } + /** Whether the component an open projected is mounted and drawing. */ + | { type: 'presentation-content'; expected: string } + /** Unwinds the undo stack to `level`, confirming every native dismissal unless `wait` is false. */ + | { type: 'presentation-rollback'; level?: number; wait?: boolean } + +export interface HeartbeatReply { + alive: true +} +export interface RestoreReply { + restored: true +} +/** `routes` is null when no navigator is mounted (release build, still booting, no DevTools hook). */ +export interface NavStateReply { + routes: NavRoute[] | null +} +export type NavigateReply = + | { + ok: true + action: 'expo-replace' | 'replace' | 'navigate' | 'noop' + /** The full root-to-leaf navigator path actually dispatched, once resolved. */ + path?: string[] + /** Whether `route[0]` was a root route, or had to be located inside a nested navigator. */ + via?: 'exact' | 'nested' + } + | { + ok: false + reason: + | 'no-navigator' + | 'no-dispatch' + | 'no-expo-router' + | 'unregistered' + | 'invalid-route' + | 'threw' + detail?: string + } +/** + * One snapshot of how settled a screen is. Every field is computed fresh from the current tree. + */ +export interface ReadinessSnapshot { + /** The focused route path the app is actually on, innermost name last. */ + active: string[] + /** The target's screen fiber exists and is focused. */ + found: boolean + /** Count of host nodes under the screen carrying text, an image source, or a label. */ + content: number + /** A visible loading affordance, with a one-word reason when there is one. */ + loading: string | null + /** 32-bit hash of the screen's structure, compared across polls to detect stability. */ + signature: string + /** + * Expected parameter keys whose value on the focused route does not match. Non-empty means + * `found` is false because of the parameters rather than because the app is on another route. + */ + paramMismatch: string[] +} + +/** + * `ReadinessSnapshot` as the app computes it, plus the in-app verdict. Unlike the pipe-delimited + * string it replaces, nothing here has to fit in 200 characters, so `active` is the whole focused + * path rather than its last three segments. + */ +export interface ReadinessReply extends ReadinessSnapshot { + /** + * The app's own verdict: the screen was found, shows no loading affordance, the tree has been + * quiet, and frames painted. Computed over the signals that are available; read `confident`. + */ + settled: boolean + /** No commit for `quietMs` and no request in flight or recently completed for `idleMs`. */ + quiet: boolean + /** Two frames ran with no commit between them; null when there is no `requestAnimationFrame`. */ + painted: boolean | null + /** True only when every signal (commits, fetch, xhr, paint) was installed. */ + confident: boolean + /** Signals that could not be installed. Non-empty means `settled` rests on less evidence. */ + missing: string[] + /** Milliseconds since the last observed commit; null when commits cannot be observed. */ + commitQuietMs: number | null + /** 0 while a request is in flight, else ms since the last completion; null when untracked. */ + networkQuietMs: number | null + /** Requests started and not yet completed; null when no network signal is available. */ + inflight: number | null +} +/** What the agent says about a command it will not run. Passed through to the caller as data. */ +export interface AgentErrorReply { + error: 'invalid-command' | 'unknown-command' | 'command-failed' + detail?: string +} +export interface AgentStoppedReply { + stopped: true +} + +/** The shape of the object installed at `globalThis[key]`. */ +export interface InstalledAgent { + invoke(commandJson: string, reply: (value: unknown) => void): void + restore(): void +} + +/** + * The fibers to start a walk from: one `current` per root of every React Native renderer. + * + * Renderers are enumerated and filtered by `rendererPackageName` rather than assuming React Native + * is renderer id 1. `hook.renderers` is keyed by registration order, so a bundle that registers + * anything first would leave `getFiberRoots(1)` empty. Id 1 stays as the fallback because on the + * measured reference app it is in fact the React Native renderer. + */ +export function agentCollectFiberRoots(): any[] { + var hook = (globalThis as any).__REACT_DEVTOOLS_GLOBAL_HOOK__ + if (!hook || typeof hook.getFiberRoots !== 'function') return [] + var rendererIds: any[] = [] + try { + if (hook.renderers && typeof hook.renderers.forEach === 'function') { + hook.renderers.forEach(function (renderer: any, id: any) { + if (!renderer || renderer.rendererPackageName === 'react-native-renderer') { + rendererIds.push(id) + } + }) + } + } catch (error) { + rendererIds = [] + } + if (!rendererIds.length) rendererIds.push(1) + var stack: any[] = [] + for (var r = 0; r < rendererIds.length; r++) { + var roots: any = null + try { + roots = hook.getFiberRoots(rendererIds[r]) + } catch (error) { + roots = null + } + if (!roots || typeof roots.forEach !== 'function') continue + roots.forEach(function (root: any) { + if (root && root.current) stack.push(root.current) + }) + } + return stack +} + +/** + * Finds the app's React Navigation root. Returns `{ nav, state }` -- the object a caller can read + * or dispatch through, and the root state already read from it -- or null when none is mounted. + * + * Two shapes are probed because which one is reachable is a property of the app under test. A + * **container ref** exposes `getRootState` directly but is not guaranteed to sit in the fiber tree + * as a prop or a `stateNode`. A **screen's `navigation` prop** is in the tree on every screen + * React Navigation renders and carries `getState` and `getParent`, but knows only its own + * navigator, so it is climbed to the root: reading it directly would report one stack and miss + * every sibling tab. The climb discriminates on those two read-only methods rather than on + * `dispatch`, which keeps the reading commands from ever naming a mutating one. + * + * Bounded by `limits.walkBudget` nodes and `limits.walkDeadlineMs` of wall clock, and gives up + * rather than finishing at any cost: this runs on the app's JS thread. The deadline is the real + * protection. The node cap is generous because a measured live tree ran to 17,822 fibers and the + * comparison project records a busy feed exceeding 16,000, so a tight cap would fail on exactly the + * screens that matter. A `seen` set guards cycles, which a child/sibling push cannot do alone. + * + * A carrier that answers badly does not end the search: each `getRootState()` is called in its own + * try block and its result must have `routes`, so a still-mounting container ref is skipped rather + * than accepted as a successful read of an empty sweep. It is called on the carrier, not detached, + * because a container ref reads its internals through its receiver. + */ +export function agentFindNavigator(limits: AgentLimits): { nav: any; state: any } | null { + var stack = agentCollectFiberRoots() + if (!stack.length) return null + var deadline = Date.now() + limits.walkDeadlineMs + var budget = limits.walkBudget + var seen = new Set() + var climb = function (value: any): { nav: any; state: any } | null { + if (!value || typeof value !== 'object') return null + if (typeof value.getState !== 'function' || typeof value.getParent !== 'function') return null + try { + var nav = value + var climbed = new Set() + while (typeof nav.getParent === 'function' && !climbed.has(nav)) { + climbed.add(nav) + var parent = nav.getParent() + if (!parent) break + nav = parent + } + var state = typeof nav.getRootState === 'function' ? nav.getRootState() : nav.getState() + return state && state.routes ? { nav: nav, state: state } : null + } catch (error) { + return null + } + } + var found: { nav: any; state: any } | null = null + while (stack.length && budget-- > 0 && !found) { + if ((budget & 255) === 0 && Date.now() > deadline) return null + var fiber = stack.pop() + if (!fiber || seen.has(fiber)) continue + seen.add(fiber) + var memo = fiber.memoizedState + var props = fiber.memoizedProps + var carriers = [ + fiber.stateNode, + props && props.value, + props && props.navigationRef, + memo && memo.memoizedState && memo.memoizedState.current + ] + for (var at = 0; at < carriers.length && !found; at++) { + var carrier = carriers[at] + if (carrier && typeof carrier.getRootState === 'function') { + try { + var direct = carrier.getRootState() + if (direct && direct.routes) found = { nav: carrier, state: direct } + } catch (error) { + found = null + } + } + } + if (!found) found = climb(props && props.navigation) + if (!found) found = climb(props && props.value) + if (!found) found = climb(fiber.stateNode) + if (fiber.child) stack.push(fiber.child) + if (fiber.sibling) stack.push(fiber.sibling) + } + return found +} + +/** + * Flattens a navigation state into the routes `nav-state.ts` has always returned, depth first. + * + * Only values that can honestly become a URL segment survive: strings and finite numbers (as + * strings). A route object holding a whole post or a callback neither crosses the wire nor arrives + * as `[object Object]` pretending to be an id. Depth is capped at 12, as before. + */ +export function agentFlattenRoutes(state: any): NavRoute[] { + var flat: NavRoute[] = [] + var walk = function (node: any, depth: number): void { + if (!node || depth > 12 || !Array.isArray(node.routes)) return + for (var i = 0; i < node.routes.length; i++) { + var route = node.routes[i] + if (!route || typeof route.name !== 'string') continue + var entry: NavRoute = { name: route.name } + if (typeof route.path === 'string' && route.path) entry.path = route.path + var raw = route.params + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + var kept: Record = {} + var any = false + for (var key in raw) { + var value = raw[key] + if (typeof value === 'string') { + kept[key] = value + any = true + } else if (typeof value === 'number' && isFinite(value)) { + kept[key] = String(value) + any = true + } + } + if (any) entry.params = kept + } + flat.push(entry) + if (route.state) walk(route.state, depth + 1) + } + } + walk(state, 0) + return flat +} + +/** The `nav-state` command: the flattened navigator state, or `{ routes: null }`. */ +export function agentNavState(limits: AgentLimits): NavStateReply { + var found = agentFindNavigator(limits) + if (!found || !found.state) return { routes: null } + return { routes: agentFlattenRoutes(found.state) } +} + +/* --------------------------------------------------------------- link harvest */ + +/** Most link candidates one `links` command may return. Matches the reference runtime's cap. */ +export const LINK_CANDIDATE_CAP = 200 + +/** + * One destination a link element in the tree names. + * + * `name` and `params` together are the thing the a11y learners structurally cannot assemble: both + * halves of a two-param route arrive in a single record, so no precedence rule has to decide which + * identifier on screen owned which key. + */ +export interface LinkCandidate { + /** The route name an object destination named (`screen`). */ + name?: string + /** A routable path, from a string destination or an object's `pathname`. */ + path?: string + /** URL-able values only — strings and finite numbers, as `agentFlattenRoutes` filters them. */ + params?: Record +} + +/** The `links` command's reply. `scanned` and `capped` are host diagnostics, never param values. */ +export interface LinksReply { + links: LinkCandidate[] + /** Fibers visited by the walk. */ + scanned: number + /** True when the walk stopped on the node budget, the deadline, or the candidate cap. */ + capped: boolean +} + +/** + * The path part of a string destination, or null when there is nothing routable in it. + * + * Deliberately permissive about the scheme and strict about nothing else: `normalizePath` in + * `param-store.ts` owns the real parsing, and this only has to keep obvious non-routes (a label, a + * callback stringified by accident, an absolute asset URL) out of the 200-candidate budget. + */ +export function agentRoutablePath(value: any): string | null { + if (typeof value !== 'string') return null + if (!value || value.length > 500) return null + if (value.charAt(0) === '/') return value + if (/^[a-zA-Z][\w+.-]*:\/\//.test(value)) return value + return null +} + +/** + * Reads one `href`/`to` prop into a candidate, or null when it names no destination. + * + * Accepts the two shapes React Navigation and Expo Router apps actually use: a path string, and an + * object carrying `screen`/`pathname` alongside `params`. A bare route name with no params is + * dropped -- it resolves nothing the navigator state did not already say -- so every candidate + * returned is either a path or a name with values attached. + */ +export function agentLinkCandidate(href: any): LinkCandidate | null { + var candidate: LinkCandidate = {} + var asPath = agentRoutablePath(href) + if (asPath) { + candidate.path = asPath + return candidate + } + if (!href || typeof href !== 'object' || Array.isArray(href)) return null + if (typeof href.screen === 'string' && href.screen) candidate.name = href.screen + var fromPathname = agentRoutablePath(href.pathname) + if (fromPathname) candidate.path = fromPathname + var raw = href.params + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + var kept: Record = {} + var any = false + for (var key in raw) { + var value = raw[key] + if (typeof value === 'string') { + kept[key] = value + any = true + } else if (typeof value === 'number' && isFinite(value)) { + kept[key] = String(value) + any = true + } + } + if (any) candidate.params = kept + } + if (candidate.path) return candidate + if (candidate.name && candidate.params) return candidate + return null +} + +/** + * Every destination the mounted tree's link elements name, deduplicated. + * + * This is the one read that can see a destination the app has **not navigated to yet**. The + * navigator state (`agentNavState`) only knows routes the app has actually entered, and the + * accessibility harvest can only learn a value that reached a testID; a feed row's `href` carries + * `name` and `rkey` together, before anybody presses it, which is exactly what neither of the + * other two sources can produce. + * + * Unlike the readiness walk this one does **not** prune hidden subtrees. An unfocused tab and an + * off-screen row still hold their destinations, and those are the ones worth having. + * + * Bounded exactly like `agentFindNavigator` -- `limits.walkBudget` nodes, `limits.walkDeadlineMs` + * of wall clock, a `seen` set for cycles -- because it runs on the app's JS thread. It stops at + * `max` candidates rather than walking a whole feed for destinations that will not fit in the + * reply, and says so through `capped`. + */ +export function agentCollectLinks(limits: AgentLimits, max: number): LinksReply { + var stack = agentCollectFiberRoots() + var links: LinkCandidate[] = [] + var keys = new Set() + var seen = new Set() + var scanned = 0 + var capped = false + var deadline = Date.now() + limits.walkDeadlineMs + var budget = limits.walkBudget + while (stack.length) { + if (budget-- <= 0) { + capped = true + break + } + if ((budget & 255) === 0 && Date.now() > deadline) { + capped = true + break + } + var fiber = stack.pop() + if (!fiber || seen.has(fiber)) continue + seen.add(fiber) + scanned += 1 + if (fiber.child) stack.push(fiber.child) + if (fiber.sibling) stack.push(fiber.sibling) + var props = fiber.memoizedProps + if (!props || typeof props !== 'object') continue + var href = props.href + if (href === undefined || href === null) href = props.to + if (href === undefined || href === null) continue + var candidate = agentLinkCandidate(href) + if (!candidate) continue + var key: string + try { + key = JSON.stringify([candidate.name, candidate.path, candidate.params]) + } catch (error) { + continue + } + if (keys.has(key)) continue + if (links.length >= max) { + capped = true + break + } + keys.add(key) + links.push(candidate) + } + return { links: links, scanned: scanned, capped: capped } +} + +/** Where `agentResolveRoutePath` found the requested route, and the path to dispatch for it. */ +export interface ResolvedRoutePath { + /** The root-to-leaf path. Equal to the requested route unless a nested navigator supplied it. */ + path: string[] + via: 'exact' | 'nested' | 'none' + /** How many navigators were inspected, for the host's diagnostics only. */ + searched: number +} + +/** + * Resolves a requested route to a path the root navigator can actually dispatch. + * + * The parser spells most react-navigation ids as bare leaf names, so `route[0]` is usually a screen + * registered inside some nested navigator rather than a root route. Checking only the root's + * `routeNames` (which is all the dispatch guard used to do) refuses those outright. So when the root + * does not register it, the mounted navigator tree is searched breadth first - bounded at 500 + * navigators and 16 levels - carrying each child navigator's ancestor route-name chain, and the + * match becomes the chain concatenated with the requested route. + * + * A screen registered in several tabs matches several times. The candidate sharing the longest + * prefix with the live focused path wins, which keeps the app in the tab it is already on instead + * of yanking it to whichever navigator the walk happened to reach first. + */ +export function agentResolveRoutePath(state: any, route: string[]): ResolvedRoutePath { + var head = String(route[0]) + if (state && Array.isArray(state.routeNames) && state.routeNames.indexOf(head) >= 0) { + return { path: route.slice(), via: 'exact', searched: 0 } + } + // The focused route names from the root down, innermost last, bounded like every other walk here. + var focused: string[] = [] + var cursor = state + for (var level = 0; cursor && Array.isArray(cursor.routes) && cursor.routes.length && level < 16; level++) { + var active = cursor.routes[cursor.index == null ? 0 : cursor.index] + if (!active || typeof active.name !== 'string') break + focused.push(active.name) + cursor = active.state + } + var matches: string[][] = [] + var pending: any[] = [{ state: state, depth: 0, chain: [] as string[] }] + var searched = 0 + while (pending.length && searched < 500) { + var item = pending.shift() + var node = item && item.state + if (!node || item.depth >= 16) continue + searched++ + if ( + item.chain.length && + Array.isArray(node.routeNames) && + node.routeNames.indexOf(head) >= 0 + ) { + matches.push(item.chain.concat(route)) + } + if (!Array.isArray(node.routes)) continue + for (var index = 0; index < node.routes.length; index++) { + var child = node.routes[index] + if (!child || !child.state || typeof child.name !== 'string') continue + pending.push({ + state: child.state, + depth: item.depth + 1, + chain: item.chain.concat([child.name]) + }) + } + } + if (!matches.length) return { path: route.slice(), via: 'none', searched: searched } + var best = matches[0] + var bestScore = -1 + for (var candidate = 0; candidate < matches.length; candidate++) { + var shared = 0 + while ( + shared < matches[candidate].length && + shared < focused.length && + matches[candidate][shared] === focused[shared] + ) shared++ + if (shared > bestScore) { + bestScore = shared + best = matches[candidate] + } + } + return { path: best, via: 'nested', searched: searched } +} + +/** Dispatches a nested React Navigation route after proving its leaf is registered live. */ +export function agentNavigate( + route: string[], + params: Record | null, + limits: AgentLimits, + expoPath: string | null, + candidates: string[] | null +): NavigateReply { + if (!Array.isArray(route) || !route.length) return { ok: false, reason: 'invalid-route' } + if (expoPath) { + // Match the original App Flow runtime: inspect only Metro modules the app already initialized, + // never execute a cold module just to obtain Expo Router's imperative singleton. + var modules = (globalThis as any).__r && (globalThis as any).__r.getModules + ? (globalThis as any).__r.getModules() + : null + if (modules && typeof modules.values === 'function') { + var values = modules.values() + var next = values.next() + while (!next.done) { + var module = next.value + if (module && module.isInitialized) { + var exports = module.publicModule && module.publicModule.exports + var descriptor = exports && Object.getOwnPropertyDescriptor(exports, 'router') + var router = descriptor && 'value' in descriptor ? descriptor.value : null + if ( + router && + typeof router.push === 'function' && + typeof router.replace === 'function' && + typeof router.canGoBack === 'function' + ) { + try { + router.replace({ pathname: expoPath, params: params || {} }) + return { ok: true, action: 'expo-replace' } + } catch (error) { + return { + ok: false, + reason: 'threw', + detail: String(error && (error as any).message).slice(0, 80) + } + } + } + } + next = values.next() + } + } + return { ok: false, reason: 'no-expo-router' } + } + var found = agentFindNavigator(limits) + if (!found) return { ok: false, reason: 'no-navigator' } + var nav = found.nav + if (!nav || typeof nav.dispatch !== 'function') return { ok: false, reason: 'no-dispatch' } + var current = found.state + var resolved = agentResolveRoutePath(current, route) + // An unvisited tab has no mounted state for the walk above to see, so the host's parsed ancestor + // names get one last try against the root before the route is called unregistered. + if (resolved.via === 'none' && Array.isArray(candidates) && Array.isArray(current.routeNames)) { + for (var guess = 0; guess < candidates.length; guess++) { + var ancestor = String(candidates[guess]) + if (!ancestor || current.routeNames.indexOf(ancestor) < 0) continue + resolved = { + path: [ancestor].concat(route), + via: 'nested', + searched: resolved.searched + } + break + } + } + if (resolved.via === 'none') { + return { + ok: false, + reason: 'unregistered', + detail: 'searched ' + resolved.searched + ' mounted navigator(s)' + } + } + var path = resolved.path + var via: 'exact' | 'nested' = resolved.via + + // Preserve the original runtime's bounded-stack behavior: replace the focused leaf when the + // requested path is already inside that stack, otherwise navigate through the nested payload. + // This avoids accumulating one mounted screen per Fast capture. + var leaf = current + for (var depth = 0; depth < path.length - 1; depth++) { + var step = leaf && leaf.routes && leaf.routes[leaf.index == null ? 0 : leaf.index] + if (!step || step.name !== path[depth]) { + leaf = null + break + } + leaf = step.state + } + var expected = String(path[path.length - 1]) + var selected = leaf && leaf.routes && leaf.routes[leaf.index == null ? 0 : leaf.index] + var same = function (before: any, after: any, depth: number, budget: any): boolean { + if (Object.is(before, after)) return true + if ( + !before || + !after || + typeof before !== 'object' || + typeof after !== 'object' || + depth > 6 || + --budget.left < 0 + ) return false + var array = Array.isArray(before) + if ( + array !== Array.isArray(after) || + (!array && + (Object.getPrototypeOf(before) !== Object.prototype || + Object.getPrototypeOf(after) !== Object.prototype)) + ) return false + var a = Object.getOwnPropertyDescriptors(before) + var b = Object.getOwnPropertyDescriptors(after) + var keys = Object.keys(a) + if (keys.length > 200 || keys.length !== Object.keys(b).length) return false + for (var keyIndex = 0; keyIndex < keys.length; keyIndex++) { + var key = keys[keyIndex] + if ( + !a[key] || + !b[key] || + !('value' in a[key]) || + !('value' in b[key]) || + !same(a[key].value, b[key].value, depth + 1, budget) + ) return false + } + return true + } + var nested: any = params && Object.keys(params).length ? params : undefined + for (var offset = path.length - 1; offset > 0; offset--) { + nested = { screen: String(path[offset]), params: nested, initial: false } + } + var payload: any = { name: String(path[0]) } + if (nested !== undefined) payload.params = nested + try { + if ( + leaf && + leaf.type === 'stack' && + leaf.key && + Array.isArray(leaf.routeNames) && + leaf.routeNames.indexOf(expected) >= 0 + ) { + if ( + selected && + selected.name === expected && + (!params || same(selected.params || {}, params, 0, { left: 200 })) + ) return { ok: true, action: 'noop', path: path, via: via } + nav.dispatch({ + type: 'REPLACE', + target: leaf.key, + payload: { name: expected, params: params || undefined } + }) + return { ok: true, action: 'replace', path: path, via: via } + } + nav.dispatch({ type: 'NAVIGATE', payload: payload }) + return { ok: true, action: 'navigate', path: path, via: via } + } catch (error) { + return { + ok: false, + reason: 'threw', + detail: String(error && (error as any).message).slice(0, 80) + } + } +} + +/** + * Reduces a React Native `style` prop to the two flags that decide visibility. + * + * Style props are routinely **arrays**, and nested ones, so reading only a plain object misses most + * of the hidden subtrees an app actually produces. Flattened with the array's own merge semantics: + * left to right, a later entry overrides an earlier one. Depth is capped at 5. Iterative, because + * an injected function cannot rely on calling itself by name (see the file doc). + */ +export function agentStyleFlags(style: any): { display?: unknown; opacity?: unknown } { + var out: { display?: unknown; opacity?: unknown } = {} + // Entries are processed in reading order; a stack of [node, depth] pairs is reversed on push so + // the first array element is popped first. + var stack: Array<{ node: any; depth: number }> = [{ node: style, depth: 0 }] + while (stack.length) { + var item = stack.pop() as { node: any; depth: number } + var node = item.node + if (!node || item.depth > 5) continue + if (Array.isArray(node)) { + for (var index = node.length - 1; index >= 0; index--) { + stack.push({ node: node[index], depth: item.depth + 1 }) + } + continue + } + if (typeof node !== 'object') continue + if (node.display !== undefined) out.display = node.display + if (node.opacity !== undefined) out.opacity = node.opacity + } + return out +} + +/** + * Whether the app has hidden this subtree, so it should not count as on screen. + * + * Counting a hidden subtree reports content and stability for a screen the user cannot see. + * Measured on a real app: a stopped `ActivityIndicator` sat under + * `style: [{display:"none",opacity:0}]` and was counted as a live loading affordance, so every + * readiness poll on that screen reported a skeleton and the settle loop burned its whole 8s budget + * on a fully rendered screen. That is why the style is flattened with array merge semantics + * (`agentStyleFlags`) rather than "any entry hides it": `[{display:"none"},{display:"flex"}]` is + * visible, and pruning a visible screen is the more damaging way to be wrong. + * + * Deliberately more thorough than the reference implementation, which reads a plain-object style + * only and so misses every array-valued one. + */ +export function agentHidden(props: any): boolean { + if (!props) return false + if (props.focused === false || props.isFocused === false || props.screenFocused === false) { + return true + } + if (props.style) { + var flags = agentStyleFlags(props.style) + if (flags.display === 'none' || flags.opacity === 0) return true + } + return props.accessibilityElementsHidden === true || props['aria-hidden'] === true +} + +/** + * A component's display name. It may sit on a function, or on the object `memo` and `forwardRef` + * wrap it in; the latter is routine in React Native, and reading only functions would miss most of + * the loading indicators worth detecting. Follows `type` wrappers a bounded number of times. + */ +export function agentComponentName(value: any): string { + for (var hops = 0; hops < 8; hops++) { + if (!value) return '' + if (typeof value === 'function') return value.displayName || value.name || '' + if (typeof value !== 'object') return '' + var direct = + value.displayName || + (typeof value.render === 'function' + ? value.render.displayName || value.render.name || '' + : '') + if (direct) return direct + value = value.type + } + return '' +} + +/** + * The `readiness` command: one stateless snapshot of how settled a screen is. It decides *when to + * photograph*, not *whether we arrived*; `agentSettle` adds the stateful evidence around it and + * `agentVerdict` folds the two together. + * + * Two traversal details were each found by measurement and are kept exactly: + * + * - **Siblings are pushed before the hidden check.** A fiber's later siblings are reachable only + * through it, so skipping the push together with the subtree severs the rest of the sibling list. + * One hidden scene cut a walk from 1425 nodes to 937 and hid the focused screen entirely, so every + * poll reported `found: false`. + * - **A parameter mismatch makes `found` false**, not merely a different signature. A dispatch still + * in flight lands with the previous instance's params for a poll or two, and settling on that + * would photograph the wrong screen. + */ +export function agentReadiness( + routeName: string, + expectedParams: Record | null, + limits: AgentLimits +): ReadinessSnapshot { + var empty: ReadinessSnapshot = { + found: false, + content: 0, + loading: null, + signature: '', + active: [], + paramMismatch: [] + } + var found = agentFindNavigator(limits) + if (!found) return empty + // The focused route path, innermost last, bounded at 16 levels of nesting. + var state = found.state + var active: string[] = [] + for (var depth = 0; state && state.routes && state.routes.length && depth < 16; depth++) { + var route = state.routes[state.index == null ? 0 : state.index] + if (!route) break + active.push(route.name) + state = route.state + } + var expected: Record = expectedParams || {} + var screen: any = null + var stack = agentCollectFiberRoots() + var seen = new Set() + var budget = limits.walkBudget + var deadline = Date.now() + limits.walkDeadlineMs + while (stack.length && budget-- > 0 && !screen) { + if ((budget & 255) === 0 && Date.now() > deadline) break + var fiber = stack.pop() + if (!fiber || seen.has(fiber)) continue + seen.add(fiber) + if (fiber.sibling) stack.push(fiber.sibling) + var props = fiber.memoizedProps + if (agentHidden(props)) continue + if (props && props.route && props.route.name === routeName) { + var focused = true + try { + if (props.navigation && typeof props.navigation.isFocused === 'function') { + focused = props.navigation.isFocused() + } + } catch (error) { + focused = true + } + if (focused) screen = fiber + } + if (fiber.child) stack.push(fiber.child) + } + var content = 0 + var loading = '' + var parts: string[] = [] + var missed: string[] = [] + if (screen) { + var inner = [screen.child] + var innerSeen = new Set() + var innerBudget = limits.innerWalkBudget + var loaderPattern = + /Skeleton|Shimmer|LoadingPlaceholder|LoadingIndicator|LoadingSpinner|LoadingView|LoadingScreen|ActivityIndicator|Spinner|^Loader$/ + while (inner.length && innerBudget-- > 0) { + var node = inner.pop() + if (!node || innerSeen.has(node)) continue + innerSeen.add(node) + // Same reason as the outer walk: pruning a hidden subtree must not take its siblings with it. + if (node.sibling) inner.push(node.sibling) + var nodeProps = node.memoizedProps + if (agentHidden(nodeProps)) continue + if (!loading) { + var typeName = agentComponentName(node.type) || agentComponentName(node.elementType) + if (typeName && loaderPattern.test(typeName)) loading = 'skeleton' + else if (node.tag === 13 && node.memoizedState !== null) loading = 'suspense' + else if (nodeProps) { + var busy = + (nodeProps.accessibilityState && nodeProps.accessibilityState.busy) || + nodeProps['aria-busy'] + if (busy === true) loading = 'busy' + else if ( + nodeProps.isLoading === true || + nodeProps.loading === true || + nodeProps.animating === true + ) { + loading = 'busy' + } else if ( + nodeProps.accessibilityRole === 'progressbar' || + nodeProps.role === 'progressbar' + ) { + loading = 'busy' + } + } + } + if (node.tag === 5 && nodeProps) { + var text = typeof nodeProps.children === 'string' ? nodeProps.children : null + var media = nodeProps.source || nodeProps.src + var label = + typeof nodeProps.accessibilityLabel === 'string' ? nodeProps.accessibilityLabel : null + if (text || media || label) { + content += 1 + if (parts.length < 250) { + parts.push(String(node.type) + ':' + String(text || label || 'src').slice(0, 60)) + } + } + } + if (node.child) inner.push(node.child) + } + // Only the keys the caller asked about, compared on the focused leaf's params. A screen + // carrying extra params is still the screen that was asked for. + var leaf = found.state + while (leaf && leaf.routes && leaf.routes.length) { + var next = leaf.routes[leaf.index == null ? 0 : leaf.index] + if (!next) break + leaf = next.state || next + } + for (var key in expected) { + var actual = leaf && leaf.params ? leaf.params[key] : undefined + if (String(actual) !== String(expected[key]) && missed.length < 4) missed.push(key) + } + if (missed.length) { + screen = null + content = 0 + } + } + // A 32-bit hash of the structure: only equality across polls is ever asked of it, and the + // structure itself runs to thousands of characters. + var joined = parts.join('|') + var hash = 0 + for (var at = 0; at < joined.length; at++) { + hash = (hash * 31 + joined.charCodeAt(at)) | 0 + } + return { + found: !!screen, + content: content, + loading: loading ? loading : null, + signature: (hash >>> 0).toString(36), + active: active, + paramMismatch: missed + } +} + +/** + * Replaces `target[name]` with a wrapper, safely, and registers the reversal. Returns whether the + * property is now patched; **never throws**, because a signal that cannot be installed must be + * reported as missing, not abort the install. + * + * The rules every patch in this agent follows, kept in one place so none of them can be forgotten: + * + * - **Refuse rather than force.** A missing property (when `requireOriginal`), a throwing getter, a + * frozen object, or an accessor with no setter all end in `false`. Writability is checked by + * *doing* the assignment and reading it back: a non-writable property silently ignores the write + * in sloppy mode and throws in strict mode, and the evaluator's mode is not ours to assume. + * - **No stacking.** If the current value is already one of our wrappers (it carries + * `__aiHarnessRelease`, the owning agent's `restore`), that agent is restored first, so a second + * install under a different key wraps the real original instead of wrapping the first wrapper. + * A same-key reinstall is already covered by `agentInstall` restoring its predecessor. + * - **Exact reversal.** The original value is kept and written back, or the own property is deleted + * when it was inherited, so the object ends up as it started. If something else wrapped the + * property after us, the reversal does *not* overwrite it (that would silently unhook a third + * party); it flips `control.live` so our wrapper becomes a pure pass-through and leaves it in the + * chain. + * + * `makeWrapper(original, control)` receives the original (null if it is not a function) and the + * control flag, and must return the replacement. + */ +export function agentPatchProperty( + target: any, + name: string, + requireOriginal: boolean, + makeWrapper: (original: any, control: { live: boolean }) => any, + undo: Array<() => void>, + release: () => void +): boolean { + if (!target || (typeof target !== 'object' && typeof target !== 'function')) return false + var current: any + try { + current = target[name] + } catch (error) { + return false + } + if (current && typeof current.__aiHarnessRelease === 'function') { + try { + current.__aiHarnessRelease() + } catch (error) { + current = null + } + try { + current = target[name] + } catch (error) { + return false + } + } + if (requireOriginal && typeof current !== 'function') return false + var hadOwn = Object.prototype.hasOwnProperty.call(target, name) + var control = { live: true } + var wrapper: any + try { + wrapper = makeWrapper(typeof current === 'function' ? current : null, control) + } catch (error) { + return false + } + if (typeof wrapper !== 'function') return false + try { + wrapper.__aiHarnessRelease = release + } catch (error) { + /* an unmarked wrapper still works; it just cannot be released by a later install */ + } + try { + target[name] = wrapper + } catch (error) { + return false + } + var applied = false + try { + applied = target[name] === wrapper + } catch (error) { + applied = false + } + if (!applied) return false + undo.push(function () { + control.live = false + if (target[name] !== wrapper) return + if (hadOwn) target[name] = current + else delete target[name] + }) + return true +} + +/** + * Installs the three observers and returns the state they write to. + * + * ## What each patch touches + * + * - **`__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot`**: replaced by a function that stamps + * `commitAt` and then calls whatever handler was there (DevTools' own, usually), with the same + * `this` and arguments, returning its result. React calls the property at commit time, so + * replacing it on the hook object is enough. The handler is optional: with none, only the stamp + * runs. Chained, never replaced. + * - **`fetch`**: replaced by a function that counts the call as in flight and returns the *same + * promise object* the original returned. Completion is observed by attaching a fulfilment and a + * rejection handler to it, so a rejection decrements the count and still reaches the app + * unchanged. One consequence, stated plainly: attaching a handler marks the promise as handled, so + * an app that never catches a failed request loses the engine's unhandled-rejection warning for + * it. The alternative (returning a derived promise) would preserve that warning but change the + * identity of the value the app holds, which is the worse trade for an apparatus that must not + * change behaviour. + * - **`XMLHttpRequest.prototype.send`**: replaced (the prototype, not the constructor, so + * `instanceof`, statics and subclasses are untouched). A `loadend` listener, plus a + * `readystatechange` at state 4 as a second route, decrements the count; the first to fire wins + * because each request holds a one-shot token. `send` itself is called with the original + * arguments and receiver and its result or exception propagates. + * + * ## Counting a request once + * + * React Native's `fetch` is a polyfill over `XMLHttpRequest` that calls `send` synchronously inside + * the promise executor, so without care one request counts twice. `fetchDepth` is raised for the + * duration of the synchronous original `fetch` call and `send` does not count while it is raised. + * This matters only for the count's honesty (`inflight`); quiet depends on it reaching zero. A + * native fetch that does not use `XMLHttpRequest` is unaffected. + * + * ## Blind spots (what quiet cannot see) + * + * Requests started before the install are invisible, so `commitAt` and `settledAt` start at the + * install time: the first `quietMs` after connecting is reported as not quiet rather than guessed. + * Native image loading, WebSocket traffic and anything that bypasses both globals is not counted. A + * long-lived request (long poll, server-sent events over XHR) holds `inflight` above zero for its + * whole life, so such a screen reports not quiet until the host's budget gives up. That is the safe + * direction to be wrong in. + * + * Bookkeeping lives in its own try block everywhere so an exception in it can never reach the app. + */ +export function agentCreateSignals(root: any, undo: Array<() => void>, release: () => void): any { + var started = Date.now() + var signals: any = { + installedAt: started, + commitAt: started, + settledAt: started, + pending: 0, + fetchDepth: 0, + available: { commits: false, fetch: false, xhr: false } + } + signals.begin = function () { + try { + signals.pending += 1 + return { done: false } + } catch (error) { + return null + } + } + signals.end = function (token: any) { + try { + if (!token || token.done) return + token.done = true + if (signals.pending > 0) signals.pending -= 1 + signals.settledAt = Date.now() + } catch (error) { + token = null + } + } + + var hook: any = null + try { + hook = root.__REACT_DEVTOOLS_GLOBAL_HOOK__ + } catch (error) { + hook = null + } + signals.available.commits = agentPatchProperty( + hook, + 'onCommitFiberRoot', + false, + function (original: any, control: any) { + return function (this: any) { + if (control.live) { + try { + signals.commitAt = Date.now() + } catch (error) { + control.live = true + } + } + if (typeof original === 'function') return original.apply(this, arguments) + return undefined + } + }, + undo, + release + ) + + signals.available.fetch = agentPatchProperty( + root, + 'fetch', + true, + function (original: any, control: any) { + return function (this: any) { + if (!control.live) return original.apply(this, arguments) + var token = signals.begin() + var result: any + signals.fetchDepth += 1 + try { + result = original.apply(this, arguments) + } catch (error) { + signals.end(token) + throw error + } finally { + signals.fetchDepth -= 1 + } + try { + if (result && typeof result.then === 'function') { + var done = function (): void { + signals.end(token) + } + result.then(done, done) + } else { + signals.end(token) + } + } catch (error) { + signals.end(token) + } + return result + } + }, + undo, + release + ) + + var xhrProto: any = null + try { + xhrProto = root.XMLHttpRequest ? root.XMLHttpRequest.prototype : null + } catch (error) { + xhrProto = null + } + if (xhrProto && typeof xhrProto.addEventListener === 'function') { + signals.available.xhr = agentPatchProperty( + xhrProto, + 'send', + true, + function (original: any, control: any) { + return function (this: any) { + if (!control.live || signals.fetchDepth > 0) return original.apply(this, arguments) + var token = signals.begin() + try { + var xhr = this + var done = function (): void { + signals.end(token) + } + xhr.addEventListener('loadend', done) + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) done() + }) + } catch (error) { + signals.end(token) + } + try { + return original.apply(this, arguments) + } catch (error) { + signals.end(token) + throw error + } + } + }, + undo, + release + ) + } + return signals +} + +/** + * Folds a stateless snapshot and the in-app evidence into the verdict. Pure: `now` is passed in so + * the arithmetic is testable without a clock. + * + * - `commitQuietMs = now - lastCommit` (null if commits cannot be observed). + * - `networkQuietMs = 0` while anything is in flight, else `now - lastCompletion` (null if neither + * `fetch` nor `XMLHttpRequest` could be wrapped). + * - `quiet` needs each *available* clock to have reached its threshold and nothing in flight. An + * unavailable signal does not block quiet (it cannot be consulted) but it is named in `missing` + * and clears `confident`, so the host can tell a verdict made on fewer signals. + * - `settled` = found, no loading affordance, quiet, and not `painted === false`. `painted` null + * (no `requestAnimationFrame`) does not block; it is listed as missing like any other. + * + * A snapshot identical twice in a row is deliberately not treated as evidence of anything; the + * app's own commit and request clocks are. + */ +export function agentVerdict( + snapshot: any, + signals: any, + painted: boolean | null, + now: number, + quietMs: number, + idleMs: number +): ReadinessReply { + var missing: string[] = [] + if (!signals.available.commits) missing.push('commits') + if (!signals.available.fetch) missing.push('fetch') + if (!signals.available.xhr) missing.push('xhr') + if (painted === null) missing.push('paint') + var commitQuietMs: number | null = signals.available.commits + ? Math.max(0, now - signals.commitAt) + : null + var networkTracked = signals.available.fetch || signals.available.xhr + var networkQuietMs: number | null = networkTracked + ? signals.pending > 0 + ? 0 + : Math.max(0, now - signals.settledAt) + : null + var quiet = + (commitQuietMs === null || commitQuietMs >= quietMs) && + (networkQuietMs === null || (signals.pending === 0 && networkQuietMs >= idleMs)) + var out: any = snapshot + out.quiet = quiet + out.painted = painted + out.commitQuietMs = commitQuietMs + out.networkQuietMs = networkQuietMs + out.inflight = networkTracked ? signals.pending : null + out.missing = missing + out.confident = missing.length === 0 + out.settled = !!out.found && !out.loading && quiet && painted !== false + return out +} + +/** + * The `readiness` command: waits for two frames, then answers once with the verdict. + * + * ## The paint check + * + * Two nested `requestAnimationFrame` callbacks: the first runs before the next paint, the second + * before the one after, so by the time the second fires a frame has been presented for whatever the + * tree looked like when the first was queued. This is what the 570ms CLI round-trip used to supply + * for free. `painted` is also false if a commit landed while the frames were pending, because then + * the frames painted a tree that is no longer the tree. + * + * A backgrounded app or a stalled UI thread never delivers the frames, so a timer (`paintTimeoutMs`) + * answers `painted: false` instead of leaving the host to time out with no information. With no + * `requestAnimationFrame` at all the check is reported unavailable (`painted: null`) and the reply is + * immediate. The snapshot and the timestamps are taken *after* the frames, so the verdict describes + * the tree as it is when the reply is sent. + */ +export function agentSettle( + signals: any, + routeName: string, + expectedParams: Record | null, + limits: AgentLimits, + quietMs: number, + idleMs: number, + reply: (value: unknown) => void +): void { + var root = globalThis as any + var finished = false + var timer: any = null + var commitAtStart = signals.commitAt + var finish = function (painted: boolean | null): void { + if (finished) return + finished = true + try { + if (timer !== null) root.clearTimeout(timer) + } catch (error) { + timer = null + } + try { + var now = Date.now() + var snapshot = agentReadiness(routeName, expectedParams, limits) + reply(agentVerdict(snapshot, signals, painted, now, quietMs, idleMs)) + } catch (error) { + reply({ + error: 'command-failed', + detail: String(error && (error as any).message).slice(0, 200) + }) + } + } + var raf = root.requestAnimationFrame + if (typeof raf !== 'function') { + finish(null) + return + } + try { + var timeout = + typeof limits.paintTimeoutMs === 'number' && limits.paintTimeoutMs > 0 + ? limits.paintTimeoutMs + : 500 + timer = root.setTimeout(function () { + finish(false) + }, timeout) + raf.call(root, function () { + raf.call(root, function () { + finish(signals.commitAt === commitAtStart) + }) + }) + } catch (error) { + finish(null) + } +} + + +/* ======================================================================= presentations + + The in-app presentation runtime, ported from the reference implementation's + `src/server/app-flow/presentations-runtime.js`. + + ## What this buys + + A modal, bottom sheet or dialog is a *variant* of a screen, not a route. The sweep cannot reach + one by navigating, and reaching one by tapping needs a model planner plus a tap that may do + something else entirely. The reference's answer, ported here, is to open it the way the app + itself opens it: call the component's own `useState` setter, or the zero-argument `open()` on the + controller object the app already holds. The host's existing capture then photographs it, and + `presentation-rollback` puts the app back exactly as it was. + + ## The three effect kinds + + - **state** — the opening is `setShowSheet(true)`. We find the live hook, remember the current + value, and call the app's own setter with the functional form, which is what keeps a queued app + update from being clobbered by ours. + - **control** — the opening is `sheetRef.current.present()`. We call that zero-argument method + and remember its paired closer. + - **mount** — there is no opening at all: the component is projected on its own (below). + + ## The synthetic-Modal projection + + `agentProject` is the part that most deserves reading before changing. It does **not** clone a + fiber tree. It builds one element, `react.createElement(type, props)`, re-wraps it in the context + providers found up the focus's `return` chain, puts it inside an error boundary, and appends that + as a **sibling** of the live app inside a fresh `Modal`. Four properties are load-bearing and each + was a bug in the reference before it was a rule: + + - The copy is a *new element of the same type*, so it renders from scratch with the app's own + component. A fiber clone would share mutable state with the live tree. + - Contexts are re-created up the chain, **excluding** `ScrollView.Context` and + `VirtualizedList.contextType`: a new Modal is a new native window, and restoring a list parent + from a different window hands native list code a container it does not own. + - Errors are contained by swapping the root's `onCaughtError` for a wrapper that suppresses + **only** this boundary's errors. Every other app error keeps the original handler, so the app's + own LogBox behaviour is unchanged. + - The new Modal is attached with `renderer.overrideProps(root, [], record.next)` where `next` + spreads the app's existing root children *flat* alongside the modal. Nesting them under a new + array would change the element identity React reconciles against and remount the whole live + app, re-running every effect in it. + + ## Why rollback is the safety-critical half + + Opening is one call. Putting the app back is a protocol. On iOS a `Modal` reports its dismissal + through `onDismiss`, and React Native removes that listener when the Modal unmounts. So the order + is: set `visible: false` but **keep the Modal mounted**, poll for `record.dismissed` with a + 2000ms deadline, and only then restore the root's original children. Unmounting first removes the + listener before the event can arrive, and the app is then left with a native window nothing can + prove is closed. For the same reason a detached owner on iOS throws rather than quietly dropping + the checkpoint: a projection whose root vanished cannot be confirmed dismissed. + + ## What is bounded, and why every bound is kept + + Everything here runs on the app's JS thread. The hook-collection patch window is 80ms and the + patch is removed in every exit path; bindings are capped at 1500, hook indices at 300, stacks at + 8000 characters; the seed substitution expires after 1000ms; a control dismissal waits at most + 4000ms with one retry; a modal dismissal at most 2000ms. These are the reference's numbers and + are deliberately not re-tuned here. + + ## Deliberately not ported + + - The reference's Babel instrumentation plugin and its `__MOBILE_DEV_FLOW_REGISTRY__` prepared + binding fast path. This repo has no transform hook, so there is no registry to read and the + stack-symbolication path below is the only binding route. + - `openedSites`/`controlSites`. Those read `flow-owner-N:` ids off Profiler (`tag === 12`) + markers that only the instrumentation plugin emits, so here they would always return nothing. + - The in-app capture driver and queue. The host drives capture. + - The TanStack Query observer result reuse, the portal/UI-effect approval paths, the prepared + host-slot placement and the inline (non-Modal) projection placement. Each is an optimisation + layered on top of the mechanics above and each needs its own stage. */ + +/** Most hook slots walked on one owner when locating a binding. The reference's cap. */ +export const PRESENTATION_HOOK_INDEX_CAP = 300 +/** Most live hook bindings held at once. The reference's cap. */ +export const PRESENTATION_BINDING_CAP = 1500 +/** Longest creation stack retained per binding, in characters. The reference's cap. */ +export const PRESENTATION_STACK_CAP = 8000 +/** How long hooks stay patched around a forced render pass, in milliseconds. */ +export const PRESENTATION_COLLECT_WINDOW_MS = 80 +/** How long a projected copy may go without consuming its seeded hook value. */ +export const PRESENTATION_SEED_DEADLINE_MS = 1000 +/** How long an iOS modal may take to report `onDismiss` before rollback gives up. */ +export const PRESENTATION_DISMISS_DEADLINE_MS = 2000 +/** How long a controller-owned native presentation may take to close, with one retry inside. */ +export const PRESENTATION_CONTROL_DEADLINE_MS = 4000 +/** Poll interval while waiting for a native dismissal. The reference's 40ms. */ +export const PRESENTATION_POLL_MS = 40 + +/** The six `Modal` props a projected copy inherits from the nearest ancestor `Modal`. */ +export const MODAL_CARRY_OVER_PROPS = [ + 'presentationStyle', + 'transparent', + 'statusBarTranslucent', + 'navigationBarTranslucent', + 'hardwareAccelerated', + 'supportedOrientations' +] as const + +/** A state site as the source scan emits it; only the fields the agent reads are named. */ +export interface PresentationStateSite { + id: string + file: string + owner: string + hook?: 'useState' | 'useReducer' + owners?: string[] + ownerSites?: { file: string; owner: string }[] +} +/** A presentation action as the source scan emits it; only the fields the agent reads are named. */ +export interface PresentationAction { + id: string + name: string + owner: string + component: string + prop: string + preview?: boolean + views?: string[] + effect: + | { kind: 'mount'; file: string; export: string } + | { kind: 'state'; site: string; path: string[]; value: unknown } + | { + kind: 'control' + component: string + prop: string + method: string + close: string | string[] + target?: { file: string; owner: string } + } + trigger?: Record + consumer?: { component: string } +} + +/** `presentation-setup`: hand the agent the source catalog and bind it to the live tree. */ +export interface PresentationSetupReply { + ok: boolean + /** Live hook bindings after the collection pass. */ + bindings: number + /** Bindings matched to a source state site. */ + matched: number + /** Owners the pass forced a render on. Bounded; never a component name. */ + scheduled: number + /** + * Bindings with no site yet, with the creation stack the host symbolicates against Metro. The + * host answers with `presentation-configure`. Bounded at 400 per kind. + */ + unbound?: { + id: string + owner: string + kind: 'state' | 'entry' + stack: string + source?: { file: string; line: number; column: number } + }[] + /** Live opener JSX records (the entry leg) and how many already name a catalog site. */ + entries?: number + entriesMatched?: number + /** Wall clock the whole pass occupied, for the host's bounded meter. */ + elapsedMs: number + error?: string +} +/** `presentation-configure`: how many of the host's matches named a live binding. */ +export interface PresentationConfigureReply { + ok: boolean + configured: number + elapsedMs: number +} +/** `presentations`: which catalog entries are openable against the tree as it is now. */ +export interface PresentationListReply { + available: { id: string; kind: 'state' | 'control' | 'mount' }[] + /** Catalog entries with no live binding. A count only; never an id or a name. */ + unavailable: number + /** Why entries were refused, as a count per closed-set reason. Never an id or a name. */ + rejected?: Record + elapsedMs: number +} +/** `presentation-open` / `presentation-rollback`. */ +export type PresentationOpenReply = + | { ok: true; kind: 'state' | 'control' | 'mount'; checkpoint: number; elapsedMs: number } + | { ok: false; error: string; reason?: string; checkpoint: number; elapsedMs: number } +export type PresentationRollbackReply = + | { ok: true; checkpoint: number; elapsedMs: number; evidence?: string } + | { ok: false; error: string; checkpoint: number; elapsedMs: number } + +/** + * Whether `value` is a presentation controller: a zero-argument opener paired with a closer. + * + * Ported verbatim from the reference (`openingControl`). The arity check is the whole point — a + * one-argument `open(id)` is an app API that needs data we do not have, and calling it would be a + * guess. Descriptors are read rather than properties, so a getter is never invoked: evaluating an + * app getter to decide whether something is a sheet controller is exactly the kind of side effect + * this runtime must not have. + */ +export function agentOpeningControl(value: any, method?: string): boolean { + if (!value || typeof value !== 'object') return false + var data = function (key: string): any { + var descriptor = Object.getOwnPropertyDescriptor(value, key) + return descriptor && 'value' in descriptor ? descriptor.value : undefined + } + var pairs: Array<[string, string[]]> = [ + ['open', ['close', 'dismiss', 'hide']], + ['present', ['dismiss', 'close']], + ['show', ['hide', 'close']], + ['expand', ['close', 'collapse']] + ] + for (var at = 0; at < pairs.length; at++) { + var key = pairs[at]![0] + var closers = pairs[at]![1] + if (method && method !== key) continue + var opener = data(key) + if (typeof opener !== 'function' || opener.length !== 0) continue + for (var c = 0; c < closers.length; c++) { + if (typeof data(closers[c]!) === 'function') return true + } + } + return false +} + +/** The paired closer's name for a controller, or null. Mirrors `agentOpeningControl`'s table. */ +export function agentControlCloser(value: any, method: string): string | null { + if (!value || typeof value !== 'object') return null + var table: Record = { + open: ['close', 'dismiss', 'hide'], + present: ['dismiss', 'close'], + show: ['hide', 'close'], + expand: ['close', 'collapse'] + } + var closers = table[method] + if (!closers) return null + for (var at = 0; at < closers.length; at++) { + var descriptor = Object.getOwnPropertyDescriptor(value, closers[at]!) + if (descriptor && 'value' in descriptor && typeof descriptor.value === 'function') { + return closers[at]! + } + } + return null +} + +/** + * Immutably writes `next` at `path` inside `value`. The reference's `setPath`, iterative here + * because an injected function cannot rely on recursing by name (see the file doc). + * + * An empty path replaces the whole value, which is the common case (`setShowSheet(true)`). + */ +export function agentSetPath(value: any, path: string[], next: any): any { + if (!Array.isArray(path) || !path.length) return next + var copies: any[] = [] + var cursor = value + for (var at = 0; at < path.length; at++) { + copies.push(cursor) + cursor = cursor === null || cursor === undefined ? undefined : cursor[path[at]!] + } + var result = next + for (var back = path.length - 1; back >= 0; back--) { + var source = copies[back] + var copy: any + if (Array.isArray(source)) copy = source.slice() + else if (source && typeof source === 'object') { + copy = {} + for (var key in source) copy[key] = source[key] + } else copy = {} + copy[path[back]!] = result + result = copy + } + return result +} + +/** + * A committed-tree index: every visible fiber, a map from either alternate to the *current* one, + * and a map from a props object to the fibers rendering it. + * + * The reference's `committedStructure`/`index` pair, reduced to the three lookups this stage needs. + * `current` is what makes a checkpoint survive a commit: React swaps alternates on every render, so + * a fiber captured at open time is routinely the stale half by the time rollback runs, and every + * read of a remembered fiber goes through this map first. `props` is what lets a projected copy's + * element be found again in the tree without matching on a component name. + * + * Hidden subtrees are pruned with `agentHidden`, and — exactly as in the readiness walk — the + * sibling is pushed *before* the prune, because a fiber's later siblings are reachable only through + * it and dropping them would sever the rest of the list. + */ +export function agentPresentationIndex(pres: any): any { + if (pres.structureCache) return pres.structureCache + var all: any[] = [] + var current = new Map() + var props = new Map() + var names = new Map() + var seen = new Set() + var concealed = new Set() + var stack = agentCollectFiberRoots() + var budget = pres.limits.walkBudget + var deadline = Date.now() + pres.limits.walkDeadlineMs + var capped = false + while (stack.length) { + if (budget-- <= 0 || ((budget & 255) === 0 && Date.now() > deadline)) { + capped = true + break + } + var fiber = stack.pop() + if (!fiber || seen.has(fiber)) continue + seen.add(fiber) + if (fiber.sibling) stack.push(fiber.sibling) + if (concealed.has(fiber.return) || agentHidden(fiber.memoizedProps)) { + concealed.add(fiber) + continue + } + if (fiber.child) stack.push(fiber.child) + all.push(fiber) + current.set(fiber, fiber) + if (fiber.alternate) current.set(fiber.alternate, fiber) + var label = agentComponentName(fiber.type) || agentComponentName(fiber.elementType) + if (label) { + var byName = names.get(label) + if (!byName) { + byName = [] + names.set(label, byName) + } + byName.push(fiber) + } + var owned = fiber.memoizedProps + if (owned && typeof owned === 'object') { + var byProps = props.get(owned) + if (!byProps) { + byProps = [] + props.set(owned, byProps) + } + byProps.push(fiber) + } + } + // Live opener JSX records, keyed by the catalog site the host's join matched them to. A site id + // is an action id, or `:target` / `:consumer` / `:expected` for the other JSX the + // action names. Only fibers that are in this (visible) tree are listed, as their current half. + var entryMap = new Map() + if (pres.entries) { + pres.entries.forEach(function (record: any) { + record.fibers.forEach(function (entryFiber: any) { + var live = current.get(entryFiber) + if (!live) return + record.actions.forEach(function (site: string) { + var list = entryMap.get(site) + if (!list) { + list = [] + entryMap.set(site, list) + } + if (list.indexOf(live) < 0) list.push(live) + }) + }) + }) + } + var visibility = new Map() + var tree: any = { + all: all, + current: current, + props: props, + names: names, + entries: entryMap, + capped: capped, + // Mounted, not pruned as hidden, and not under a disabled, unfocused or hidden-modal ancestor. + isVisible: function (fiber: any): boolean { + var live = current.get(fiber) + if (!live) return false + var known = visibility.get(live) + if (known === undefined) { + known = agentActiveAncestors(live) + visibility.set(live, known) + } + return known + }, + inside: function (fiber: any, owner: any): boolean { + if (!owner) return false + // Prepared JSX and native navigation wrappers can exceed 100 ancestors. Compare exact + // fibers and stop on a cycle, not at an arbitrary UI depth. + var walked = new Set() + for (var parent = fiber; parent && !walked.has(parent); parent = parent.return) { + if (parent === owner || parent === owner.alternate) return true + walked.add(parent) + } + return false + } + } + pres.structureCache = tree + return tree +} + +/** + * Whether a fiber is live on screen as far as its own ancestors can say: no hidden, disabled, + * unfocused or closed-modal ancestor. + * + * The reference's `activeAncestors`, without its native-bounds measurement. A screen that React + * Navigation keeps mounted but not focused reports `navigation.isFocused() === false`, which is the + * property that keeps another screen's opener from counting as this screen's. + */ +export function agentActiveAncestors(fiber: any): boolean { + var walked = new Set() + for (var parent = fiber; parent && !walked.has(parent); parent = parent.return) { + walked.add(parent) + var props = parent.memoizedProps + if (!props || typeof props !== 'object') continue + if (agentHidden(props)) return false + if (props.disabled === true) return false + if (props.accessibilityState && props.accessibilityState.disabled === true) return false + if ( + props.visible === false && + (agentComponentName(parent.type) === 'Modal' || + (parent.tag === 5 && typeof props.onShow === 'function')) + ) { + return false + } + for (var key in props) { + if (props[key] === false && /^(?:is)?(?:screen|page|tab)(?:focused|active)$/i.test(key)) { + return false + } + } + if (props.route && props.navigation && typeof props.navigation.isFocused === 'function') { + try { + if (!props.navigation.isFocused()) return false + } catch (error) { + return false + } + } + } + return true +} + +/** + * The focused leaf route: the one mounted screen whose `navigation.isFocused()` is true and that + * contains no other focused screen. Modelled on the reference's `mountContext`. + * + * Returns that route's fiber, or null when there is none or more than one. Null means "do not + * restrict by screen", not "refuse": the visibility rules still apply on their own. + */ +export function agentFocusedScope(tree: any): any { + if (tree.scopeResolved) return tree.scope + var routes: any[] = [] + for (var at = 0; at < tree.all.length; at++) { + var fiber = tree.all[at] + var props = fiber.memoizedProps + if (!props || !props.route || typeof props.route.name !== 'string') continue + if (!props.navigation || typeof props.navigation.isFocused !== 'function') continue + var focused = false + try { + focused = props.navigation.isFocused() === true && tree.isVisible(fiber) + } catch (error) { + focused = false + } + if (focused) routes.push(fiber) + } + var leaves: any[] = [] + for (var r = 0; r < routes.length; r++) { + var holdsAnother = false + for (var c = 0; c < routes.length; c++) { + if (c !== r && tree.inside(routes[c], routes[r])) holdsAnother = true + } + if (!holdsAnother) leaves.push(routes[r]) + } + tree.scope = leaves.length === 1 ? leaves[0] : null + tree.scopeResolved = true + return tree.scope +} + +/** + * Whether `fiber` belongs to `owner`: physically beneath it, or rendered by it (React's dev-only + * `_debugOwner` chain). The second route is what survives a portal, which moves an element's host + * parents away from the component that created it. Bounded and cycle-safe. + */ +export function agentOwnedBy(fiber: any, owner: any): boolean { + if (!fiber || !owner) return false + var queue: any[] = [fiber] + var seen = new Set() + for (var at = 0; at < queue.length && at < 2000; at++) { + var node = queue[at] + if (!node || seen.has(node)) continue + seen.add(node) + if (node === owner || node === owner.alternate) return true + if (node.return) queue.push(node.return) + if (node._debugOwner) queue.push(node._debugOwner) + } + return false +} + +/** + * The nearest vertical scroll container above a fiber, or null. Owners sharing one are rows of the + * same list; pagers, horizontal lists and unrelated owners elsewhere on screen do not. + */ +export function agentListOf(fiber: any, tree: any): any { + var walked = new Set() + for (var parent = fiber.return; parent && !walked.has(parent) && walked.size < 400; parent = parent.return) { + walked.add(parent) + if (parent.tag !== 5) continue + var kind = + parent.stateNode && parent.stateNode.viewConfig && parent.stateNode.viewConfig.uiViewClassName + ? parent.stateNode.viewConfig.uiViewClassName + : typeof parent.type === 'string' + ? parent.type + : undefined + if (kind !== 'RCTScrollView' && kind !== 'AndroidScrollView') continue + var props = parent.memoizedProps + if (props && (props.horizontal || props.pagingEnabled || props.scrollEnabled === false)) return null + return tree.current.get(parent) ? tree.current.get(parent) : parent + } + return null +} + +/** The visible fiber count of a freshly walked tree, or -1 when the walk hit its budget. */ +export function agentVisibleCount(pres: any): number { + pres.structureCache = undefined + var tree = agentPresentationIndex(pres) + pres.structureCache = undefined + return tree.capped ? -1 : tree.all.length +} + +/** + * The value a hook binding currently holds. The reference's `hookValue`. + * + * Re-reads through the tree's `current` map first, for the alternate-swap reason above, and rebinds + * `binding.fiber` to the live half so the next read starts from the right place. + */ +export function agentHookValue(pres: any, binding: any): any { + var tree = agentPresentationIndex(pres) + var live = tree.current.get(binding.fiber) + if (!live) return undefined + binding.fiber = live + var state = live.memoizedState + for (var at = 0; state && at < binding.index; at++) state = state.next + return state ? state.memoizedState : undefined +} + +/** + * The component identities a source state site's owner could be, read out of Metro's module map. + * + * Ported from the reference's `collectionTargets`. The one rule that must not be relaxed: only + * **data** descriptors are read (`'value' in descriptor`). Invoking an export getter to find out + * whether it is the `Settings` component would initialise a project module as a side effect of + * looking, which is the kind of thing this runtime exists not to do. A module that has not been + * initialised is skipped rather than loaded for the same reason. + * + * A site whose owner cannot be resolved to an identity sets `fallback`, which widens collection to + * every fiber with that display name rather than refusing the site outright. + */ +export function agentCollectionTargets(pres: any, states: any[]): any { + var targets = new Map() + var modules = (globalThis as any).__r && (globalThis as any).__r.getModules + ? (globalThis as any).__r.getModules() + : null + var initialized = new Map() + var normalize = function (value: string): string { + return String(value).split('\\').join('/').replace(/^file:\/\//, '').replace(/^\.\//, '') + } + if (modules && typeof modules.values === 'function') { + var values = modules.values() + var next = values.next() + while (!next.done) { + var module = next.value + if (module && module.isInitialized && typeof module.verboseName === 'string') { + initialized.set( + normalize(module.verboseName), + module.publicModule ? module.publicModule.exports : undefined + ) + } + next = values.next() + } + } + var exportsFor = function (file: string): any { + var direct = initialized.get(file) + if (direct !== undefined) return direct + return pres.sourceRoot ? initialized.get(pres.sourceRoot + '/' + file) : undefined + } + for (var s = 0; s < states.length; s++) { + var site = states[s] + if (!site || typeof site.id !== 'string') continue + var qualified = Array.isArray(site.ownerSites) ? site.ownerSites : [] + var sources: Array<{ owner: string; file: string }> = [{ owner: site.owner, file: site.file }] + for (var q = 0; q < qualified.length; q++) sources.push(qualified[q]) + var extra = Array.isArray(site.owners) ? site.owners : [] + for (var e = 0; e < extra.length; e++) { + var already = false + for (var k = 0; k < qualified.length; k++) if (qualified[k].owner === extra[e]) already = true + if (!already) sources.push({ owner: extra[e], file: site.file }) + } + for (var at = 0; at < sources.length; at++) { + var owner = sources[at]!.owner + var file = sources[at]!.file + var target = targets.get(owner) + if (!target) { + target = { types: new Set(), sitesByType: new Map(), fallbackSites: new Set(), fallback: false } + targets.set(owner, target) + } + var addType = function (bucket: any, type: any, id: string): void { + bucket.types.add(type) + var sites = bucket.sitesByType.get(type) + if (!sites) { + sites = new Set() + bucket.sitesByType.set(type, sites) + } + sites.add(id) + } + var exported = exportsFor(file) + var matches: any[] = [] + var descriptors = + exported && (typeof exported === 'object' || typeof exported === 'function') + ? Object.getOwnPropertyDescriptors(exported) + : {} + for (var key in descriptors) { + var descriptor = (descriptors as any)[key] + // Read data exports only. Never initialize a project module or invoke a getter to + // identify one of several unrelated same-named components. + if (!descriptor || !('value' in descriptor)) continue + var type = descriptor.value + var read = function (holder: any, field: string): any { + if (!holder || (typeof holder !== 'object' && typeof holder !== 'function')) return undefined + var found = Object.getOwnPropertyDescriptor(holder, field) + return found && 'value' in found ? found.value : undefined + } + var body = read(type, 'render') + if (body === undefined) body = read(type, 'type') + if (body === undefined) body = type + var named = + key === owner || + (body && (body.displayName === owner || body.name === owner)) + if (named && typeof body === 'function') { + matches.push(type) + matches.push(body) + } + } + if (typeof exported === 'function' && exported.name === owner) matches.push(exported) + if (matches.length) { + for (var m = 0; m < matches.length; m++) addType(target, matches[m], site.id) + } else { + target.fallback = true + target.fallbackSites.add(site.id) + } + } + } + return targets +} + +/** + * Replaces React's `useState`/`useReducer` with wrappers that record every setter the forced render + * pass produces, and that substitute a seeded initial value for a projected copy's first render. + * + * Ported from the reference's `patchHooks`. Two properties carry over unchanged: + * + * - The **first** module exposing a complete React surface wins and the loop breaks. Several + * modules re-export React; patching more than one would wrap the same functions twice. + * - A non-writable `useState` aborts the whole patch and un-patches what was already applied, + * rather than leaving React half-wrapped. + * + * The wrappers are created by `agentHookWrapper` rather than inline in the loop because **some + * Hermes versions share loop bindings across closures** — the reference's own comment, and the + * reason each wrapper needs its own function scope. Injected code bypasses Metro's block-scope + * transform, so the `let` that would make this safe in app code does not help here. + */ +export function agentPatchPresentationHooks(pres: any): boolean { + if (pres.patches.length) return true + var modules = (globalThis as any).__r && (globalThis as any).__r.getModules + ? (globalThis as any).__r.getModules() + : null + if (!modules || typeof modules.values !== 'function') return false + var values = modules.values() + var next = values.next() + while (!next.done) { + var module = next.value + if (module && module.isInitialized) { + var react = agentMutableReact(module.publicModule ? module.publicModule.exports : undefined) + if ( + react && + typeof react.createElement === 'function' && + typeof react.useState === 'function' && + typeof react.useReducer === 'function' + ) { + var keys = ['useState', 'useReducer'] + for (var at = 0; at < keys.length; at++) { + var key = keys[at]! + var descriptor = Object.getOwnPropertyDescriptor(react, key) + if (!descriptor || !descriptor.writable) continue + var original = react[key] + var wrapped = agentHookWrapper(pres, key, original) + try { + react[key] = wrapped + } catch (error) { + agentUnpatchPresentationHooks(pres) + return false + } + pres.patches.push({ react: react, key: key, original: original, wrapped: wrapped }) + } + break + } + } + next = values.next() + } + return pres.patches.length > 0 +} + +/** Removes the hook patch, leaving anything a third party wrapped afterwards in place. */ +export function agentUnpatchPresentationHooks(pres: any): void { + for (var at = 0; at < pres.patches.length; at++) { + var patch = pres.patches[at] + if (patch.react[patch.key] === patch.wrapped) patch.react[patch.key] = patch.original + } + pres.patches.length = 0 +} + +/** + * One hook wrapper, in its own function scope. + * + * Injected code bypasses Metro's block-scope transform. Some Hermes versions share loop bindings + * across closures, so each wrapper needs a function scope of its own; this is that scope. + */ +export function agentHookWrapper(pres: any, kind: string, original: any): any { + return function (): any { + var args: any[] = [] + for (var at = 0; at < arguments.length; at++) args.push(arguments[at]) + return agentRegisterHook(pres, kind, original, args) + } +} + +/** + * The body of every hook wrapper: seed the copy, call the real hook, record the setter. + * + * Ported from the reference's `register`. Three things are exact and each matters: + * + * - **The seed substitutes arguments, it never dispatches.** A projected copy that needs its state + * to start at some value gets that value as the hook's *initial* argument + * (`useReducer` keeps its reducer and takes `[reducer, value, () => value]`). Dispatching the + * app's own reducer, or running its initializer, could fire a request and manufacture a backend + * response for a screenshot. + * - **Nothing is wrapped and nothing is retained.** The real setter is returned to the app + * untouched; restoration later uses the exact hook index collected during this render, including + * indices contributed by custom hooks. + * - **The owner's alternate is checked too**, because the render being observed may be running on + * either half. + */ +export function agentRegisterHook(pres: any, kind: string, original: any, args: any[]): any { + var live = pres.current() + if (!live) return original.apply(undefined, args) + var index = 0 + for (var hook = live.fiber.memoizedState; hook && index < 300; hook = hook.next) index++ + var pending = live.fiber.pendingProps + if (pending === undefined || pending === null) pending = live.fiber.memoizedProps + var projection: any = null + for (var at = 0; at < pres.projected.length && !projection; at++) { + var candidate = pres.projected[at] + if (candidate.seed && candidate.child.props === pending && candidate.focus.type === live.fiber.type) { + projection = candidate + } + } + if (projection && projection.seed.index === index && projection.seed.kind === kind) { + var value = projection.seed.value + projection.seed.applied = true + // Seed the temporary instance's hook. Never dispatch the original reducer or run its + // initializer to manufacture a backend response. + args = + kind === 'useReducer' + ? [args[0], value, function () { return value }] + : [function () { return value }] + } + var result = original.apply(undefined, args) + if (!pres.collecting.has(live.fiber) && !pres.collecting.has(live.fiber.alternate)) return result + if (!Array.isArray(result) || typeof result[1] !== 'function') return result + var record = pres.owners.get(live.fiber) + if (!record) record = pres.owners.get(live.fiber.alternate) + if (!record) record = new Map() + pres.owners.set(live.fiber, record) + var id = record.get(index) + var binding = id ? pres.bindings.get(id) : undefined + if (!binding && pres.bindings.size < 1500) { + id = String(++pres.sequence) + record.set(index, id) + binding = { + id: id, + index: index, + kind: kind, + stack: String(new Error().stack).slice(0, 8000), + fiber: live.fiber, + renderer: live.renderer, + setter: result[1] + } + pres.bindings.set(id, binding) + } + if (binding) { + binding.fiber = live.fiber + binding.setter = result[1] + } + // Do not wrap setters or keep a second callback in the app. Restoration uses the exact hook + // index collected during this render, including custom hooks. + return result +} + +/** + * The real React object behind a module's exports. + * + * The reference's `mutableReact`, kept because its reason is still true here: an ESM namespace + * object can report a property as writable and then reject the assignment, so the `default` export + * is preferred — but only when it is provably the *same* runtime (same `createElement`, same + * `useState`, same `useReducer`), never merely because a `default` exists. + */ +export function agentMutableReact(exported: any): any { + if (!exported) return exported + var descriptor = Object.getOwnPropertyDescriptor(exported, 'default') + var value = descriptor && 'value' in descriptor ? descriptor.value : undefined + if ( + value && + value.createElement === exported.createElement && + typeof value.useState === 'function' && + value.useState === exported.useState && + value.useReducer === exported.useReducer + ) return value + return exported +} + +/** + * Records the opener JSX of every catalog action that is mounted right now: the entry leg. + * + * Ported from the reference's `entry`/`records`. A fiber's `_debugStack` is the `Error` React's + * development build captures where its element was created, so its frames point at the JSX that + * rendered it, even when many unrelated components share a display name. Nothing here calls the + * element's callback or reads anything but the stack text. Records are keyed by component name plus + * stack, and bounded; the host symbolicates each record's stack through Metro and the answer comes + * back as `presentation-configure`, which fills `record.actions`. + */ +export function agentRegisterEntries(pres: any, tree: any): void { + var wanted = new Set() + var actions = pres.catalog.actions + for (var a = 0; a < actions.length; a++) { + var action = actions[a] + if (!action || !action.effect || action.effect.kind === 'mount') continue + wanted.add(action.component) + if (action.effect.kind === 'control') wanted.add(action.effect.component) + if (action.consumer) wanted.add(action.consumer.component) + } + var mounted = new Set(tree.all) + var gone: string[] = [] + pres.entries.forEach(function (record: any, id: string) { + var live = false + record.fibers.forEach(function (fiber: any) { + if (mounted.has(fiber) || mounted.has(fiber.alternate)) live = true + }) + if (live) record.fibers.clear() + else gone.push(id) + }) + for (var g = 0; g < gone.length; g++) { + var dead = pres.entries.get(gone[g]!) + pres.entries.delete(gone[g]!) + if (dead) pres.entryKeys.delete(dead.key) + } + for (var at = 0; at < tree.all.length; at++) { + var fiber = tree.all[at] + var label = agentComponentName(fiber.type) || agentComponentName(fiber.elementType) + if (!label || !wanted.has(label)) continue + var origin = fiber._debugStack ? fiber._debugStack : fiber._debugSource + if (!origin || typeof origin !== 'object') continue + var record = pres.entrySources.get(origin) + if (record && pres.entries.get(record.id) !== record) { + // The record was dropped as stale; a source object can outlive it, so look it up again. + var existing = pres.entryKeys.get(record.key) + if (existing) record = existing + else if (pres.entries.size < 1500) { + pres.entries.set(record.id, record) + pres.entryKeys.set(record.key, record) + } else record = undefined + } + if (!record) { + var stack = typeof origin.stack === 'string' ? origin.stack.slice(0, 8000) : undefined + var location = + !stack && typeof origin.fileName === 'string' + ? { + file: origin.fileName, + line: origin.lineNumber, + column: Math.max(0, (origin.columnNumber === undefined ? 1 : origin.columnNumber) - 1) + } + : undefined + if (!stack && !location) continue + var key = JSON.stringify([label, stack ? stack : location]) + record = pres.entryKeys.get(key) + if (!record && pres.entries.size < 1500) { + record = { + id: 'entry-' + ++pres.sequence, + key: key, + kind: 'entry', + name: label, + stack: stack, + source: location, + actions: new Set(), + fibers: new Set(), + checked: false + } + pres.entryKeys.set(key, record) + pres.entries.set(record.id, record) + } + if (record) pres.entrySources.set(origin, record) + } + if (record) record.fibers.add(fiber) + } +} + +/** + * Binds the source catalog's state sites to live hooks, inside a bounded patch window. + * + * Ported from the reference's `collect`. The shape of it is the whole safety argument: + * + * 1. Drop bindings whose owner is no longer mounted. + * 2. Decide which mounted fibers are candidate owners, from `agentCollectionTargets`. A fiber whose + * `_debugHookTypes` proves it has no `useState`/`useReducer` is skipped, and so is `tag === 14` + * (`SimpleMemoComponent`), exactly as in the reference. + * 3. Patch hooks, force a render on each candidate with `renderer.scheduleUpdate(fiber)`, and wait + * **80ms**. + * 4. Un-patch, in every exit path. + * + * Step 4 is why step 3 may be bounded at all. The app runs with wrapped hooks only for the length + * of one forced render pass; nothing observes it afterwards, no timer survives, no dispatcher is + * replaced and the app's own source is never touched. If the wait were unbounded, or the un-patch + * conditional, a disconnect in the middle would leave the app permanently instrumented — which is + * also why the agent's lease watchdog calls `restore` and reaches this through `undo`. + */ +export function agentPresentationCollect(pres: any, done: (value: unknown) => void): void { + var started = Date.now() + var root = globalThis as any + var finish = function (error?: string): void { + agentUnpatchPresentationHooks(pres) + pres.collecting.clear() + pres.structureCache = undefined + var matched = 0 + // Bindings the in-app attribution could not name a site for, with the creation stack the host + // needs to symbolicate them through Metro (`presentations-bindings.ts`) and hand back with + // `presentation-configure`. Bounded, and carrying nothing but a stack and an owner label. + var unbound: Array<{ id: string; owner: string; kind: string; stack: string; source?: any }> = [] + var stateCount = 0 + pres.bindings.forEach(function (binding: any) { + if (binding.site) { + matched += 1 + return + } + if (stateCount >= 400 || !binding.stack || binding.checked) return + stateCount += 1 + unbound.push({ + id: binding.id, + owner: + agentComponentName(binding.fiber && binding.fiber.type) || + agentComponentName(binding.fiber && binding.fiber.elementType) || + '', + kind: 'state', + stack: binding.stack + }) + }) + // The entry leg: opener JSX the host has not yet matched to a catalog site. A record the host + // already looked at and could not place is `checked` and is not sent again. + var entryCount = 0 + var entriesMatched = 0 + pres.entries.forEach(function (record: any) { + if (record.actions.size) { + entriesMatched += 1 + return + } + if (record.checked || entryCount >= 400) return + if (!record.stack && !record.source) return + entryCount += 1 + var wire: any = { id: record.id, owner: record.name, kind: 'entry', stack: record.stack ? record.stack : '' } + if (record.source) wire.source = record.source + unbound.push(wire) + }) + done({ + ok: !error, + bindings: pres.bindings.size, + matched: matched, + entries: pres.entries.size, + entriesMatched: entriesMatched, + unbound: unbound, + scheduled: pres.lastScheduled, + elapsedMs: Date.now() - started, + error: error + }) + } + try { + pres.lastScheduled = 0 + pres.structureCache = undefined + var tree = agentPresentationIndex(pres) + var mounted = new Set(tree.all) + var stale: string[] = [] + pres.bindings.forEach(function (binding: any, id: string) { + if (!mounted.has(binding.fiber) && !mounted.has(binding.fiber.alternate)) stale.push(id) + }) + for (var s = 0; s < stale.length; s++) pres.bindings.delete(stale[s]!) + // The entry leg must never cost the state leg: a failure here only means fewer entries. + try { + agentRegisterEntries(pres, tree) + } catch (error) { + pres.entries.clear() + pres.entryKeys.clear() + } + var targets = agentCollectionTargets(pres, pres.catalog.states) + for (var at = 0; at < tree.all.length; at++) { + var fiber = tree.all[at] + if (fiber.tag === 14) continue + if (Array.isArray(fiber._debugHookTypes)) { + var stateful = false + for (var h = 0; h < fiber._debugHookTypes.length; h++) { + var kind = fiber._debugHookTypes[h] + if (kind === 'useState' || kind === 'useReducer') stateful = true + } + if (!stateful) continue + } + var label = agentComponentName(fiber.type) || agentComponentName(fiber.elementType) + var target = targets.get(label) + if (!target) continue + if (target.fallback || target.types.has(fiber.type) || target.types.has(fiber.elementType)) { + pres.collecting.add(fiber) + var byType = target.sitesByType.get(fiber.type) + if (!byType) byType = target.sitesByType.get(fiber.elementType) + pres.pendingSites.set(fiber, byType ? byType : target.fallbackSites) + } + } + if (!pres.collecting.size || !agentPatchPresentationHooks(pres)) { + finish() + return + } + pres.collecting.forEach(function (fiber: any) { + var renderers = pres.renderers() + for (var r = 0; r < renderers.length; r++) { + if (typeof renderers[r].scheduleUpdate !== 'function') continue + try { + renderers[r].scheduleUpdate(fiber) + pres.lastScheduled += 1 + } catch (error) { + continue + } + break + } + }) + if (!pres.lastScheduled) { + finish() + return + } + root.setTimeout(function () { + try { + agentAttributeBindings(pres) + } catch (error) { + finish(String(error && (error as any).message).slice(0, 120)) + return + } + finish() + }, 80) + } catch (error) { + finish(String(error && (error as any).message).slice(0, 120)) + } +} + +/** + * Labels each freshly collected binding with the source site its owner was a candidate for. + * + * Without the reference's instrumentation plugin there is no per-hook marker, so a binding can only + * be attributed by its owner. An owner that is a candidate for exactly one site takes that site; an + * owner that is a candidate for several is left unattributed rather than guessed at, because + * opening the wrong state is indistinguishable from the app doing it. + */ +export function agentAttributeBindings(pres: any): void { + pres.bindings.forEach(function (binding: any) { + if (binding.site) return + var sites = pres.pendingSites.get(binding.fiber) + if (!sites) sites = pres.pendingSites.get(binding.fiber.alternate) + if (!sites || sites.size !== 1) return + sites.forEach(function (id: string) { + binding.site = id + }) + }) +} + +/** + * The bindings and controllers one catalog action could be opened through, right now. + * + * This is the reduced stand-in for the reference's `resolveEntry`. The reference resolves an entry + * through its instrumented JSX markers and falls back to an elaborate identity search; with no + * instrumentation the only honest resolution is: + * + * - **state**: gated on the entry leg exactly as a control is (`agentResolveStateEntry`). A catalog + * `site` is the `useState` call, and many openers share one (every button that calls a provider's + * `openComposer` writes the same hook), so the site alone says nothing about which screen the + * opener is on. + * - **control**: a mounted fiber rendering the named component which carries, on the named prop (or + * that prop's `ref.current`), an object `agentOpeningControl` accepts. Again exactly one. + * - **mount**: the exported component identity named by the effect, read from Metro's module map + * without initialising anything. + */ +export function agentResolvePresentationEntry(pres: any, action: any, tree: any): any { + if (action.effect.kind === 'state') return agentResolveStateEntry(pres, action, tree) + if (action.effect.kind === 'control') return agentResolveControlEntry(pres, action, tree) + if (action.effect.kind === 'mount') { + var type = agentMountedExport(pres, action.effect.file, action.effect.export) + if (!type) return { reason: 'not-loaded', error: 'The source component is not loaded.' } + // The reference's two `mountedExport` guards (presentations-runtime.js:1313-1314), which the + // first port left out. Without them any always-loaded module's export is offered on every + // screen, because nothing else about a mount is screen-scoped. + var body = agentMountBody(type) + if (typeof body !== 'function' || agentComponentName(type) !== action.owner) { + return { reason: 'owner-mismatch', error: 'The source export is not the named component.' } + } + // An app-wide component the app already renders itself — a NUX announcement mounted in the + // shell, say. Projecting a second cold copy shows whatever it renders with no provider state, + // which for a gated component is nothing at all; the live one is the real thing. + var mounted = false + for (var m = 0; m < tree.all.length; m++) { + var fiber = tree.all[m] + if (fiber.type === type || fiber.elementType === type || fiber.type === body) { + mounted = true + break + } + } + if (mounted) { + return { reason: 'already-mounted', error: 'The app already renders this component.' } + } + return { type: type, expected: action.owner } + } + return { reason: 'unsupported', error: 'Unsupported presentation transition.' } +} + +/** + * The entry leg shared by every non-preview action: which one visible owner this action's opener + * JSX lives in, or why there is none. + * + * Modelled on the reference's `find` (entries ~1404, owners ~1411, hard gate ~1558): + * + * - The hard gate: a non-preview action whose opener JSX the host has not matched to a live fiber + * is refused outright (`unbound`). Nothing about a state site or a controller can stand in for it. + * - Each bound opener must be the named component carrying a function on the named prop, enabled, + * inside the focused screen and visible; `trigger` props must match. + * - The owner is the innermost visible fiber named `action.owner` that holds an opener. Owners that + * are rows of one vertical list are the only case where several are accepted. + * + * Returns `{ owners, openers }`, or a refusal `{ reason, error }` from a closed set. + */ +export function agentResolveOpener(pres: any, action: any, tree: any): any { + var scope = agentFocusedScope(tree) + var inScope = function (fiber: any): boolean { + return !scope || tree.inside(fiber, scope) + } + var refuse = function (reason: string, error: string): any { + return { reason: reason, error: error } + } + var labelOf = function (fiber: any): string { + return agentComponentName(fiber.type) || agentComponentName(fiber.elementType) + } + var bound = tree.entries.get(action.id) + if (!bound || !bound.length) { + return refuse('unbound', 'The opening control could not be bound to its source.') + } + var openers: any[] = [] + var disabled = 0 + for (var e = 0; e < bound.length; e++) { + var opener = bound[e] + var props = opener.memoizedProps + if (labelOf(opener) !== action.component) continue + if (!props || typeof props[action.prop] !== 'function') continue + if ( + props.disabled === true || + props['aria-disabled'] === true || + (props.accessibilityState && props.accessibilityState.disabled === true) + ) { + disabled += 1 + continue + } + if (!inScope(opener) || !tree.isVisible(opener)) continue + var triggered = true + if (action.trigger) { + for (var key in action.trigger) if (props[key] !== action.trigger[key]) triggered = false + } + if (triggered) openers.push(opener) + } + if (!openers.length) { + return disabled + ? refuse('disabled', 'The opening control is disabled in this app state.') + : refuse('not-in-view', 'The opening control is not in the active view.') + } + var candidates = tree.names.get(action.owner) + var visibleOwners: any[] = [] + for (var c = 0; candidates && c < candidates.length; c++) { + if (tree.isVisible(candidates[c]) && inScope(candidates[c])) visibleOwners.push(candidates[c]) + } + var owners: any[] = [] + for (var o = 0; o < visibleOwners.length; o++) { + var owner = visibleOwners[o] + var holdsOpener = false + for (var h = 0; h < openers.length; h++) if (tree.inside(openers[h], owner)) holdsOpener = true + if (!holdsOpener) continue + var holdsInner = false + for (var i = 0; i < visibleOwners.length; i++) { + if (visibleOwners[i] !== owner && tree.inside(visibleOwners[i], owner)) holdsInner = true + } + if (!holdsInner) owners.push(owner) + } + if (!owners.length) return refuse('no-owner', 'The opening control has no visible source owner in the active view.') + if (owners.length > 1) { + var list = agentListOf(owners[0], tree) + var rows = !!list + for (var r = 1; rows && r < owners.length; r++) if (agentListOf(owners[r], tree) !== list) rows = false + if (!rows) return refuse('ambiguous-owner', 'More than one live owner renders this opening control.') + } + return { owners: owners, openers: openers } +} + +/** + * Resolves a `state` action to the one `useState` binding it may write, or says why it cannot. + * + * A state site is a hook call, not a screen: a provider's single `useState` is the destination of + * every opener that calls its `openComposer`, so matching `binding.site === effect.site` alone made + * one sheet "belong" to every opener in the app and opened it on screens that render none of them. + * The opener's entry is what places an action on a screen, so it gates a state action just as it + * gates a control (`agentResolveOpener`), and then, as in the reference's `resolveOwner`: + * + * - the owner's own opener callbacks must be one distinct function (a `handler` name may break a tie); + * - the action's prop `guard` must hold on the owner's props; + * - exactly one visible `useState` binding carries the site, and when the site is declared by the + * action's own owner component that binding must live inside the chosen owner; + * - the state must not already hold the opening value. + * + * An action that carries opening `input` (data computed from the opener's arguments) is refused: + * this runtime only writes the catalog's static `effect.value` and never evaluates `input`, so + * writing it would open the wrong thing. A failed or missing source join leaves no entry bound and + * the action is refused as `unbound`, so a degraded join captures nothing rather than something + * else. Previews are source-synthesised views, not presses, and keep the visibility-only rule. + */ +export function agentResolveStateEntry(pres: any, action: any, tree: any): any { + var effect = action.effect + var refuse = function (reason: string, error: string): any { + return { reason: reason, error: error } + } + var read = function (binding: any): any { + var value = agentHookValue(pres, binding) + for (var p = 0; p < effect.path.length; p++) { + value = value === null || value === undefined ? undefined : value[effect.path[p]] + } + return value + } + var sameSite = function (binding: any): boolean { + return binding.site === effect.site && binding.kind !== 'useReducer' && !!tree.current.get(binding.fiber) + } + if (action.preview) { + var visible: any[] = [] + pres.bindings.forEach(function (binding: any) { + if (sameSite(binding) && tree.isVisible(binding.fiber)) visible.push(binding) + }) + if (visible.length !== 1) { + return visible.length + ? refuse('ambiguous-binding', 'More than one live instance owns this opening state.') + : refuse('no-binding', 'The opening state could not be bound to its source.') + } + return { binding: visible[0] } + } + if (action.input) { + return refuse('unsupported', 'This opening needs data from its opener, which this runtime cannot supply.') + } + var resolved = agentResolveOpener(pres, action, tree) + if (resolved.error) return resolved + var site: any = null + var states = pres.catalog.states + for (var s = 0; states && s < states.length; s++) if (states[s].id === effect.site) site = states[s] + var last: any = null + for (var w = 0; w < resolved.owners.length && w < 24; w++) { + var owner = resolved.owners[w] + var callbacks: any[] = [] + var named: any[] = [] + for (var o = 0; o < resolved.openers.length; o++) { + var opener = resolved.openers[o] + if (!tree.inside(opener, owner)) continue + var callback = opener.memoizedProps[action.prop] + if (callbacks.indexOf(callback) < 0) callbacks.push(callback) + } + if (callbacks.length > 1 && action.handler) { + for (var c = 0; c < callbacks.length; c++) if (callbacks[c].name === action.handler) named.push(callbacks[c]) + callbacks = named + } + if (callbacks.length !== 1) { + last = refuse('ambiguous-callback', 'More than one opening entry matches this control.') + continue + } + if (!agentCondition(action.guard, owner.memoizedProps)) { + last = refuse('unmet-condition', 'The opening control does not meet its source condition.') + continue + } + var matches: any[] = [] + pres.bindings.forEach(function (binding: any) { + if (!sameSite(binding) || !tree.isVisible(binding.fiber)) return + if (site && site.owner === action.owner && !tree.inside(binding.fiber, owner)) return + matches.push(binding) + }) + if (matches.length !== 1) { + last = matches.length + ? refuse('ambiguous-binding', 'More than one live instance owns this opening state.') + : refuse('no-binding', 'The opening state could not be bound to its source.') + continue + } + var value = read(matches[0]) + var already = + JSON.stringify(value) === JSON.stringify(effect.value) || + (!effect.path.length && typeof effect.value === 'object' && value !== null && value !== undefined) + if (already) { + last = refuse('already-open', 'This state already holds the opening value.') + continue + } + return { binding: matches[0] } + } + return last +} + +/** + * Evaluates a source-derived prop condition (`AppFlowUiCondition`) against a props object. + * The reference's `condition`. A missing node is true; an unknown operator is false. + */ +export function agentCondition(node: any, props: any): any { + if (!node) return true + if ('value' in node) return node.value + if (node.prop) { + var value = props + for (var at = 0; at < node.prop.length; at++) { + value = value === null || value === undefined ? undefined : value[node.prop[at]] + } + return value + } + var a = agentCondition(node.args[0], props) + var b = node.args[1] && agentCondition(node.args[1], props) + switch (node.op) { + case '!': + return !a + case '&&': + return a && b + case '||': + return a || b + case '===': + return a === b + case '!==': + return a !== b + case '==': + // eslint-disable-next-line eqeqeq + return a == b + case '!=': + // eslint-disable-next-line eqeqeq + return a != b + } + return false +} + +/** + * Resolves a `control` action to the one controller it may open, or says why it cannot. + * + * A controller is app-wide in the way a context value is: the same `ReportDialog` can be mounted by + * a component that appears on no screen at all. Binding by name alone therefore opened it on every + * screen. Ownership is what makes it this screen's, and it is enforced the way the reference's + * `find` does: + * + * - The action's opener JSX (the entry, matched to this action by the host's source join) must be + * a live, visible fiber inside the focused screen. A non-preview action with no bound entry at all + * is refused outright: that is the reference's hard gate. + * - It must sit inside exactly one visible owner (the innermost visible fiber named `action.owner`). + * Owners that are rows of one vertical list are the only exception, and the first row whose + * controller resolves is used. + * - The controller must be rendered inside that owner, and there must be exactly one distinct value. + * + * Every refusal carries a `reason` from a closed set, so it can be counted without naming anything. + */ +export function agentResolveControlEntry(pres: any, action: any, tree: any): any { + var effect = action.effect + var scope = agentFocusedScope(tree) + var inScope = function (fiber: any): boolean { + return !scope || tree.inside(fiber, scope) + } + var refuse = function (reason: string, error: string): any { + return { reason: reason, error: error } + } + var labelOf = function (fiber: any): string { + return agentComponentName(fiber.type) || agentComponentName(fiber.elementType) + } + var controllerOf = function (fiber: any): any { + var props = fiber.memoizedProps + if (!props || typeof props !== 'object') return null + var carried = props[effect.prop] + if (carried && typeof carried === 'object' && 'current' in carried) carried = carried.current + if (!agentOpeningControl(carried, effect.method)) return null + var close = agentControlCloser(carried, effect.method) + return close ? { value: carried, close: close, fiber: fiber } : null + } + var controllers = function (pool: any[], keep: (fiber: any) => boolean): any { + var seen: any[] = [] + var distinct: any[] = [] + for (var at = 0; at < pool.length; at++) { + var fiber = pool[at] + if (labelOf(fiber) !== effect.component || !keep(fiber)) continue + var carried = controllerOf(fiber) + if (!carried) continue + if (seen.indexOf(carried.value) >= 0) continue + seen.push(carried.value) + distinct.push(carried) + } + return distinct + } + var targetPool = function (): any[] { + if (effect.target) { + var bound = tree.entries.get(action.id + ':target') + return bound ? bound : [] + } + var named = tree.names.get(effect.component) + return named ? named : [] + } + var pick = function (distinct: any[]): any { + if (distinct.length === 1) return { target: distinct[0] } + return distinct.length + ? refuse('ambiguous-control', 'More than one live control matches this opening.') + : refuse('no-control', 'The opening control could not be bound to its source.') + } + + if (action.preview) { + return pick( + controllers(targetPool(), function (fiber: any): boolean { + return inScope(fiber) && tree.isVisible(fiber) + }) + ) + } + + var resolved = agentResolveOpener(pres, action, tree) + if (resolved.error) return resolved + var owners = resolved.owners + var last: any = null + for (var w = 0; w < owners.length && w < 24; w++) { + var current = owners[w] + last = pick( + controllers(targetPool(), function (fiber: any): boolean { + return agentOwnedBy(fiber, current) + }) + ) + if (last.target) return last + } + return last +} + +/** + * The function a component type renders through: `memo`'s `type`, `forwardRef`'s `render`, or the + * component itself. Ported from the reference's `mountedExport` (presentations-runtime.js:1312). + * + * Read through own data descriptors so a getter on a user component is never invoked. + */ +export function agentMountBody(type: any): any { + if (!type || (typeof type !== 'object' && typeof type !== 'function')) return undefined + var read = function (key: string): any { + var descriptor = Object.getOwnPropertyDescriptor(type, key) + return descriptor && 'value' in descriptor ? descriptor.value : undefined + } + var render = read('render') + if (render !== undefined) return render + var inner = read('type') + if (inner !== undefined) return inner + return type +} + +/** + * One named export of an already-initialised Metro module, read as a data descriptor. + * + * Never calls `__r(key)` to warm a cold module: the reference only does that behind its prepared + * build, and executing a project module to obtain a component would run its top-level side effects. + */ +export function agentMountedExport(pres: any, file: string, name: string): any { + var modules = (globalThis as any).__r && (globalThis as any).__r.getModules + ? (globalThis as any).__r.getModules() + : null + if (!modules || typeof modules.values !== 'function') return null + var normalize = function (value: string): string { + return String(value).split('\\').join('/').replace(/^file:\/\//, '').replace(/^\.\//, '') + } + var values = modules.values() + var next = values.next() + while (!next.done) { + var module = next.value + if (module && module.isInitialized && typeof module.verboseName === 'string') { + var path = normalize(module.verboseName) + if (path === file || (pres.sourceRoot && path === pres.sourceRoot + '/' + file)) { + var exported = module.publicModule ? module.publicModule.exports : undefined + var descriptor = exported ? Object.getOwnPropertyDescriptor(exported, name) : undefined + if (descriptor && 'value' in descriptor && descriptor.value) return descriptor.value + } + } + next = values.next() + } + return null +} + +/** + * The React and React Native module objects plus the root host `View` a projection attaches to. + * + * Ported from the reference's `projectionRoot`. Only **framework** exports are accepted: a module + * qualifies as React Native when it exposes `Platform.OS`, `StyleSheet.create`, `View` and `Modal` + * together. No project module, account store or screen controller is ever evaluated to manufacture + * a destination, and nothing is initialised to look. + * + * The attachment point is the nearest ancestor `View` of the focus, because the app's main window + * cannot present while one of its own native sheets is open; attaching inside the container the + * focus already lives in keeps the copy in the same native window. + */ +export function agentProjectionRoot(pres: any, focus: any): any { + var modules = (globalThis as any).__r && (globalThis as any).__r.getModules + ? (globalThis as any).__r.getModules() + : null + if (!modules || typeof modules.values !== 'function') return null + var react: any = null + var native: any = null + var values = modules.values() + var next = values.next() + while (!next.done) { + var module = next.value + if (module && module.isInitialized) { + var exported = module.publicModule ? module.publicModule.exports : undefined + if (exported && typeof exported.createElement === 'function' && typeof exported.useState === 'function') { + react = agentMutableReact(exported) + } + // Framework exports only. No project module, account store or native screen controller is + // evaluated to manufacture a destination. + if ( + exported && + Object.getOwnPropertyDescriptor(exported, 'Platform') && + Object.getOwnPropertyDescriptor(exported, 'StyleSheet') + ) { + try { + if ( + exported.Platform && + typeof exported.Platform.OS === 'string' && + exported.StyleSheet && + typeof exported.StyleSheet.create === 'function' && + exported.View && + exported.Modal + ) native = exported + } catch (error) { + native = native + } + } + } + next = values.next() + } + if (!react || typeof react.Component !== 'function' || !native) return null + var ancestor: any = null + var seen = new Set() + for (var parent = focus; parent && !seen.has(parent) && !ancestor; parent = parent.return) { + seen.add(parent) + if (parent.type === native.View || parent.elementType === native.View) ancestor = parent + } + if (!ancestor) return null + var tree = agentPresentationIndex(pres) + var root = tree.current.get(ancestor) + if (!root) return null + var renderers = pres.renderers() + for (var at = 0; at < renderers.length; at++) { + if (typeof renderers[at].overrideProps === 'function') { + return { root: root, renderer: renderers[at], react: react, native: native } + } + } + return null +} + +/** + * The union of a component's outermost native views, in window coordinates. + * + * The reference's `bodyFrame`. A projected copy goes into a brand new `Modal`, and a `Modal` starts + * at the window origin — so a sheet that lived below the status bar would jump to the top of the + * screen and photograph differently from the real thing. Positioning the copy at the original's + * frame is what keeps the screenshot comparable. + */ +export function agentBodyFrame(focus: any): any { + var left = Infinity + var top = Infinity + var right = -Infinity + var bottom = -Infinity + var hosts = 0 + var stack = [focus ? focus.child : null] + while (stack.length && hosts < 50) { + var fiber = stack.pop() + if (!fiber) continue + if (fiber.sibling) stack.push(fiber.sibling) + if (fiber.tag !== 5) { + stack.push(fiber.child) + continue + } + hosts++ + try { + var box = agentWindowRect(fiber) + if (!box) continue + if (!isFinite(box.x) || !isFinite(box.y) || !isFinite(box.width) || !isFinite(box.height)) continue + if (box.width <= 0 || box.height <= 0) continue + left = Math.min(left, box.x) + top = Math.min(top, box.y) + right = Math.max(right, box.x + box.width) + bottom = Math.max(bottom, box.y + box.height) + } catch (error) { + continue + } + } + return right > left && bottom > top + ? { x: left, y: top, width: right - left, height: bottom - top } + : undefined +} + +/** One host fiber's window rect, through the public instance or Fabric's UI manager. */ +export function agentWindowRect(fiber: any): any { + var state = fiber.stateNode + var instance = state && state.canonical && state.canonical.publicInstance + ? state.canonical.publicInstance + : state + if (instance && typeof instance.getBoundingClientRect === 'function') { + return instance.getBoundingClientRect() + } + var node = state ? state.node : undefined + var manager = (globalThis as any).nativeFabricUIManager + var rect = + node && manager && typeof manager.getBoundingClientRect === 'function' + ? manager.getBoundingClientRect(node, true) + : undefined + if (!Array.isArray(rect) || rect.length !== 4) return undefined + return { x: rect[0], y: rect[1], width: rect[2], height: rect[3] } +} + +/** + * The `Modal` props a projected copy inherits from the nearest ancestor `Modal`. + * + * Ported from the reference. A sheet rendered inside a `transparent` `pageSheet` modal looks + * nothing like the same sheet in an opaque full-screen one, so these six presentation props are + * carried over — and only these six, from the **nearest** ancestor, stopping at the first one + * found. `transparent: false` is the default the carried-over props then override, which is why the + * caller spreads this *after* the default rather than before. + * + * Pure, so the selection is testable without a device. + */ +export function agentModalCarryOver(focus: any, Modal: any): Record { + var carried: Record = {} + if (!Modal) return carried + var keys = [ + 'presentationStyle', + 'transparent', + 'statusBarTranslucent', + 'navigationBarTranslucent', + 'hardwareAccelerated', + 'supportedOrientations' + ] + var seen = new Set() + for (var parent = focus ? focus.return : null; parent && !seen.has(parent); parent = parent.return) { + seen.add(parent) + if (parent.type !== Modal && parent.elementType !== Modal) continue + var props = parent.memoizedProps + if (props) { + for (var at = 0; at < keys.length; at++) { + if (keys[at]! in props) carried[keys[at]!] = props[keys[at]!] + } + } + break + } + return carried +} + +/** + * Whether copying this component would mount a second copy of the live app navigation. + * + * The reference's `copiesLiveNavigation`, and the single most important refusal in this file. A + * provider's existing `children` can contain the whole app navigator; re-creating that provider + * would mount a second navigator, run its framework subscriptions, and leave errors in every later + * capture. The reference's rule is kept exactly: walk the element tree the props carry, find the + * fibers rendering those exact props objects *inside* the focus, and refuse if any mounted fiber + * under one of them looks like a React Navigation screen (a `route.name` string together with a + * `navigation.getState` function). + * + * Two deliberate conservatisms, both the reference's: an element graph that exceeds 2000 nodes is + * refused outright rather than walked further, and descriptors are read rather than properties so + * no app getter runs during the check. The branch is left unresolved rather than made to work by + * stripping children; only a source recipe that isolates the presentation body may unblock it. + */ +export function agentCopiesLiveNavigation(focus: any, props: any, tree: any): boolean { + var own = function (holder: any, key: string): any { + if (!holder || (typeof holder !== 'object' && typeof holder !== 'function')) return undefined + var descriptor = Object.getOwnPropertyDescriptor(holder, key) + return descriptor && 'value' in descriptor ? descriptor.value : undefined + } + var children = own(props, 'children') + if (!children || typeof children !== 'object') return false + var scopes: any[] = [] + var seen = new Set() + var queue: any[] = [children] + while (queue.length && seen.size < 2000) { + var element = queue.pop() + if (!element || typeof element !== 'object' || seen.has(element)) continue + seen.add(element) + if (Array.isArray(element)) { + for (var at = 0; at < element.length; at++) queue.push(element[at]) + continue + } + var type = own(element, 'type') + var elementProps = own(element, 'props') + if (!type || !elementProps) continue + var rendering = tree.props.get(elementProps) + var list = rendering ? rendering : [] + for (var r = 0; r < list.length; r++) { + var fiber = list[r] + if ((fiber.type === type || fiber.elementType === type) && tree.inside(fiber, focus)) { + scopes.push(fiber) + } + } + var nested = own(elementProps, 'children') + if (nested) queue.push(nested) + } + if (queue.length) return true + if (!scopes.length) return false + for (var n = 0; n < tree.all.length; n++) { + var node = tree.all[n] + var nodeProps = node.memoizedProps + var route = own(nodeProps, 'route') + var navigation = own(nodeProps, 'navigation') + if (typeof own(route, 'name') !== 'string') continue + if (typeof own(navigation, 'getState') !== 'function') continue + for (var s = 0; s < scopes.length; s++) if (tree.inside(node, scopes[s])) return true + } + return false +} + +/** + * Suppresses **only** this projection's caught render errors. + * + * Ported from the reference's `containPreviewErrors`/`previewErrorHandler`. React Native reports + * even caught render errors to LogBox, so a copy that throws once would paint a red box over every + * later capture. The root's `onCaughtError` is swapped for a wrapper that returns early *only* when + * the error came from this exact boundary class on this exact root; every other error reaches the + * app's original handler unchanged. Neither the error nor its message is retained. + */ +export function agentContainPreviewErrors(pres: any, focus: any, record: any, boundaryType: any): void { + var root: any = null + var seen = new Set() + for (var parent = focus; parent && !seen.has(parent); parent = parent.return) { + seen.add(parent) + if (parent.tag === 3) root = parent.stateNode + } + if (!root || typeof root.onCaughtError !== 'function') return + record.errorRoot = root + record.boundaryType = boundaryType + for (var at = 0; at < pres.caughtPatches.length; at++) { + if (pres.caughtPatches[at].root === root && root.onCaughtError === pres.caughtPatches[at].wrapped) return + } + var original = root.onCaughtError + var projected = pres.projected + var wrapped = function (this: any, error: any, info: any): any { + for (var index = 0; index < projected.length; index++) { + var item = projected[index] + if (item.errorRoot === root && info && info.errorBoundary && info.errorBoundary.constructor === item.boundaryType) { + item.failed = true + return undefined + } + } + return original.apply(this, arguments) + } + root.onCaughtError = wrapped + pres.caughtPatches.push({ root: root, original: original, wrapped: wrapped }) +} + +/** Restores every root error handler no live projection still needs. */ +export function agentReleasePreviewErrors(pres: any, all: boolean): void { + for (var index = pres.caughtPatches.length - 1; index >= 0; index--) { + var patch = pres.caughtPatches[index] + var needed = false + if (!all) { + for (var at = 0; at < pres.projected.length; at++) { + if (pres.projected[at].errorRoot === patch.root) needed = true + } + } + if (needed) continue + if (patch.root.onCaughtError === patch.wrapped) patch.root.onCaughtError = patch.original + pres.caughtPatches.splice(index, 1) + } +} + +/** Expires a seeded hook value so the hook patch cannot outlive one render. */ +export function agentSeedDeadline(pres: any, record: any): void { + var root = globalThis as any + try { + root.clearTimeout(record.seedTimer) + } catch (error) { + record.seedTimer = undefined + } + record.seedTimer = root.setTimeout(function () { + if (pres.projected.indexOf(record) < 0) return + if (!record.seed.applied) record.failed = true + var waiting = false + for (var at = 0; at < pres.projected.length; at++) { + var item = pres.projected[at] + if (item.seed && !item.seed.applied && !item.failed) waiting = true + } + if (!pres.collecting.size && !waiting) agentUnpatchPresentationHooks(pres) + }, 1000) +} + +/** + * Projects a component as a synthetic `Modal` sibling of the live app. + * + * Ported from the reference's `project`, minus its prepared host-slot and inline placements (see + * the section doc). Read the file doc above for why each of the following is the way it is; in + * order, this function: + * + * 1. Refuses when the copy would duplicate live navigation. + * 2. Builds the copy as `react.createElement(type, preview.props || focus.memoizedProps)` — one new + * element of the same type, **not** a fiber-tree clone. + * 3. Re-wraps it in the context providers up `focus.return`, excluding `ScrollView.Context` and + * `VirtualizedList.contextType`, because a new `Modal` is a new native window and restoring a + * list parent from a different window hands native code a container it does not own. + * 4. Wraps it in a `PreviewBoundary` and swaps the root error handler for one that suppresses only + * this boundary. + * 5. Carries the six presentation props over from the nearest ancestor `Modal`, over a default of + * `transparent: false`, with `animationType: 'none'` so no transition is photographed. + * 6. Positions the body absolutely at the original's frame. + * 7. Attaches the modal as a **flat sibling** of the app's existing root children through + * `renderer.overrideProps(root, [], record.next)`. Nesting the existing children under a new + * array instead would change what React reconciles against and remount the live app. + */ +export function agentProject(pres: any, focus: any, preview: any, mountedContext: any): any { + pres.structureCache = undefined + var tree = agentPresentationIndex(pres) + var live = tree.current.get(focus) + if (live) focus = live + if (!focus) return { error: 'This view cannot be projected.' } + for (var at = 0; at < pres.projected.length; at++) { + if (!preview && (pres.projected[at].focus === focus || pres.projected[at].focus === focus.alternate)) { + return { error: 'This view cannot be projected.' } + } + } + var sourceProps = preview && preview.props ? preview.props : focus.memoizedProps + // A provider's existing children can contain the live app navigator. Copying that provider would + // mount a second app, run framework subscriptions and leave errors in later captures. Keep the + // branch unresolved until a source recipe isolates its body; never strip arbitrary children. + if (agentCopiesLiveNavigation(focus, sourceProps, tree)) { + return { + status: 'needs-data', + error: 'This preview would duplicate live navigation. Its source recipe must isolate the presentation from the existing app children.' + } + } + var context = mountedContext ? mountedContext : agentProjectionRoot(pres, focus) + if (!context) return { error: 'This renderer cannot project a local view.' } + var type = focus.elementType ? focus.elementType : focus.type + if (!type || typeof type === 'string') return { error: 'No component view to project.' } + var root = context.root + var renderer = context.renderer + var react = context.react + var native = context.native + var props = root.memoizedProps + var child = react.createElement(type, sourceProps) + var content = child + // Keep live app providers. A new Modal resets native list/scroll ownership; copying those + // contexts inside it would restore a parent from a different native window. + var resetContexts: any[] = [] + if (native.ScrollView && native.ScrollView.Context) resetContexts.push(native.ScrollView.Context) + if (native.VirtualizedList && native.VirtualizedList.contextType) { + resetContexts.push(native.VirtualizedList.contextType) + } + var ancestors = new Set() + for (var parent = focus.return; parent && !ancestors.has(parent); parent = parent.return) { + ancestors.add(parent) + if (parent.tag !== 10 || !parent.memoizedProps || !('value' in parent.memoizedProps)) continue + var provider = parent.elementType ? parent.elementType : parent.type + if (!provider) continue + var identity = provider._context ? provider._context : provider + if (resetContexts.indexOf(identity) >= 0) continue + var carried = preview && preview.providers ? preview.providers.get(parent) : undefined + content = react.createElement( + provider, + { value: carried === undefined ? parent.memoizedProps.value : carried }, + content + ) + } + var record: any = { + root: root, + renderer: renderer, + react: react, + props: props, + focus: focus, + child: child, + content: content, + seed: preview ? preview.seed : undefined, + mount: preview ? preview.mount : undefined, + ios: native.Platform.OS === 'ios', + shown: false, + dismissed: false, + failed: false + } + var PreviewBoundary = class extends react.Component { + constructor(boundaryProps: any) { + super(boundaryProps) + ;(this as any).state = { failed: false } + } + static getDerivedStateFromError(): any { + return { failed: true } + } + componentDidCatch(): void { + record.failed = true + } + render(): any { + return (this as any).state.failed ? null : (this as any).props.children + } + } + var body = react.createElement(PreviewBoundary, null, content) + var key = 'mobile-flow-preview-' + ++pres.sequence + // A temporary Modal starts at the window origin. Keep the copy at its original body's frame, so + // a body below the status bar stays below it. + record.frame = agentBodyFrame(focus) + var frame = record.frame + var placed = + frame && (frame.x >= 1 || frame.y >= 1) + ? react.createElement( + native.View, + { style: { position: 'absolute', left: frame.x, top: frame.y, width: frame.width, height: frame.height } }, + body + ) + : body + record.positioned = placed !== body + var modalProps: any = { key: key, transparent: false } + var carriedProps = agentModalCarryOver(focus, native.Modal) + for (var carryKey in carriedProps) modalProps[carryKey] = carriedProps[carryKey] + modalProps.visible = true + modalProps.animationType = 'none' + modalProps.onShow = function () { + record.shown = true + } + modalProps.onDismiss = function () { + record.dismissed = true + } + var modal = react.createElement(native.Modal, modalProps, placed) + // React unwraps one unkeyed root Fragment before reconciling children. Append at that same + // level. Nesting the existing Fragment under a new sibling array would remount the live app and + // rerun all of its effects. + var transparentRoot = + props.children && + props.children.type === react.Fragment && + props.children.key == null && + props.children.props && + props.children.props.ref === undefined + var appBody = transparentRoot ? props.children.props.children : props.children + var appChildren = Array.isArray(appBody) ? appBody : [appBody] + record.element = modal + var nextChildren = react.createElement.apply( + react, + [react.Fragment, null].concat(appChildren).concat([modal]) + ) + record.next = {} + for (var propKey in props) record.next[propKey] = props[propKey] + record.next.children = nextChildren + if (record.seed && !agentPatchPresentationHooks(pres)) { + return { error: 'Temporary hook initialization is unavailable.' } + } + // React Native reports even caught render errors to LogBox. Contain only errors caught by this + // exact temporary boundary; all app errors keep the root's original handler. + agentContainPreviewErrors(pres, focus, record, PreviewBoundary) + pres.projected.push(record) + pres.undo.push({ projection: record }) + pres.structureCache = undefined + renderer.overrideProps(root, [], record.next) + if (record.seed) agentSeedDeadline(pres, record) + return { ok: true, focus: focus } +} + +/** Whether a projection's modal is still among its root's children. */ +export function agentProjectionAttached(pres: any, record: any): boolean { + var tree = agentPresentationIndex(pres) + var root = tree.current.get(record.root) + var children = + root && root.memoizedProps && root.memoizedProps.children && root.memoizedProps.children.props + ? root.memoizedProps.children.props.children + : undefined + if (!Array.isArray(children)) return false + for (var at = 0; at < children.length; at++) if (children[at] === record.element) return true + return false +} + +/** Drops a projection's bookkeeping and un-patches anything no other projection still needs. */ +export function agentReleaseProjection(pres: any, record: any): void { + var root = globalThis as any + try { + root.clearTimeout(record.seedTimer) + } catch (error) { + record.seedTimer = undefined + } + var position = pres.projected.indexOf(record) + if (position >= 0) pres.projected.splice(position, 1) + agentReleasePreviewErrors(pres, false) + var waiting = false + for (var at = 0; at < pres.projected.length; at++) { + var item = pres.projected[at] + if (item.seed && !item.seed.applied && !item.failed) waiting = true + } + if (record.seed && !waiting) agentUnpatchPresentationHooks(pres) + pres.structureCache = undefined +} + +/** + * Opens one catalog entry and pushes its undo checkpoint. + * + * Ported from the reference's `open`. The three effect kinds are described in the section doc; what + * is worth restating at the call site is the **functional setter**: + * + * binding.setter(function (previous) { return guard ? previous : agentSetPath(...) }) + * + * React may already have a queued update from the app itself (a response that landed between the + * read and the write). Passing a plain value would discard it. Passing the functional form lets the + * update see the newest state and decline when it is no longer the state that was read, so the + * opening applies source-proven UI state and never replaces the app's own newer data. + */ +export function agentPresentationOpen(pres: any, id: string, focus: any): any { + var started = Date.now() + for (var at = 0; at < pres.undo.length; at++) { + if (pres.undo[at].closing) { + return { ok: false, error: 'A native presentation is still dismissing.', checkpoint: pres.undo.length, elapsedMs: Date.now() - started } + } + } + var action: any = null + for (var a = 0; a < pres.catalog.actions.length; a++) { + if (pres.catalog.actions[a].id === id) action = pres.catalog.actions[a] + } + if (!action) { + return { ok: false, error: 'The requested view is not in the source catalog.', checkpoint: pres.undo.length, elapsedMs: Date.now() - started } + } + pres.structureCache = undefined + var tree = agentPresentationIndex(pres) + var found = agentResolvePresentationEntry(pres, action, tree) + if (found.error) { + return { + ok: false, + error: found.error, + reason: found.reason ? found.reason : 'other', + checkpoint: pres.undo.length, + elapsedMs: Date.now() - started + } + } + if (action.effect.kind === 'mount') { + // The context is the focused leaf route, as in the reference's `mountContext`. The first fiber + // of the tree is the HostRoot, which has no `View` above it, so projecting from there found no + // attachment point and refused every mount preview on every screen. + var mountScope = focus ? focus : agentFocusedScope(tree) + var context = mountScope ? agentProjectionRoot(pres, mountScope) : null + if (!context) { + return { + ok: false, + error: 'This renderer cannot project a local view.', + checkpoint: pres.undo.length, + elapsedMs: Date.now() - started + } + } + var owner = { type: found.type, elementType: found.type, memoizedProps: {}, return: focus } + var projection = agentProject(pres, owner, { mount: true }, context) + if (!projection.ok) { + return { ok: false, error: projection.error, checkpoint: pres.undo.length, elapsedMs: Date.now() - started } + } + // The reference returns `expected: action.owner` from every open and refuses readiness when + // that component is absent or draws nothing (`presentations-runtime.js:2156-2159`). The host + // probes it with `presentation-content` once the screen settles. + return { + ok: true, + kind: 'mount', + expected: action.owner, + checkpoint: pres.undo.length, + elapsedMs: Date.now() - started + } + } + if (action.effect.kind === 'state') { + var binding = found.binding + var previous = agentHookValue(pres, binding) + for (var p = 0; p < action.effect.path.length; p++) { + previous = previous === null || previous === undefined ? undefined : previous[action.effect.path[p]] + } + pres.undo.push({ + binding: binding, + path: action.effect.path, + value: previous, + nativeDismiss: previous === null || previous === undefined || previous === false + }) + var before = previous + var path = action.effect.path + var opened = action.effect.value + // Apply source-proven UI state only. A queued app update owns its newer state; never replace + // it with the captured opening data. + binding.setter(function (current: any) { + var reference = current + for (var step = 0; step < path.length; step++) { + reference = reference === null || reference === undefined ? undefined : reference[path[step]] + } + if (!Object.is(reference, before)) return current + return agentSetPath(current, path, opened) + }) + pres.structureCache = undefined + return { ok: true, kind: 'state', checkpoint: pres.undo.length, elapsedMs: Date.now() - started } + } + if (action.effect.kind === 'control') { + var target = found.target + for (var o = 0; o < pres.undo.length; o++) { + if (!pres.undo[o].closed && pres.undo[o].control === target.value) { + return { ok: true, kind: 'control', checkpoint: pres.undo.length, elapsedMs: Date.now() - started } + } + } + pres.undo.push({ + control: target.value, + close: target.close, + focus: target.fiber, + nativeFocus: target.fiber, + origin: target.fiber, + // What the tree looked like before this opened. A dismissal is confirmed against it. + baseline: tree.capped ? -1 : tree.all.length + }) + target.value[action.effect.method]() + pres.structureCache = undefined + return { ok: true, kind: 'control', checkpoint: pres.undo.length, elapsedMs: Date.now() - started } + } + return { ok: false, error: 'Unsupported presentation transition.', checkpoint: pres.undo.length, elapsedMs: Date.now() - started } +} + +/** + * Waits for a controller-owned presentation to report that it closed. + * + * Ported from the reference's `waitForDismissal`, with its 4000ms deadline and its single retry. + * The reference confirms against native presentation status it observes; this port has none, so the + * confirmation is read off the committed tree (see the comments in the body). The shape check this + * used to make (`agentOpeningControl` on the controller) was true whether the sheet was open or + * not, so every control close ran to the deadline and failed, and a failed unwind is what latches + * the sweep's UI-state capture off. + * + * Expressed as a poll rather than a promise chain because this agent has no promise-based control + * flow (see the file's syntax rules). + */ +export function agentWaitForDismissal(pres: any, entry: any, done: (error: string | null) => void): void { + var root = globalThis as any + var started = Date.now() + var stable = 0 + var last = -2 + var step = function (): void { + if (entry.dismissed) { + pres.lastDismissEvidence = 'event' + done(null) + return + } + // A controller is a plain object with an opener and a closer; it looks identical open and + // closed, so its shape says nothing about whether the presentation went away. The tree does: + // an opened sheet mounts content, and a dismissed one takes it away again. + var count = agentVisibleCount(pres) + if (count >= 0) { + if (entry.openCount === undefined) entry.openCount = count + var grew = entry.baseline >= 0 ? entry.openCount - entry.baseline : 0 + if (grew > 0) { + // Back to (nearly) what it was before opening: the content is gone. + if (count - entry.baseline <= Math.max(2, Math.floor(grew / 4))) { + pres.lastDismissEvidence = 'tree' + done(null) + return + } + } else { + // The open added nothing visible to compare against, so the only evidence available is a + // tree that has stopped changing a while after the close was sent. + stable = count === last ? stable + 1 : 0 + last = count + if (stable >= 4 && Date.now() - started >= 800) { + pres.lastDismissEvidence = 'stable' + done(null) + return + } + } + } + var shrinking = count >= 0 && entry.openCount !== undefined && count < entry.openCount + if (!entry.closeRetried && !shrinking && Date.now() - started >= 1000) { + // A close can run before a native ref exists and silently do nothing. Retry once only, and + // only while nothing has begun to leave the tree: a close that is already animating stays + // single, because a second one can dismiss whatever opened behind it. + entry.closeRetried = true + try { + entry.control[entry.close]() + } catch (error) { + entry.closeRetried = true + } + } + // A full-height sheet in a long-running, larger app can take more than two seconds to report + // its dismissal; a confirmed close returns at once. + if (Date.now() - started >= 4000) { + done('Native presentation dismissal has not finished.') + return + } + root.setTimeout(step, 100) + } + step() +} + +/** + * Unwinds the undo stack down to `level`, confirming every native dismissal on the way. + * + * Ported from the reference's `restorePresentations`. The iOS ordering in the projection branch is + * the safety-critical part of this whole file and is kept exactly: + * + * 1. Clone the modal with `visible: false` and write it back — **the Modal stays mounted**. + * 2. Poll for `record.dismissed` with a 2000ms deadline. + * 3. Only then restore the root's original children. + * + * Unmounting at step 1 would remove React Native's event listener before `onDismiss` could run, and + * the app would be left with a native window nothing can prove is closed. For the same reason a + * projection whose root has detached, on iOS, after it was shown and before it was dismissed, + * **throws** rather than quietly dropping the checkpoint. + * + * A checkpoint is kept until its close actually succeeds (`entry.closed` is set only afterwards), so + * a thrown close does not discard the only way to restore the app on the next attempt. + */ +export function agentRestorePresentations( + pres: any, + level: number, + wait: boolean, + done: (error: string | null) => void +): void { + var root = globalThis as any + var floor = Math.max(0, level) + var step = function (): void { + if (pres.undo.length <= floor) { + if (wait) { + root.setTimeout(function () { + done(null) + }, 80) + return + } + done(null) + return + } + // Keep the checkpoint until its close operation succeeds. A thrown close must not discard the + // only way to restore the app on the next attempt. + var entry = pres.undo[pres.undo.length - 1] + if (entry.closed) { + pres.undo.pop() + step() + return + } + var finishEntry = function (): void { + entry.closed = true + pres.undo.pop() + step() + } + if (entry.projection) { + var record = entry.projection + try { + root.clearTimeout(record.seedTimer) + } catch (error) { + record.seedTimer = undefined + } + pres.structureCache = undefined + // Preserve new app children, but never infer native dismissal from an unmount which removed + // the callback needed to confirm it. + if (!agentProjectionAttached(pres, record)) { + if (record.ios && record.shown && !record.dismissed) { + done('Temporary modal dismissal is unconfirmed after its owner detached.') + return + } + agentReleaseProjection(pres, record) + finishEntry() + return + } + var restoreChildren = function (): void { + var currentProps = record.root.memoizedProps ? record.root.memoizedProps : record.props + var restored: any = {} + for (var key in currentProps) restored[key] = currentProps[key] + restored.children = record.props.children + pres.structureCache = undefined + record.renderer.overrideProps(record.root, [], restored) + } + if (!entry.closing) { + if (wait && record.ios && record.shown) { + // Keep Modal mounted while native dismissal runs. Unmounting first removes React + // Native's event listener before onDismiss can run. + var hidden = record.react.cloneElement(record.element, { visible: false }) + var siblings = record.next.children.props.children + var swapped: any[] = [] + for (var s = 0; s < siblings.length; s++) { + swapped.push(siblings[s] === record.element ? hidden : siblings[s]) + } + var children = record.react.cloneElement.apply( + record.react, + [record.next.children, {}].concat(swapped) + ) + var nextProps: any = {} + var live = record.root.memoizedProps ? record.root.memoizedProps : record.props + for (var liveKey in live) nextProps[liveKey] = live[liveKey] + nextProps.children = children + pres.structureCache = undefined + record.renderer.overrideProps(record.root, [], nextProps) + } else restoreChildren() + entry.closing = true + } + if (wait && record.ios && record.shown) { + // React Native emits Modal.onDismiss on iOS only. Retain the restore record while an + // already shown iOS modal is still dismissing. + var dismissStarted = Date.now() + var poll = function (): void { + if (record.dismissed) { + restoreChildren() + agentReleaseProjection(pres, record) + finishEntry() + return + } + if (!agentProjectionAttached(pres, record)) { + done('Temporary modal dismissal is unconfirmed after its owner detached.') + return + } + if (Date.now() - dismissStarted >= 2000) { + done('Temporary modal dismissal has not finished.') + return + } + root.setTimeout(poll, 40) + } + poll() + return + } + agentReleaseProjection(pres, record) + finishEntry() + return + } + if (entry.control) { + if (!entry.closing) { + entry.closing = true + // What the sheet looks like in the tree, measured before the close can change it. + if (wait && entry.openCount === undefined) { + var openCount = agentVisibleCount(pres) + if (openCount >= 0) entry.openCount = openCount + } + try { + entry.control[entry.close]() + } catch (error) { + done('The presentation control refused to close.') + return + } + } + if (wait) { + agentWaitForDismissal(pres, entry, function (error: string | null) { + if (error) { + done(error) + return + } + entry.closing = false + finishEntry() + }) + return + } + entry.closing = false + finishEntry() + return + } + if (!entry.closing) { + var path = entry.path + var value = entry.value + entry.binding.setter(function (current: any) { + return agentSetPath(current, path, value) + }) + entry.closing = true + pres.structureCache = undefined + } + entry.closing = false + finishEntry() + } + step() +} + +/** + * Serialises rollbacks. The reference keeps a `rollbackTask` promise chain for the same reason: two + * overlapping unwinds of one undo stack would each pop the other's checkpoint and leave the app in + * a state neither of them can describe. Here the chain is a queue of callbacks, because this agent + * has no promise-based control flow. + */ +/** + * Whether what a projection put on screen is actually worth photographing: the expected component + * is mounted, and something under it draws. + * + * The reference gets this from its readiness probe, whose reason chain answers `'target'` when the + * expected component is absent and `'empty'` when the focused view has no content + * (`presentations-runtime.js`, readiness at `runtime.js:629-649`). This port settles on device + * pressables instead, which cannot tell "still loading" from "renders nothing", so a component + * that draws nothing burned the full settle timeout and was then photographed blank anyway. + * + * Counts host fibers (`tag === 5`) with a non-zero visible box under the expected component. No app + * content is read: the result is two numbers and a boolean. + */ +export function agentPresentationContent(pres: any, expected: string): any { + pres.structureCache = undefined + var tree = agentPresentationIndex(pres) + var targets = expected ? tree.names.get(expected) : undefined + if (!targets || !targets.length) return { found: false, hosts: 0, visible: 0 } + var hosts = 0 + var visible = 0 + for (var t = 0; t < targets.length; t++) { + var root = targets[t] + var stack = [root] + var seen = 0 + while (stack.length && seen < 4000) { + var fiber = stack.pop() + seen++ + if (!fiber) continue + if (fiber.tag === 5) { + hosts++ + if (tree.isVisible(fiber)) visible++ + } + if (fiber.child) stack.push(fiber.child) + if (fiber !== root && fiber.sibling) stack.push(fiber.sibling) + } + } + return { found: true, hosts: hosts, visible: visible } +} + +export function agentPresentationRollback( + pres: any, + level: number, + wait: boolean, + done: (value: unknown) => void +): void { + var started = Date.now() + var run = function (): void { + agentRestorePresentations(pres, level, wait, function (error: string | null) { + pres.rollbackRunning = false + var reply: any = error + ? { ok: false, error: error, checkpoint: pres.undo.length, elapsedMs: Date.now() - started } + : { ok: true, checkpoint: pres.undo.length, elapsedMs: Date.now() - started } + if (pres.lastDismissEvidence) { + reply.evidence = pres.lastDismissEvidence + pres.lastDismissEvidence = undefined + } + try { + done(reply) + } catch (replyError) { + pres.rollbackRunning = false + } + var next = pres.rollbackQueue.shift() + if (next) next() + }) + } + if (pres.rollbackRunning) { + pres.rollbackQueue.push(function () { + pres.rollbackRunning = true + run() + }) + return + } + pres.rollbackRunning = true + run() +} + +/** The `presentations` command: which catalog entries are openable against the tree as it is now. */ +export function agentPresentationList(pres: any): any { + var started = Date.now() + pres.structureCache = undefined + var tree = agentPresentationIndex(pres) + var available: Array<{ id: string; kind: string }> = [] + var unavailable = 0 + var rejected: Record = {} + // Openers that reach the same destination are one view, listed once (the reference's `list` + // keys on the controller, or on `[site, path, value]` for a state). Without this a screen with + // two buttons for one provider's composer photographs that composer twice. + var aliased = 0 + var seenStates = new Set() + var seenControls: any[] = [] + var kinds: Record = {} + for (var at = 0; at < pres.catalog.actions.length && available.length < 200; at++) { + var action = pres.catalog.actions[at] + var found = agentResolvePresentationEntry(pres, action, tree) + if (found.error) { + unavailable += 1 + var why = found.reason ? found.reason : 'other' + rejected[why] = (rejected[why] ? rejected[why] : 0) + 1 + continue + } + if (found.binding && !action.preview) { + var stateKey = JSON.stringify([action.effect.site, action.effect.path, action.effect.value]) + if (seenStates.has(stateKey)) { + aliased += 1 + continue + } + seenStates.add(stateKey) + } + if (found.target && !action.preview) { + if (seenControls.indexOf(found.target.value) >= 0) { + aliased += 1 + continue + } + seenControls.push(found.target.value) + } + kinds[action.effect.kind] = (kinds[action.effect.kind] ? kinds[action.effect.kind] : 0) + 1 + available.push({ id: action.id, kind: action.effect.kind }) + } + return { + available: available, + unavailable: unavailable, + rejected: rejected, + aliased: aliased, + kinds: kinds, + elapsedMs: Date.now() - started + } +} + +/** + * The presentation runtime's state, created once per install and torn down with the agent. + * + * Everything mutable the port needs lives here rather than in a module constant, because an + * injected function is re-created in a fresh scope inside the app (see the file doc). The teardown + * is registered on the agent's `undo` list, so the lease watchdog un-patches React's hooks and + * every root error handler if the host stops talking — which is the whole reason a bounded patch + * window is safe to open at all. + */ +export function agentCreatePresentations(root: any, limits: AgentLimits, undo: Array<() => void>): any { + var pres: any = { + limits: limits, + sequence: 0, + sourceRoot: undefined, + catalog: { states: [], actions: [] }, + bindings: new Map(), + entries: new Map(), + entryKeys: new Map(), + entrySources: new WeakMap(), + lastDismissEvidence: undefined, + owners: new WeakMap(), + pendingSites: new WeakMap(), + collecting: new Set(), + patches: [], + caughtPatches: [], + projected: [], + undo: [], + rollbackQueue: [], + rollbackRunning: false, + lastScheduled: 0, + structureCache: undefined + } + pres.renderers = function (): any[] { + var hook = root.__REACT_DEVTOOLS_GLOBAL_HOOK__ + var found: any[] = [] + try { + if (hook && hook.renderers && typeof hook.renderers.forEach === 'function') { + hook.renderers.forEach(function (renderer: any) { + if (renderer && renderer.rendererPackageName === 'react-native-renderer') found.push(renderer) + }) + } + } catch (error) { + return found + } + return found + } + pres.current = function (): any { + var renderers = pres.renderers() + for (var at = 0; at < renderers.length; at++) { + var renderer = renderers[at] + if (typeof renderer.getCurrentFiber !== 'function') continue + var fiber: any = null + try { + fiber = renderer.getCurrentFiber() + } catch (error) { + fiber = null + } + if (fiber) return { fiber: fiber, renderer: renderer } + } + return null + } + undo.push(function () { + agentUnpatchPresentationHooks(pres) + agentReleasePreviewErrors(pres, true) + pres.collecting.clear() + pres.bindings.clear() + pres.entries.clear() + pres.entryKeys.clear() + pres.projected.length = 0 + pres.undo.length = 0 + pres.structureCache = undefined + }) + return pres +} + +/** + * Installs the agent at `globalThis[key]` and arms the lease. See the file doc for the lease and + * the fail-closed rules; the order below matters: + * + * 1. Restore any previous agent under the same key (a reconnect re-installs under the key it used + * before, and two live agents would double-arm the watchdog). + * 2. Refuse outright if there is no timer to arm. An agent that cannot expire is the one outcome + * this design exists to prevent. + * 3. Arm the lease *before* publishing the global, and restore on any failure, so there is no window + * in which the agent is reachable but unguarded. + * + * `invoke` takes the command as a JSON string and replies at most once, through the callback the + * host wraps around its binding. It never throws: a malformed or failing command becomes an + * `AgentErrorReply`, because an exception inside `Runtime.evaluate` would surface on the host as an + * evaluation failure and be indistinguishable from the wrapper itself failing to compile. + */ +export function agentInstall(key: string, leaseMs: number, limits: AgentLimits): void { + var root = globalThis as any + var previous = root[key] + if (previous && typeof previous.restore === 'function') { + try { + previous.restore() + } catch (error) { + previous = null + } + } + if (typeof root.setTimeout !== 'function' || typeof root.clearTimeout !== 'function') { + throw new Error('no timers available: refusing to install without a watchdog') + } + var lease = typeof leaseMs === 'number' && isFinite(leaseMs) && leaseMs > 0 ? leaseMs : 10000 + var stopped = false + var watchdog: any = null + var undo: Array<() => void> = [] + var agent: InstalledAgent | null = null + var signals: any = null + var presentations: any = null + + function restore(): void { + if (stopped) return + stopped = true + try { + root.clearTimeout(watchdog) + } catch (error) { + watchdog = null + } + watchdog = null + for (var i = undo.length - 1; i >= 0; i--) { + try { + undo[i]() + } catch (error) { + continue + } + } + undo.length = 0 + // Only remove the global if it is still ours, so a stale watchdog cannot delete a newer agent. + try { + if (root[key] === agent) delete root[key] + } catch (error) { + try { + root[key] = undefined + } catch (inner) { + stopped = true + } + } + } + + function renewLease(): void { + if (stopped) return + try { + root.clearTimeout(watchdog) + watchdog = root.setTimeout(function () { + restore() + }, lease) + } catch (error) { + // Fail closed: an agent that cannot re-arm its watchdog must not keep running. + restore() + } + } + + /* Runs a command and hands its reply to the callback; readiness calls it after the paint check. */ + function dispatch(command: any, done: (value: unknown) => void): void { + var type = command && command.type + if (type === 'heartbeat') { + done({ alive: true }) + } else if (type === 'nav-state') { + done(agentNavState(limits)) + } else if (type === 'navigate') { + done( + agentNavigate( + command.route, + command.params || null, + limits, + command.expoPath || null, + command.candidates || null + ) + ) + } else if (type === 'links') { + var max = + typeof command.max === 'number' && isFinite(command.max) && command.max > 0 + ? command.max + : 200 + done(agentCollectLinks(limits, max)) + } else if (type === 'readiness') { + var quiet = + typeof command.quietMs === 'number' && isFinite(command.quietMs) && command.quietMs >= 0 + ? command.quietMs + : limits.quietMs + var idle = + typeof command.idleMs === 'number' && isFinite(command.idleMs) && command.idleMs >= 0 + ? command.idleMs + : limits.idleMs + agentSettle( + signals, + String(command.route), + command.expectedParams || null, + limits, + quiet, + idle, + done + ) + } else if (type === 'presentation-setup') { + presentations.catalog = { + states: Array.isArray(command.states) ? command.states : [], + actions: Array.isArray(command.actions) ? command.actions : [] + } + presentations.sourceRoot = + typeof command.projectRoot === 'string' + ? String(command.projectRoot).split('\\').join('/').replace(/\/$/, '') + : undefined + agentPresentationCollect(presentations, done) + } else if (type === 'presentation-configure') { + var configureStarted = Date.now() + var matches = Array.isArray(command.matches) ? command.matches : [] + var configured = 0 + for (var mi = 0; mi < matches.length && mi < 3000; mi++) { + var match = matches[mi] + if (!match || typeof match.binding !== 'string' || typeof match.site !== 'string') continue + var target = presentations.bindings.get(String(match.binding)) + if (target) { + target.site = String(match.site) + configured += 1 + continue + } + var entryRecord = presentations.entries.get(String(match.binding)) + if (entryRecord) { + entryRecord.actions.add(String(match.site)) + configured += 1 + } + } + // Records the host looked at and could not place are not offered again. + var checkedIds = Array.isArray(command.checked) ? command.checked : [] + for (var ci = 0; ci < checkedIds.length && ci < 3000; ci++) { + var checkedRecord = + presentations.bindings.get(String(checkedIds[ci])) || + presentations.entries.get(String(checkedIds[ci])) + if (checkedRecord) checkedRecord.checked = true + } + presentations.structureCache = undefined + done({ ok: true, configured: configured, elapsedMs: Date.now() - configureStarted }) + } else if (type === 'presentations') { + done(agentPresentationList(presentations)) + } else if (type === 'presentation-open') { + done(agentPresentationOpen(presentations, String(command.id), null)) + } else if (type === 'presentation-content') { + done(agentPresentationContent(presentations, String(command.expected))) + } else if (type === 'presentation-rollback') { + var level = + typeof command.level === 'number' && isFinite(command.level) && command.level >= 0 + ? command.level + : 0 + agentPresentationRollback(presentations, level, command.wait !== false, done) + } else { + done({ error: 'unknown-command' }) + } + } + + agent = { + invoke: function (commandJson: string, reply: (value: unknown) => void): void { + var replied = false + var once = function (value: unknown): void { + if (replied) return + replied = true + try { + reply(value) + } catch (error) { + replied = true + } + } + var command: any + try { + command = JSON.parse(commandJson) + } catch (error) { + once({ error: 'invalid-command' }) + return + } + if (command && command.type === 'restore') { + restore() + once({ restored: true }) + return + } + if (stopped) { + once({ stopped: true }) + return + } + renewLease() + if (stopped) { + once({ stopped: true }) + return + } + try { + dispatch(command, once) + } catch (error) { + once({ + error: 'command-failed', + detail: String(error && (error as any).message).slice(0, 200) + }) + } + }, + restore: restore + } + renewLease() + if (stopped) throw new Error('could not arm the lease watchdog') + // Patch only after the watchdog is armed, so the app is never instrumented without an expiry, and + // restore on any failure so a half-installed agent cannot leave patches behind. + try { + signals = agentCreateSignals(root, undo, restore) + // The presentation runtime registers its own teardown on `undo`, so the lease watchdog + // un-patches React hooks and every swapped root error handler if the host stops talking. + presentations = agentCreatePresentations(root, limits, undo) + root[key] = agent + } catch (error) { + restore() + throw error + } +} + +/** + * Everything that is injected, in the order it is declared in the app. The key is the name each + * function is declared and called by; `runtime-source.ts` verifies `fn.name` matches it. + */ +export const AGENT_FUNCTIONS = { + agentCollectFiberRoots, + agentFindNavigator, + agentFlattenRoutes, + agentNavState, + agentRoutablePath, + agentLinkCandidate, + agentCollectLinks, + agentResolveRoutePath, + agentNavigate, + agentStyleFlags, + agentHidden, + agentComponentName, + agentReadiness, + agentPatchProperty, + agentCreateSignals, + agentVerdict, + agentSettle, + agentOpeningControl, + agentControlCloser, + agentSetPath, + agentActiveAncestors, + agentFocusedScope, + agentOwnedBy, + agentListOf, + agentVisibleCount, + agentPresentationIndex, + agentHookValue, + agentCollectionTargets, + agentMutableReact, + agentPatchPresentationHooks, + agentUnpatchPresentationHooks, + agentHookWrapper, + agentRegisterHook, + agentPresentationCollect, + agentAttributeBindings, + agentRegisterEntries, + agentResolvePresentationEntry, + agentResolveOpener, + agentResolveStateEntry, + agentCondition, + agentResolveControlEntry, + agentMountedExport, + agentProjectionRoot, + agentWindowRect, + agentBodyFrame, + agentModalCarryOver, + agentCopiesLiveNavigation, + agentContainPreviewErrors, + agentReleasePreviewErrors, + agentSeedDeadline, + agentProject, + agentProjectionAttached, + agentReleaseProjection, + agentPresentationOpen, + agentWaitForDismissal, + agentRestorePresentations, + agentMountBody, + agentPresentationContent, + agentPresentationRollback, + agentPresentationList, + agentCreatePresentations, + agentInstall +} as const diff --git a/src/app-flow/cdp/app-runtime-transport.ts b/src/app-flow/cdp/app-runtime-transport.ts new file mode 100644 index 00000000..6e1542bb --- /dev/null +++ b/src/app-flow/cdp/app-runtime-transport.ts @@ -0,0 +1,224 @@ +/** + * One persistent connection to the app's own JS runtime, as a `RenderTransport`. + * + * This is the handshake the Fast sweep grew for itself — select an unambiguous Metro target for + * this device, open a `CdpConnection` to it, and latch the failure so a run never re-pays for a + * handshake that already lost. It lives here rather than inside `orchestrator.ts` because it has a + * second caller now: runtime replay (`runtime-replay.ts`), which puts the app on one screen by + * dispatching through the navigator exactly as the sweep's render rung does. + * + * Duplicating it instead would mean two components disagreeing about whether the runtime is + * reachable — and about what to tell the user when it is not — which is precisely what the + * `CreateReachPortsOptions.renderTransport` comment warns against. It also keeps one set of + * `app_flow.runtime.*` meters: the connect timing, the per-command timing and the connection-state + * count are emitted here, so a replay's runtime cost lands in the same series as a sweep's and the + * two stay comparable across releases (see `docs/telemetry.md`). + * + * Nothing product-identifying is measured. The attributes are the closed sets the sweep already + * sent — `outcome`, `operation`, `state` — and the handshake's own failure text is scrubbed by + * `describeTransportFailure` before it goes anywhere near a log line, never to telemetry. + */ +import type { AgentCommand, NavigateReply, NavStateReply, ReadinessReply } from './agent-runtime.ts' +import { CdpConnection, createTargetResolver } from './connection.ts' +import { listMetroTargets, type MetroCdpTarget } from './discovery.ts' +import type { NavRoute } from '../nav-state.ts' +import type { RenderTransport } from '../reach/adapters.ts' +import type { RenderAvailability } from '../reach/ports.ts' +import { recordRuntimeCount, recordRuntimeTiming } from '../runtime-telemetry.ts' + +/** How long a structured agent command may take before it is abandoned. The sweep's own number. */ +const COMMAND_TIMEOUT_MS = 5_000 + +/** + * A handshake failure in words safe to show and to log. + * + * It is carried on `RenderAvailability` into a rung's skip note, a replay's error line and the + * local run log, so it is scrubbed the way every other product string here is: absolute paths are + * replaced (an error from a spawned tool can carry the project's location), and the message is + * bounded so a stack-shaped `Error.message` cannot turn one skip note into a page. Nothing here + * reaches telemetry — the `app_flow.runtime.connect` timing carries the `outcome` attribute and + * nothing more. + */ +export function describeTransportFailure(error: unknown): string { + const raw = (error instanceof Error ? error.message : String(error)).trim() + const scrubbed = raw + .replace(/(?:\/[\w.@~-]+){2,}/g, '') + .replace(/\s+/g, ' ') + .trim() + if (!scrubbed) return 'The runtime handshake failed without a message.' + return scrubbed.length > 200 ? `${scrubbed.slice(0, 197)}...` : scrubbed +} + +export interface AppRuntimeTransportOptions { + /** The dev server this device was opened against; its target list is what we search. */ + metroPort: number + /** Narrows the target list to this device, so a second booted simulator cannot be adopted. */ + deviceId: string + deviceName?: string | null + /** The parsed app name, used to disambiguate when the device offers several targets. */ + appName: string | null + /** Distinguishes this connection's links in the debugger; `fast-` for a sweep. */ + sessionId: string + signal?: AbortSignal + /** Prefix for this transport's console lines — the caller's own `[app-flow…]` tag. */ + logLabel?: string + /** + * Told once, on the first `ready()` that resolves either way. The sweep uses it to set + * `ParamStore.navState`, so one failed handshake is not retried for every later node. + */ + onAvailability?: (availability: 'ready' | 'unavailable') => void +} + +/** + * The transport plus the connection underneath it. + * + * Callers that only navigate use `transport`. The sweep also issues commands of its own (`links`) + * and hands the raw connection to the UI-state session, which is what `connection`, `state` and + * `invoke` are for — they delegate, so there is still exactly one connection per handle. + */ +export interface AppRuntimeTransport { + readonly transport: RenderTransport + /** The live connection, or null before the first successful `ready()` and after `close()`. */ + readonly connection: CdpConnection | null + /** `'idle'` until a connection exists; otherwise the connection's own state. */ + readonly state: CdpConnection['state'] | 'idle' + invoke(command: AgentCommand, timeoutMs?: number): Promise + close(): Promise +} + +export function createAppRuntimeTransport( + options: AppRuntimeTransportOptions +): AppRuntimeTransport { + const label = options.logLabel ?? '[app-flow]' + let connection: CdpConnection | null = null + let target: MetroCdpTarget | null = null + let unavailable = false + /** + * Why the handshake failed, kept beside the latch so every later caller is told the same thing + * the first one was. Two failures are otherwise indistinguishable here — no Metro runtime to + * attach to, and a runtime that refused the agent — and a sweep that skipped all nine of its + * nodes never said which. See `RenderAvailability`. + */ + let unavailableReason = '' + + const timing = (name: string, started: number, attributes: Record = {}): void => { + recordRuntimeTiming(name, performance.now() - started, attributes) + } + + const transport: RenderTransport = { + async ready(): Promise { + if (connection?.state === 'ready') return { ok: true } + if (unavailable) return { ok: false, reason: unavailableReason } + const started = performance.now() + try { + const targets = await listMetroTargets(options.metroPort, { signal: options.signal }) + const normalize = (value: string | null | undefined): string => + (value ?? '').toLowerCase().replace(/[^a-z\d]/g, '') + const app = normalize(options.appName) + const candidates = targets.filter((entry) => entry.supportsMultipleDebuggers) + const deviceCandidates = candidates.filter( + (entry) => + entry.deviceId === options.deviceId || + (!!entry.deviceName && + normalize(entry.deviceName) === normalize(options.deviceName ?? null)) + ) + target = + deviceCandidates.find( + (entry) => !app || normalize(entry.title) === app || normalize(entry.appId) === app + ) ?? (deviceCandidates.length === 1 ? deviceCandidates[0]! : null) + if (!target) throw new Error('No unambiguous Metro runtime supports Fast capture.') + connection = new CdpConnection({ + resolveTarget: createTargetResolver(options.metroPort, target), + sessionId: options.sessionId, + log: (message) => console.log(`${label} fast runtime — ${message}`), + onStateChange: (state) => { + recordRuntimeCount('app_flow.runtime.state', { state }) + } + }) + await connection.connect() + options.onAvailability?.('ready') + return { ok: true } + } catch (error) { + unavailable = true + unavailableReason = describeTransportFailure(error) + options.onAvailability?.('unavailable') + console.log(`${label} fast runtime unavailable — ${unavailableReason}`) + return { ok: false, reason: unavailableReason } + } finally { + timing('app_flow.runtime.connect', started, { + outcome: connection?.state === 'ready' ? 'ready' : 'unavailable' + }) + } + }, + async navigate(route, params, expoPath, candidates): Promise { + const started = performance.now() + try { + return await connection!.invoke( + { type: 'navigate', route, params, expoPath, candidates }, + COMMAND_TIMEOUT_MS + ) + } finally { + timing('app_flow.runtime.command', started, { operation: 'navigate' }) + } + }, + async readiness(route, params): Promise { + const started = performance.now() + try { + return await connection!.invoke( + { type: 'readiness', route, expectedParams: params }, + COMMAND_TIMEOUT_MS + ) + } finally { + timing('app_flow.runtime.command', started, { operation: 'readiness' }) + } + }, + // Same `app_flow.runtime.command` wrapper, name, unit and attribute shape as its two siblings + // above, deliberately: the whole point of reading nav-state here is that it replaces a CLI + // subprocess, and the only way that saving stays legible across releases is if the fast read + // is billed to the same meter under a new `operation`. + async navState(): Promise { + const started = performance.now() + try { + const reply = await connection!.invoke( + { type: 'nav-state' }, + COMMAND_TIMEOUT_MS + ) + return reply?.routes ?? null + } finally { + timing('app_flow.runtime.command', started, { operation: 'nav-state' }) + } + }, + async close(): Promise { + await connection?.close() + connection = null + } + } + + return { + transport, + get connection() { + return connection + }, + get state() { + return connection?.state ?? 'idle' + }, + /** + * A structured command the caller composes itself (the sweep's `links` read), billed to the + * same `app_flow.runtime.command` meter as the three the transport issues, under the + * command's own `type` as `operation`. The raw `connection` stays unmetered: the UI-state + * session drives it in bursts, and one span per burst command is the per-frame noise the + * telemetry rules rule out. + */ + async invoke(command: AgentCommand, timeoutMs: number = COMMAND_TIMEOUT_MS): Promise { + const started = performance.now() + try { + return await connection!.invoke(command, timeoutMs) + } finally { + timing('app_flow.runtime.command', started, { operation: command.type }) + } + }, + close(): Promise { + return transport.close() + } + } +} diff --git a/src/app-flow/cdp/connection.ts b/src/app-flow/cdp/connection.ts new file mode 100644 index 00000000..b9ced755 --- /dev/null +++ b/src/app-flow/cdp/connection.ts @@ -0,0 +1,816 @@ +/** + * A persistent connection to the app under test's JS runtime, through Metro's debugger socket. + * + * This replaces one `agent-device cdp runtime eval` process per evaluation (~0.5s each, measured) + * whose returned string **truncates at exactly 200 characters**, and the chunking protocol + * `nav-state.ts` built to live with that. Here a command's reply never travels as an evaluation + * result at all: the app calls a per-connection binding installed with `Runtime.addBinding`, and the + * payload arrives as a `Runtime.bindingCalled` event with no length limit short of the socket's + * `maxPayload`. The design follows `codex-mobile-dev-plugin/src/server/app-flow/connection.ts`; this + * file is that module minus its presentation/portal orchestration, plus the reconnect it leaves to + * its callers. + * + * ## Two layers, and where reconnect lives + * + * - `CdpLink` is **one socket and one installed agent**. It never recovers: when anything goes wrong + * (socket closed, the app reloaded, the agent vanished, three heartbeats missed) it fails every + * pending call with a typed `CdpConnectionLostError` and reports itself lost, once. + * - `CdpConnection` owns a link and **replaces it** when it is lost. + * + * The reference fails the connection on `Runtime.executionContextsCleared` and leaves the retry to + * a loop three layers up (`runs.ts`, backoff `min(500 * 2^min(attempt-1, 4), 5000)`). That works for + * them because a reload ends a capture run. Our sweep **relaunches the app routinely** and has to + * carry on, and every consumer of this transport would otherwise need its own copy of that loop. So + * it lives here, in the layer that already knows the facts a retry needs: + * + * - **A relaunch changes the debugger URL.** Metro reassigns target ids across app restarts, so + * reconnecting to the old URL cannot work; each attempt calls `resolveTarget()` again, which is + * where `reidentifyTarget` (discovery.ts) matches the app by identity rather than by id. + * - **The agent is gone and must be re-installed**, which is the link's own handshake. + * - **The lease means the old agent expires by itself**, so there is nothing to clean up on the old + * socket: a reconnect is only ever "start again". + * + * What recovery deliberately does **not** do is retry the command that was in flight. After a + * reload we cannot know whether it ran, and today's three commands are reads but the next ones + * will not be. That command rejects with `CdpConnectionLostError`; the caller, which knows whether + * its command is idempotent, decides. A command issued *while* recovery is running waits for it + * (bounded by its own timeout) instead of failing, because that is the "relaunch mid-sweep" case. + * + * `reconnect()` is also public. After `maxReconnectAttempts` failed attempts the connection stops + * trying and reports `failed`; the caller, which knows Metro is back or the app was started again, + * re-arms it explicitly rather than having a background loop run forever against a dead Metro. + * + * ## Correlation: one counter, two kinds of id + * + * A single sequence feeds both kinds of request so ids never collide. **Positive ids** are plain CDP + * calls (`Runtime.enable`, `Runtime.addBinding`, the install evaluate) and their result is the + * message with that `id`. **Negative ids** are app commands. A command is itself a `Runtime.evaluate` + * sent under its negative id, but the evaluate's own result is empty (the wrapper returns nothing); + * the answer arrives later as `Runtime.bindingCalled` carrying the same id inside its JSON payload. + * So `message.id > 0` resolves a CDP call, a binding event resolves a command, and a CDP-level + * `error` or `result.exceptionDetails` on *either* kind rejects by `message.id`. That last rule is + * what lets a Hermes compile failure of the command wrapper land on the right promise instead of + * stranding it until its timeout. + * + * ## Surrogates (mandatory, not an optimisation) + * + * The reference documents a real bug: the debugger path re-encodes a raw non-BMP character (an + * emoji) into two separate surrogate code units that Hermes cannot compile. So every evaluated + * expression has every surrogate rewritten as a `\uXXXX` escape. That is only valid where the + * character sits inside a string, regex, template or comment, which is the only place source + * carries user text. Command values additionally go through `toWellFormed()` as they are + * serialised, so a lone surrogate (which JSON would carry as an escape the app would then rebuild + * into an unpaired unit) becomes U+FFFD at the boundary instead of travelling at all. + * + * ## Expiry in the app's own clock + * + * A command that waited in a socket buffer behind a busy JS thread can run long after the host gave + * up on it, which for a navigation is the worst outcome (it happens, late, with nobody waiting). The + * install handshake therefore returns the app's clock reading, the host computes + * `appClock - installStarted`, and every command carries an absolute deadline in the **app's** + * clock: the app drops it with `commandExpired` rather than running it. The offset is taken at the + * *start* of the install round trip, so transit time only ever makes the deadline later than the + * host's, never earlier: a fresh command cannot be expired by a slow handshake. Monotonic clocks on + * both sides (`performance.now`) mean wall-clock skew cannot matter; the app falls back to + * `Date.now` only when it has no `performance`, and the offset is then simply relative to that. + * + * ## Things that were changed from the reference, and why + * + * - **Teardown waits for its three trailing sends to be written** (bounded) before `terminate()`. + * The reference sends and terminates in the same tick; `terminate()` destroys the socket and can + * discard anything still buffered, which would leave the binding installed. + * - **A malformed binding payload is dropped**, not fatal. The reference treats any parse failure + * as a dead connection; a frame of the wrong shape is a protocol surprise, not evidence the + * socket is gone, and the command it belonged to still has its own timeout. A malformed *frame* + * (not JSON at all) is still fatal, since the stream itself can no longer be trusted. + * - **A `commandExpired` reply counts as a reply** for heartbeat purposes: the app answered, so it + * is alive and the link works. + * - **`restore` carries no deadline.** The teardown command must not expire in a slow app. + * + * ## Limits worth knowing + * + * `maxPayload` is 2MB; a larger reply makes `ws` close the socket (1009), which surfaces here as a + * lost link, not as a truncated result. The handshake and install timeouts below are not measured + * against a real Hermes compile of the agent; they are the reference's 2s, widened for the install. + */ +import { randomUUID } from 'node:crypto' +import type { ClientOptions } from 'ws' +import { WebSocket } from 'ws' +import { + AGENT_LEASE_MS, + DEFAULT_AGENT_LIMITS, + type AgentCommand, + type AgentLimits, + type HeartbeatReply +} from './agent-runtime.ts' +import { listMetroTargets, reidentifyTarget, type MetroCdpTarget } from './discovery.ts' +import { agentSource } from './runtime-source.ts' + +/** Origin of the failure, so a caller can branch without string-matching messages. */ +export type CdpErrorKind = + 'timeout' | 'command-expired' | 'runtime-unavailable' | 'evaluation-failed' | 'connection-lost' + +export class CdpError extends Error { + readonly kind: CdpErrorKind + constructor(kind: CdpErrorKind, message: string) { + super(message) + this.name = new.target.name + this.kind = kind + } +} + +/** The host gave up waiting. The command may still run in the app unless its deadline expires it. */ +export class CdpTimeoutError extends CdpError { + readonly operation: string + readonly timeoutMs: number + constructor(operation: string, timeoutMs: number) { + super('timeout', `${operation} did not complete within ${timeoutMs}ms`) + this.operation = operation + this.timeoutMs = timeoutMs + } +} + +/** The app dropped the command because its in-app deadline had already passed. It did not run. */ +export class CommandExpiredError extends CdpError { + readonly operation: string + constructor(operation: string) { + super('command-expired', `${operation} reached the app after its deadline and was not run`) + this.operation = operation + } +} + +/** No agent is installed at the key: the app reloaded, or the lease restored and removed it. */ +export class RuntimeUnavailableError extends CdpError { + readonly operation: string + constructor(operation: string) { + super('runtime-unavailable', `${operation}: the in-app agent is not installed`) + this.operation = operation + } +} + +/** The debugger rejected an evaluation (typically a compile error) or the app threw in it. */ +export class CdpEvaluationError extends CdpError { + readonly operation: string + readonly detail?: string + constructor(operation: string, detail?: string) { + super( + 'evaluation-failed', + `${operation} was rejected by the runtime${detail ? `: ${detail}` : ''}` + ) + this.operation = operation + this.detail = detail + } +} + +export type ConnectionLostReason = + | 'socket' + | 'reload' + | 'heartbeat' + | 'runtime-unavailable' + | 'closed' + | 'reconnecting' + | 'exhausted' + +/** The socket, the app's JS context, or the agent went away. Nothing about the call is known. */ +export class CdpConnectionLostError extends CdpError { + readonly reason: ConnectionLostReason + constructor(reason: ConnectionLostReason) { + super('connection-lost', `the debugger connection was lost (${reason})`) + this.reason = reason + } +} + +/** Handshake: the reference's 2s per step. The install also compiles the agent, so it gets more. */ +const HANDSHAKE_TIMEOUT_MS = 2_000 +const INSTALL_TIMEOUT_MS = 5_000 +const DEFAULT_COMMAND_TIMEOUT_MS = 1_500 +const HEARTBEAT_INTERVAL_MS = 2_000 +const HEARTBEAT_COMMAND_TIMEOUT_MS = 2_500 +/** A reply this recent already proves the app is alive; do not queue a heartbeat behind real work. */ +const HEARTBEAT_RECENT_REPLY_MS = 1_500 +const HEARTBEAT_FAILURE_LIMIT = 3 +const RESTORE_TIMEOUT_MS = 5_000 +/** How long teardown waits for its trailing sends to reach the socket. */ +const TEARDOWN_FLUSH_MS = 500 +const SOCKET_HANDSHAKE_TIMEOUT_MS = 3_000 +const SOCKET_MAX_PAYLOAD_BYTES = 2 * 1024 * 1024 +const KEY_PREFIX = '__aiHarnessCdp_' + +/** + * `min(500 * 2^min(attempt-1, 4), 5000)` -- the reference's reconnect schedule, kept so the two + * projects behave the same against the same Metro: 500, 1000, 2000, 4000, then 5000 forever. + * `attempt` is 1-based. + */ +export function reconnectBackoffMs(attempt: number): number { + return Math.min(500 * 2 ** Math.min(Math.max(attempt, 1) - 1, 4), 5_000) +} + +/** + * Rewrites every UTF-16 surrogate as a `\uXXXX` escape. See "Surrogates" in the file doc. Exported + * for the tests, which assert on the wire bytes rather than on this function in isolation. + */ +export function escapeSurrogates(source: string): string { + return source.replace( + /[\ud800-\udfff]/g, + (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, '0')}` + ) +} + +/** `JSON.stringify` replacer: every string leaves the host well formed. */ +const wellFormed = (_key: string, value: unknown): unknown => + typeof value === 'string' ? escapeSurrogates(value) : value + +/** + * The expression that reads the app's clock, spelled out in ES5 because the command wrapper and the + * install handshake are compiled by the same debugger evaluator as the agent (see the syntax + * constraint in `agent-runtime.ts`). Both sides of the offset must use the same expression. + */ +const APP_CLOCK_EXPR = + "(typeof performance!=='undefined'&&performance&&typeof performance.now==='function'?performance.now():Date.now())" + +/** The slice of the `ws` socket the link uses, so tests can supply a fake. */ +export interface CdpSocket { + readyState: number + send(data: string, callback?: (error?: Error) => void): void + terminate(): void + on(event: 'message', listener: (data: { toString(): string }) => void): unknown + on(event: 'error', listener: (error: Error) => void): unknown + on(event: 'close', listener: () => void): unknown + once(event: 'open', listener: () => void): unknown + once(event: 'error', listener: (error: Error) => void): unknown + once(event: 'close', listener: () => void): unknown +} + +export type CdpSocketFactory = (url: string, options: ClientOptions) => CdpSocket + +const defaultSocketFactory: CdpSocketFactory = (url, options) => + new WebSocket(url, options) as unknown as CdpSocket + +const OPEN = 1 + +interface Pending { + resolve(value: unknown): void + reject(error: Error): void + timer: NodeJS.Timeout + operation: string +} + +export interface CdpLinkOptions { + /** Shared by every link of one `CdpConnection`, so a re-install restores the agent it replaces. */ + key?: string + leaseMs?: number + limits?: AgentLimits + createSocket?: CdpSocketFactory + heartbeatIntervalMs?: number + heartbeatRecentReplyMs?: number + heartbeatTimeoutMs?: number + now?: () => number + /** Called once if the link dies of its own accord. Not called after `close()`. */ + onLost?: (error: CdpConnectionLostError) => void +} + +/** + * One socket and one installed agent. See "Two layers" in the file doc. + */ +export class CdpLink { + private readonly socket: CdpSocket + private readonly key: string + private readonly binding: string + private readonly leaseMs: number + private readonly limits: AgentLimits + private readonly now: () => number + private readonly heartbeatIntervalMs: number + private readonly heartbeatRecentReplyMs: number + private readonly heartbeatTimeoutMs: number + private readonly onLost?: (error: CdpConnectionLostError) => void + private readonly pending = new Map() + /** ONE counter for both id kinds: positive for CDP calls, negated for app commands. */ + private sequence = 0 + private dead = false + private closing?: Promise + private heartbeat?: NodeJS.Timeout + private heartbeatInFlight = false + private heartbeatFailures = 0 + private lastReply = Number.NEGATIVE_INFINITY + private appClockOffset?: number + /** Resolves once the handshake has installed the agent; rejects if any step fails. */ + readonly ready: Promise + + constructor(url: string, options: CdpLinkOptions = {}) { + this.key = options.key ?? `${KEY_PREFIX}${randomUUID().replaceAll('-', '')}` + // Unguessable per link: the page can enumerate its own globals, and a forged reply could + // otherwise resolve a pending command. Prefixed by the key so the next install can sweep it. + this.binding = `${this.key}_reply_${randomUUID().replaceAll('-', '')}` + this.leaseMs = options.leaseMs ?? AGENT_LEASE_MS + this.limits = options.limits ?? DEFAULT_AGENT_LIMITS + this.now = options.now ?? (() => performance.now()) + this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS + this.heartbeatRecentReplyMs = options.heartbeatRecentReplyMs ?? HEARTBEAT_RECENT_REPLY_MS + this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? HEARTBEAT_COMMAND_TIMEOUT_MS + this.onLost = options.onLost + + // Metro's own origin, as its inspector proxy expects from a debugger front end. Node's global + // WebSocket cannot set it, nor `maxPayload`, which is why this uses `ws`. + const origin = new URL(url) + origin.protocol = 'http:' + this.socket = (options.createSocket ?? defaultSocketFactory)(url, { + origin: origin.origin, + handshakeTimeout: SOCKET_HANDSHAKE_TIMEOUT_MS, + maxPayload: SOCKET_MAX_PAYLOAD_BYTES, + followRedirects: false + }) + + const opened = new Promise((resolve, reject) => { + this.socket.once('open', () => resolve()) + this.socket.once('error', () => reject(new CdpConnectionLostError('socket'))) + this.socket.once('close', () => reject(new CdpConnectionLostError('socket'))) + }) + this.socket.on('message', (data) => this.onMessage(data.toString())) + this.socket.on('error', () => this.fail(new CdpConnectionLostError('socket'))) + this.socket.on('close', () => this.fail(new CdpConnectionLostError('socket'))) + this.ready = opened.then(() => this.install()) + // The caller observes `ready`; an unobserved rejection must not crash the process. + this.ready.catch(() => undefined) + } + + private async install(): Promise { + await this.send('Runtime.enable', {}, HANDSHAKE_TIMEOUT_MS, 'runtime-enable') + await this.send( + 'Runtime.addBinding', + { name: this.binding }, + HANDSHAKE_TIMEOUT_MS, + 'add-binding' + ) + // The agent's constructor path is the only thing here that can throw inside the app, and it + // does so on purpose (no timers, so no watchdog). That surfaces as `exceptionDetails`. + const keyPrefix = `${this.key}_reply_` + const expression = + `(function(){var __name=function(value){return value};` + + // A previous connection's reply global is dead weight and, worse, still a callable. + `var names=Object.keys(globalThis);for(var i=0;i this.beat(), this.heartbeatIntervalMs) + this.heartbeat.unref() + } + + /** + * Skipped when one is already in flight, or when a real reply landed recently: every command + * renews the app's lease, so a recent reply is proof of life and a heartbeat queued behind real + * work would only add a traversal's worth of latency to it. Three failures in a row with no reply + * in between closes the link; a reply that arrives while a heartbeat is outstanding clears the + * count, which is why failure is judged against when the heartbeat was *sent*. + */ + private beat(): void { + if (this.heartbeatInFlight || this.now() - this.lastReply < this.heartbeatRecentReplyMs) return + this.heartbeatInFlight = true + const sentAt = this.now() + this.invoke({ type: 'heartbeat' }, this.heartbeatTimeoutMs) + .catch(() => { + if (this.lastReply < sentAt && ++this.heartbeatFailures >= HEARTBEAT_FAILURE_LIMIT) { + this.fail(new CdpConnectionLostError('heartbeat')) + } + }) + .finally(() => { + this.heartbeatInFlight = false + }) + } + + private onMessage(text: string): void { + let message: { + id?: number + method?: string + params?: { name?: string; payload?: string } + result?: { exceptionDetails?: { text?: string; exception?: { description?: string } } } + error?: { message?: string } + } + try { + message = JSON.parse(text) + } catch { + this.fail(new CdpConnectionLostError('socket')) + return + } + if ( + message.method === 'Runtime.executionContextsCleared' || + message.method === 'Runtime.executionContextDestroyed' + ) { + // The app reloaded: the agent, the binding and every pending reply are gone with it. + this.fail(new CdpConnectionLostError('reload')) + return + } + if (message.method === 'Runtime.bindingCalled') { + if (message.params?.name !== this.binding) return + let value: { id?: unknown; result?: unknown } + try { + value = JSON.parse(message.params.payload ?? '') + } catch { + return + } + if (typeof value.id === 'number') this.settleReply(value.id, value.result) + return + } + if (typeof message.id !== 'number' || message.id === 0) return + const details = message.result?.exceptionDetails + if (message.error || details) { + const detail = details?.exception?.description ?? details?.text ?? message.error?.message + const operation = this.pending.get(message.id)?.operation ?? 'other' + this.finish(message.id, undefined, new CdpEvaluationError(operation, detail)) + return + } + // A negative id's own evaluate result is empty by design; the answer comes as a binding event. + if (message.id > 0) this.finish(message.id, message.result) + } + + private settleReply(id: number, result: unknown): void { + const pending = this.pending.get(id) + if (!pending) return + const reply = result as { commandExpired?: boolean; runtimeUnavailable?: boolean } | null + if (reply && reply.runtimeUnavailable) { + this.finish(id, undefined, new RuntimeUnavailableError(pending.operation)) + // No agent means nothing here can work again; hand the decision to whoever can reinstall. + this.fail(new CdpConnectionLostError('runtime-unavailable')) + return + } + this.lastReply = this.now() + this.heartbeatFailures = 0 + if (reply && reply.commandExpired) { + this.finish(id, undefined, new CommandExpiredError(pending.operation)) + return + } + this.finish(id, result) + } + + private finish(id: number, value?: unknown, error?: Error): void { + const pending = this.pending.get(id) + if (!pending) return + this.pending.delete(id) + clearTimeout(pending.timer) + if (error) pending.reject(error) + else pending.resolve(value) + } + + /** Marks the link dead, fails everything pending, and tells the owner, exactly once. */ + private fail(error: CdpConnectionLostError): void { + if (this.dead) return + this.dead = true + clearInterval(this.heartbeat) + for (const id of [...this.pending.keys()]) this.finish(id, undefined, error) + if (!this.closing) { + this.socket.terminate() + this.onLost?.(error) + } + } + + private send( + method: string, + params: unknown, + timeoutMs: number, + operation: string, + id: number = ++this.sequence + ): Promise { + if (this.dead || this.socket.readyState !== OPEN) { + return Promise.reject(new CdpConnectionLostError(this.dead ? 'closed' : 'socket')) + } + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => this.finish(id, undefined, new CdpTimeoutError(operation, timeoutMs)), + timeoutMs + ) + this.pending.set(id, { resolve, reject, timer, operation }) + const safe = + method === 'Runtime.evaluate' && + typeof (params as { expression?: unknown }).expression === 'string' + ? { + ...(params as object), + expression: escapeSurrogates((params as { expression: string }).expression) + } + : params + this.socket.send(JSON.stringify({ id, method, params: safe }), (error) => { + if (error) this.finish(id, undefined, new CdpConnectionLostError('socket')) + }) + }) + } + + /** + * Runs one agent command. Resolves with the agent's reply, or rejects with a typed `CdpError`. + * `timeoutMs` bounds both the host's wait and, through the embedded deadline, the app's willingness + * to start the command at all. + */ + async invoke( + command: AgentCommand, + timeoutMs: number = DEFAULT_COMMAND_TIMEOUT_MS + ): Promise { + await this.ready + const id = -++this.sequence + // `restore` must run even in a slow app, so it is the one command with no deadline. + const deadline = + this.appClockOffset === undefined || command.type === 'restore' + ? undefined + : this.now() + timeoutMs + this.appClockOffset + const reply = + `var reply=function(result){var send=globalThis[${JSON.stringify(this.binding)}];` + + `if(typeof send==='function')send(JSON.stringify({id:${id},result:result===undefined?null:result}))};` + const expression = + `(function(){var runtime=globalThis[${JSON.stringify(this.key)}];${reply}` + + (deadline === undefined + ? '' + : `if(${APP_CLOCK_EXPR}>${deadline}){reply({commandExpired:true});return}`) + + `if(!runtime||typeof runtime.invoke!=='function'){reply({runtimeUnavailable:true});return}` + + `runtime.invoke(${JSON.stringify(JSON.stringify(command, wellFormed))},reply)})()` + const result = await this.send( + 'Runtime.evaluate', + { expression, silent: true, returnByValue: true, objectGroup: this.key }, + timeoutMs, + command.type, + id + ) + if (command.type === 'heartbeat' && (result as HeartbeatReply | null)?.alive !== true) { + throw new CdpEvaluationError('heartbeat', 'invalid response') + } + return result as T + } + + /** + * Idempotent teardown, in this order: stop the heartbeat; ask the agent to restore itself + * (best effort, bounded; the lease restores it anyway if this fails); remove the binding, delete + * its global, release the object group; fail whatever is still pending; terminate the socket. + * Every call after the first returns the first's promise. + */ + close(options: { restore?: boolean } = {}): Promise { + return (this.closing ??= this.dispose(options.restore !== false)) + } + + private async dispose(restore: boolean): Promise { + clearInterval(this.heartbeat) + if (restore && !this.dead && this.socket.readyState === OPEN) { + let timer: NodeJS.Timeout | undefined + try { + await Promise.race([ + this.ready.then(() => this.invoke({ type: 'restore' }, RESTORE_TIMEOUT_MS)), + new Promise((resolve) => { + timer = setTimeout(resolve, RESTORE_TIMEOUT_MS + 500) + }) + ]) + } catch { + // The in-app watchdog restores after the host goes quiet; teardown must not hang on it. + } finally { + clearTimeout(timer) + } + } + if (this.socket.readyState === OPEN) { + const writes = [ + { method: 'Runtime.removeBinding', params: { name: this.binding } }, + { + method: 'Runtime.evaluate', + params: { + expression: `delete globalThis[${JSON.stringify(this.binding)}]`, + silent: true, + returnByValue: true + } + }, + { method: 'Runtime.releaseObjectGroup', params: { objectGroup: this.key } } + ].map( + (call) => + new Promise((resolve) => { + try { + this.socket.send(JSON.stringify({ id: ++this.sequence, ...call }), () => resolve()) + } catch { + resolve() + } + }) + ) + let flush: NodeJS.Timeout | undefined + await Promise.race([ + Promise.all(writes), + new Promise((resolve) => { + flush = setTimeout(resolve, TEARDOWN_FLUSH_MS) + }) + ]) + clearTimeout(flush) + } + this.fail(new CdpConnectionLostError('closed')) + this.socket.terminate() + } +} + +export type ConnectionState = 'idle' | 'connecting' | 'ready' | 'reconnecting' | 'failed' | 'closed' + +export interface CdpConnectionOptions { + /** + * Yields the debugger URL to connect to, **freshly each call**: a relaunched app is a new Metro + * target with a new URL. See `createTargetResolver` for the discovery-backed implementation. + */ + resolveTarget: () => Promise<{ webSocketDebuggerUrl: string }> + /** Stable across this connection's links; defaults to a random one. */ + sessionId?: string + leaseMs?: number + limits?: AgentLimits + createSocket?: CdpSocketFactory + heartbeatIntervalMs?: number + heartbeatRecentReplyMs?: number + heartbeatTimeoutMs?: number + /** Failed reconnect attempts tolerated before the state becomes `failed`. Default 10. */ + maxReconnectAttempts?: number + /** Injected so tests do not wait out the real backoff. */ + sleep?: (ms: number) => Promise + backoffMs?: (attempt: number) => number + log?: (message: string) => void + onStateChange?: (state: ConnectionState) => void +} + +/** + * Builds a `resolveTarget` that follows an app across Metro restarts and relaunches: it lists the + * targets on `metroPort` and re-identifies the one first attached, by app plus device rather than + * by id (`reidentifyTarget`). Throws when no single target matches, which the connection treats as + * a failed attempt and retries: right after a relaunch the new target is often not registered yet. + */ +export function createTargetResolver( + metroPort: number, + initial: MetroCdpTarget, + options: { list?: typeof listMetroTargets } = {} +): () => Promise { + const list = options.list ?? listMetroTargets + let current = initial + return async () => { + const targets = await list(metroPort) + const match = reidentifyTarget(targets, current.id, current) + if (!match) throw new Error(`no Metro target matches the app on port ${metroPort}`) + current = match + return match + } +} + +const defaultSleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * The connection the rest of the app uses. See "Two layers" in the file doc for what it recovers + * from, what it does not retry, and why. + */ +export class CdpConnection { + private link?: CdpLink + private recovery?: Promise + private currentState: ConnectionState = 'idle' + private readonly key: string + private readonly sleep: (ms: number) => Promise + private readonly backoffMs: (attempt: number) => number + private readonly maxAttempts: number + private readonly options: CdpConnectionOptions + + constructor(options: CdpConnectionOptions) { + this.options = options + this.key = `${KEY_PREFIX}${(options.sessionId ?? randomUUID()).replaceAll('-', '')}` + this.sleep = options.sleep ?? defaultSleep + this.backoffMs = options.backoffMs ?? reconnectBackoffMs + this.maxAttempts = options.maxReconnectAttempts ?? 10 + } + + get state(): ConnectionState { + return this.currentState + } + + private setState(state: ConnectionState): void { + if (this.currentState === state) return + this.currentState = state + this.options.onStateChange?.(state) + } + + /** The first connection. One attempt, and it throws: whether to carry on without us is the caller's call. */ + async connect(): Promise { + if (this.currentState !== 'idle') + throw new Error(`cannot connect from state ${this.currentState}`) + this.setState('connecting') + try { + await this.openLink() + this.setState('ready') + } catch (error) { + // `close()` may have landed while `openLink` was in flight; 'closed' is terminal, so a + // failed first attempt must not reopen the connection for another `connect()`. + if ((this.currentState as ConnectionState) !== 'closed') this.setState('idle') + throw error + } + } + + private async openLink(): Promise { + const target = await this.options.resolveTarget() + const link = new CdpLink(target.webSocketDebuggerUrl, { + key: this.key, + leaseMs: this.options.leaseMs, + limits: this.options.limits, + createSocket: this.options.createSocket, + heartbeatIntervalMs: this.options.heartbeatIntervalMs, + heartbeatRecentReplyMs: this.options.heartbeatRecentReplyMs, + heartbeatTimeoutMs: this.options.heartbeatTimeoutMs, + onLost: (error) => { + // A link that dies before its handshake completes is reported by `ready`, not here. + if (this.link === link && this.currentState === 'ready') this.recover(error) + } + }) + try { + await link.ready + } catch (error) { + await link.close({ restore: false }).catch(() => undefined) + throw error + } + if (this.currentState === 'closed') { + await link.close() + throw new CdpConnectionLostError('closed') + } + this.link = link + } + + private recover(cause: CdpConnectionLostError): void { + if (this.recovery || this.currentState === 'closed') return + this.options.log?.(`link lost (${cause.reason}); reconnecting`) + this.recovery = this.runRecovery().finally(() => { + this.recovery = undefined + }) + // Callers observe recovery through `invoke`/`reconnect`; this keeps it from being unhandled. + this.recovery.catch(() => undefined) + } + + private async runRecovery(): Promise { + this.setState('reconnecting') + for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { + await this.sleep(this.backoffMs(attempt)) + if (this.currentState === 'closed') throw new CdpConnectionLostError('closed') + try { + await this.openLink() + this.setState('ready') + return + } catch (error) { + this.options.log?.( + `reconnect attempt ${attempt}/${this.maxAttempts} failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + this.setState('failed') + throw new CdpConnectionLostError('exhausted') + } + + /** + * Replaces the link now, from any state but `closed`. Used after `failed`, or by a caller that + * knows it just relaunched the app and does not want to wait for the heartbeat to notice. + * Resolves when a fresh link is installed; rejects with `CdpConnectionLostError('exhausted')`. + */ + async reconnect(): Promise { + if (this.currentState === 'closed') throw new CdpConnectionLostError('closed') + if (this.recovery) return this.recovery + const old = this.link + this.link = undefined + if (old) await old.close({ restore: false }).catch(() => undefined) + this.recover(new CdpConnectionLostError('reconnecting')) + return this.recovery + } + + /** + * Runs one agent command. While a reconnect is in progress it waits for it, up to `timeoutMs` + * from now, rather than failing; a command already in flight when the link died has been rejected + * and is never replayed. See the file doc. + */ + async invoke( + command: AgentCommand, + timeoutMs: number = DEFAULT_COMMAND_TIMEOUT_MS + ): Promise { + if (this.currentState === 'closed') throw new CdpConnectionLostError('closed') + if (this.currentState === 'failed') throw new CdpConnectionLostError('exhausted') + if (this.recovery) { + let timer: NodeJS.Timeout | undefined + try { + await Promise.race([ + this.recovery, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new CdpConnectionLostError('reconnecting')), timeoutMs) + }) + ]) + } finally { + clearTimeout(timer) + } + } + if (!this.link) throw new CdpConnectionLostError('socket') + return this.link.invoke(command, timeoutMs) + } + + /** Tears the link down (restoring the app) and stops any recovery. Idempotent. */ + async close(): Promise { + if (this.currentState === 'closed') return + this.setState('closed') + const link = this.link + this.link = undefined + await link?.close() + } +} diff --git a/src/app-flow/cdp/discovery.ts b/src/app-flow/cdp/discovery.ts new file mode 100644 index 00000000..3703b381 --- /dev/null +++ b/src/app-flow/cdp/discovery.ts @@ -0,0 +1,221 @@ +/** + * Finds the React Native debugger WebSocket target for a running Metro server. + * + * Metro serves its inspector targets at `/json/list`. This module does that one HTTP GET and turns + * the answer into typed, validated targets; it opens no socket (the connection lives elsewhere) + * and imports nothing from Electron or the device layer, so it stays loadable under `node --test` + * like the rest of `app-flow/` (see the note at the top of `reach/types.ts`). + * + * The Metro port is taken as a plain number, the way every other consumer already holds it + * (`ProfilingDevice.metroPort`, `selectNavStateTarget`, `selectMetroCdpTarget`), and the origin is + * built the same way they build it: `http://127.0.0.1:`. Nothing new discovers Metro. + * + * **A Metro response is untrusted input.** The body is capped in size and in target count, and every + * `webSocketDebuggerUrl` is checked before it is handed back: `ws:` only, a loopback host, the + * port Metro itself was reached on, and no credentials or fragment. A target failing any of those + * is dropped, not repaired, so a compromised or spoofed listener on that port cannot steer the + * caller's socket at an arbitrary host. The port check also matters in the benign case: an + * inspector may advertise `localhost` where discovery used `127.0.0.1`, which is accepted, but one + * advertising a different port is not the Metro we were asked about. + * + * ### `supportsMultipleDebuggers` + * + * Whether the target reports `reactNative.capabilities.supportsMultipleDebuggers` is returned as + * data and deliberately NOT enforced here. A target without it allows exactly one debugger + * attached at a time, and a second attach either fails or evicts the first. That matters to us + * because a sweep wants to hold one socket for its whole duration while the CPU profiler + * separately attaches `agent-device cdp` to the same inspector + * (`profiling/profilers/cpu-devtool.ts`, `selectMetroCdpTarget`). On a single-debugger target those + * two collide, and `shell.ts` already SIGKILLs a hung eval for exactly that contention. Refusing + * such targets outright would make the feature unusable on older runtimes that otherwise work fine + * for a sweep with no profiler attached, so the decision is the caller's: it knows whether a + * profiler is going to run. + */ + +/** How long `/json/list` may take. Metro answers locally in milliseconds; 5s is a hung server. */ +export const DISCOVERY_TIMEOUT_MS = 5_000 +/** Largest `/json/list` body accepted. Real lists are a few hundred bytes per target. */ +export const MAX_DISCOVERY_BODY_BYTES = 1024 * 1024 +/** Most targets parsed from one response. A dev machine has a handful; beyond this is noise. */ +export const MAX_DISCOVERY_TARGETS = 100 +/** Longest string kept from any descriptive field of a target. */ +const MAX_FIELD_LENGTH = 512 + +const LOOPBACK_HOSTS: ReadonlySet = new Set(['127.0.0.1', 'localhost', '[::1]']) + +/** One validated Metro inspector target. */ +export interface MetroCdpTarget { + /** Metro's id for the target. Volatile: Metro reassigns it across restarts. Do not persist it. */ + id: string + title: string + appId?: string + deviceName?: string + /** `reactNative.logicalDeviceId`: stable per device, unlike `id`. */ + deviceId?: string + /** Validated: `ws:`, loopback host, Metro's own port. Safe to hand to a WebSocket client. */ + webSocketDebuggerUrl: string + /** + * True only when the target explicitly reports `supportsMultipleDebuggers`. False means it was + * absent or not `true` (indistinguishable on purpose; both mean "assume one debugger"). See the + * module doc for why this is data rather than a refusal. + */ + supportsMultipleDebuggers: boolean +} + +export interface ListMetroTargetsOptions { + /** Cancels the request; the rejection is the abort reason, not a "cannot reach Metro" error. */ + signal?: AbortSignal + /** Injected so tests never touch the network. Defaults to the global `fetch`. */ + fetchImpl?: typeof fetch + timeoutMs?: number +} + +/** The parts of a target that survive a Metro restart. */ +export interface MetroTargetIdentity { + appId?: string + deviceId?: string + deviceName?: string +} + +const asString = (value: unknown): string | undefined => + typeof value === 'string' ? value.slice(0, MAX_FIELD_LENGTH) : undefined + +/** + * Validates one advertised debugger URL against the Metro port we reached. Returns the normalized + * href, or null when it must be rejected. Relative URLs resolve against the Metro origin, which + * then fails the scheme check, so they are rejected rather than guessed at. + */ +function validateDebuggerUrl(raw: string, metroPort: number): string | null { + let url: URL + try { + url = new URL(raw, `http://127.0.0.1:${metroPort}`) + } catch { + return null + } + if (url.protocol !== 'ws:') return null + if (!LOOPBACK_HOSTS.has(url.hostname)) return null + if (url.username || url.password || url.hash) return null + // `ws:` defaults to port 80, which `URL` reports as the empty string. + if (Number(url.port || 80) !== metroPort) return null + return url.href +} + +/** Reads a response body as text, giving up as soon as it exceeds the cap rather than after. */ +async function readCappedText(response: Response, maxBytes: number): Promise { + const tooLarge = (): Error => new Error('Metro returned too much data for its target list.') + const declared = Number(response.headers.get('content-length')) + if (Number.isFinite(declared) && declared > maxBytes) throw tooLarge() + if (!response.body) { + const text = await response.text() + if (Buffer.byteLength(text) > maxBytes) throw tooLarge() + return text + } + const reader = response.body.getReader() + const decoder = new TextDecoder() + let received = 0 + let text = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + received += value.byteLength + if (received > maxBytes) { + await reader.cancel().catch(() => undefined) + throw tooLarge() + } + text += decoder.decode(value, { stream: true }) + } + return text + decoder.decode() +} + +/** + * Lists the inspector targets of the Metro server on `metroPort`, keeping only those whose + * debugger URL passes `validateDebuggerUrl`. Throws when Metro is unreachable, answers non-2xx, + * sends an oversized or malformed body, or sends something that is not a list; an empty array + * means Metro is up with nothing attached. + */ +export async function listMetroTargets( + metroPort: number, + options: ListMetroTargetsOptions = {} +): Promise { + const { signal, fetchImpl = fetch, timeoutMs = DISCOVERY_TIMEOUT_MS } = options + const origin = `http://127.0.0.1:${metroPort}` + const timeout = AbortSignal.timeout(timeoutMs) + let response: Response + try { + response = await fetchImpl(`${origin}/json/list`, { + // A redirect is a different server answering; following it would defeat the port check. + redirect: 'error', + signal: signal ? AbortSignal.any([timeout, signal]) : timeout + }) + } catch (error) { + if (signal?.aborted) throw error + throw new Error(`Cannot reach Metro at ${origin}.`, { cause: error }) + } + if (!response.ok) throw new Error(`Metro returned HTTP ${response.status}.`) + + const body = await readCappedText(response, MAX_DISCOVERY_BODY_BYTES) + let payload: unknown + try { + payload = JSON.parse(body) + } catch (error) { + throw new Error('Metro returned a target list that is not valid JSON.', { cause: error }) + } + if (!Array.isArray(payload)) throw new Error('Metro returned an invalid target list.') + + const targets: MetroCdpTarget[] = [] + for (const item of payload.slice(0, MAX_DISCOVERY_TARGETS)) { + if (typeof item !== 'object' || item === null) continue + const record = item as Record + if (typeof record.id !== 'string' || typeof record.webSocketDebuggerUrl !== 'string') continue + const webSocketDebuggerUrl = validateDebuggerUrl(record.webSocketDebuggerUrl, metroPort) + if (!webSocketDebuggerUrl) continue + const reactNative = (record.reactNative ?? {}) as { + logicalDeviceId?: unknown + capabilities?: { supportsMultipleDebuggers?: unknown } + } + targets.push({ + id: record.id, + title: asString(record.title) ?? record.id, + appId: asString(record.appId), + deviceName: asString(record.deviceName), + deviceId: asString(reactNative.logicalDeviceId), + webSocketDebuggerUrl, + supportsMultipleDebuggers: reactNative.capabilities?.supportsMultipleDebuggers === true + }) + } + return targets +} + +const normalizeDeviceName = (name: string): string => name.toLowerCase().replace(/[^a-z\d]/g, '') + +/** + * Finds the target we were attached to before, after Metro may have restarted and handed out new + * ids. Matching is by app plus device, never by id alone: the same app on two simulators shares an + * `appId`, and a different app on the same simulator shares a device, so both must agree. + * + * Device identity is the logical device id when both sides have one, else the device name compared + * loosely (case and punctuation), since a name is all older runtimes report. When several targets + * still match, the one whose id is unchanged wins, otherwise the answer is `undefined`: picking one + * of two equally plausible targets would silently drive the wrong app. With no previous identity + * there is nothing to re-identify, so only the id is consulted. + */ +export function reidentifyTarget( + targets: readonly T[], + previousId: string, + previous?: MetroTargetIdentity +): T | undefined { + if (!previous) return targets.find((target) => target.id === previousId) + const matches = targets.filter((target) => { + const sameApp = previous.appId + ? target.appId === previous.appId + : target.id === previousId && !target.appId + const sameDevice = + (previous.deviceId !== undefined && target.deviceId === previous.deviceId) || + (previous.deviceName !== undefined && + target.deviceName !== undefined && + normalizeDeviceName(target.deviceName) === normalizeDeviceName(previous.deviceName)) + return sameApp && sameDevice + }) + const exact = matches.find((target) => target.id === previousId) + return exact ?? (matches.length === 1 ? matches[0] : undefined) +} diff --git a/src/app-flow/cdp/runtime-source.ts b/src/app-flow/cdp/runtime-source.ts new file mode 100644 index 00000000..16a119f2 --- /dev/null +++ b/src/app-flow/cdp/runtime-source.ts @@ -0,0 +1,179 @@ +/** + * Assembles the in-app agent's source text from the real functions in `agent-runtime.ts`, checking + * each one on the way out. + * + * ## Why there is no transform here any more + * + * This module briefly ran each function through an esbuild `target: 'es2015'` transform at + * assembly time, mirroring the reference implementation's `scripts/build-flow-runtime.mjs`. That + * could not ship: the server is bundled into a single `dist/server.mjs`, and esbuild's JavaScript + * API refuses to run once it has been bundled into another file, so the packaged plugin threw + * `The esbuild JavaScript API cannot be bundled` on first connect. The reference does not hit this + * because it transforms at *build* time into a `runtime.json` artefact and its server only reads + * that file back. + * + * Rather than ship esbuild as a runtime dependency (a platform-specific native binary) or grow a + * second build artefact that can go stale, the transform is gone. It was buying nothing: every + * function in `AGENT_FUNCTIONS` is already ES2015-or-below, so the pass was a no-op on everything + * except comments, which it stripped. The agent is correspondingly ~10KB larger on the wire and + * still well inside the size tripwire below. If modern syntax is ever genuinely wanted in an agent + * function, the move is to add a build step like the reference's, not to bring esbuild back here. + * + * ## Why per function rather than over the whole concatenation + * + * Keeping the unit of checking equal to the unit of the registry is what keeps the + * `fn.name`-vs-registry-key check meaningful: each chunk must still be one top-level + * `function agentX` declaration, verified below, so a renamed or vanished declaration is caught at + * assembly rather than on first call against a device. + * + * ## What the integrity check can and cannot promise + * + * The check runs over the text that is actually injected. With no transform in front of it, the + * syntax scan is now exactly what it reads as: a style rule the author must keep, not an assertion + * that a lowering pass did its job. A hit means someone wrote a banned construct. + * + * It catches the failures that would otherwise show up only against a live device: + * + * - **A renamed function.** A bundler may rename a top-level declaration to avoid a collision. The + * functions call each other by their declared names, so injecting one under a new name would + * compile and then fail on first call. `fn.name` is compared with its registry key and a mismatch + * throws; the lowered text is separately checked to still declare that same name. + * - **A missing or extra function**, which means the registry and the check disagree about what the + * agent is. + * - **A runaway size.** The source rides in a single `Runtime.evaluate` frame that Hermes has to + * compile on every connect; a limit turns an accidental import of something large into a loud + * failure instead of a slow handshake. + * - **`async`, `await` or `??` in a function body.** Nothing lowers them any more, so these are a + * hard ban rather than an expectation. Optional chaining, spread, `for...of`, `class` and + * template literals stay allowed: the reference's own packaged artefact + * (`dist/app-flow/runtime.json`) ships all five to the same Hermes debugger evaluator, through + * the same `Runtime.evaluate` call with the same parameters, and its packaged validity check + * performs no syntax scan at all. + * + * It cannot prove the source is *correct*, and being textual it cannot tell a comment from code, so + * an in-body comment spelling out a banned token is a false positive (and the fix is to reword the + * comment — note that comments now survive into the injected text, where before the transform + * stripped them). For free references to module-scope names, the test suite compiles and evaluates + * the assembled source in an empty `node:vm` context and calls every agent function there, so + * a reference to something that only exists in this module surfaces as a `ReferenceError` in the + * test run rather than against a device. + */ +import { AGENT_FUNCTIONS } from './agent-runtime.ts' + +/** The number of functions the agent is made of. Changing the registry means changing this. */ +export const EXPECTED_AGENT_FUNCTION_COUNT = 62 +/** + * Upper bound on the assembled source. This is a tripwire for an accidental large import, not a + * byte budget: the agent is meant to grow. Measured after the presentation runtime landed and the + * esbuild pass was removed: the agent is ~85KB, about 33% of this limit, leaving ~170KB of + * headroom. (It was 75,352 bytes while esbuild still stripped comments on the way out, and 34,026 + * before the presentation runtime landed.) The reference's own packaged validity check uses + * 500,000 characters per function for the same "catch an accident" purpose. + */ +export const MAX_AGENT_SOURCE_BYTES = 256 * 1024 + +/** + * Each pattern names a construct that must not appear in an agent function. These are exactly the + * ones the reference lowers for the debugger evaluator; here nothing lowers them, so a match means + * the author wrote something the Hermes debugger evaluator should not be asked to take. + */ +const BANNED_SYNTAX: ReadonlyArray = [ + ['async function', /\basync\b/], + ['await', /\bawait\b/], + ['nullish coalescing', /\?\?/] +] + +export class AgentSourceError extends Error { + constructor(message: string) { + super(message) + this.name = 'AgentSourceError' + } +} + +type AgentFunctionMap = Readonly unknown>> + +/** + * Checks the registry and the text built from it. Exported so the tests can feed it a deliberately + * bad registry and confirm each rule fires, rather than trusting that the passing case passes for + * the right reason. + */ +export function assertAgentSourceIntegrity( + functions: AgentFunctionMap, + source: string, + expectedCount: number = EXPECTED_AGENT_FUNCTION_COUNT +): void { + const names = Object.keys(functions) + if (names.length !== expectedCount) { + throw new AgentSourceError( + `agent has ${names.length} functions, expected ${expectedCount}; update EXPECTED_AGENT_FUNCTION_COUNT with the registry` + ) + } + for (const name of names) { + if (functions[name]!.name !== name) { + throw new AgentSourceError( + `agent function "${name}" was renamed to "${functions[name]!.name}" by the build; injected functions call each other by name` + ) + } + } + if (Buffer.byteLength(source, 'utf8') > MAX_AGENT_SOURCE_BYTES) { + throw new AgentSourceError(`agent source exceeds ${MAX_AGENT_SOURCE_BYTES} bytes`) + } + for (const [label, pattern] of BANNED_SYNTAX) { + const hit = pattern.exec(source) + if (hit) { + throw new AgentSourceError( + `agent source contains ${label}, near "${source.slice(Math.max(0, hit.index - 20), hit.index + 30).replace(/\s+/g, ' ')}"; the Hermes debugger evaluator runs this text as written, so write the ES2015-or-below form instead` + ) + } + } +} + +/** + * Takes one agent function's own source and asserts it is still a top-level + * `function agentX(...)` declaration, which is what lets it survive hoisting into the shared + * closure and keeps the name check meaningful. A bundler that rewrote the declaration into an + * assignment would otherwise only fail against a live device, on first call. + */ +function checkDeclaration(name: string, text: string): string { + const code = text.trim() + if (!new RegExp(`(^|[^\\w$])function\\s+${name}\\s*\\(`).test(code)) { + throw new AgentSourceError( + `agent function "${name}" is not a top-level declaration; injected functions call each other by name` + ) + } + return code +} + +/** + * The source of an expression that evaluates to the install function. Calling the result with + * `(key, leaseMs, limits)` installs the agent; every helper lives in the closure that expression + * creates, so nothing is added to the app's global scope but the one key. + * + * Each function is emitted as a declaration (`fn.toString()` already is one, and `checkDeclaration` + * asserts it stayed one), so they are hoisted and may be listed in any order. + */ +export function buildAgentSource(functions: AgentFunctionMap = AGENT_FUNCTIONS): string { + const declarations = Object.entries(functions) + .map(([name, fn]) => checkDeclaration(name, fn.toString())) + .join('\n') + const source = `(function () {\n${declarations}\nreturn agentInstall;\n})()` + assertAgentSourceIntegrity(functions, source, Object.keys(functions).length) + return source +} + +let cached: string | undefined + +/** + * The agent source for the real registry, built and checked once. Nothing about it varies per + * connection (the key, lease and limits are arguments at the call site), so rebuilding it per + * connect would only repeat the check. Also asserts the registry size against the constant, which + * `buildAgentSource` cannot do for a registry passed in. + */ +export function agentSource(): string { + if (cached === undefined) { + const source = buildAgentSource() + assertAgentSourceIntegrity(AGENT_FUNCTIONS, source) + cached = source + } + return cached +} diff --git a/src/app-flow/cli-args.ts b/src/app-flow/cli-args.ts new file mode 100644 index 00000000..103e07c8 --- /dev/null +++ b/src/app-flow/cli-args.ts @@ -0,0 +1,12 @@ +/** + * Shell quoting for the `agent-device …` lines persisted into `pathToReach`. + * + * Its own module because both `capture.ts` (which builds the boot/install/open/replay lines) and + * `manual/commands.ts` (which builds the press/fill/scroll ones) need it, and neither should import + * the other: the core capture module must not depend on a feature folder, and the manual recorder's + * command builders must stay free of Electron so they can be tested under `node --test`. + */ +export function quoteCliArg(value: string): string { + if (/^[A-Za-z0-9._:+/=@-]+$/.test(value)) return value + return `'${value.replace(/'/g, `'\\''`)}'` +} diff --git a/src/app-flow/coalesced-write.ts b/src/app-flow/coalesced-write.ts new file mode 100644 index 00000000..9bffa648 --- /dev/null +++ b/src/app-flow/coalesced-write.ts @@ -0,0 +1,88 @@ +/** + * A write that may be delayed but must never be lost — the sweep's crash-resume checkpoint. + * + * `writeSweepProgress` serialises the whole capture map (~38KB on the reference app) and used to + * run unthrottled on every screen *and* on every opportunistic sighting, chrome detection and + * runtime-node mint. The live document publisher next door was throttled for exactly this reason; + * the checkpoint never was. + * + * Two bounds, and a rule: + * + * - A **revision counter**. `mark` says something changed; a write that finds nothing new since + * the last one does nothing at all. This is what makes a redundant flush free, and what makes a + * trailing write racing a forced one harmless. + * - A **coalescing window**. A `mark` inside the window does not write; it schedules a trailing + * write that carries it and everything that joins it. A burst costs one write. + * - **Force always writes if anything is pending.** Crash-resume is the entire point of the file, + * so every durable path — stopped, finished, cancelled, failed, and the sweep's `finally` — + * calls `flush`, and the window can therefore only ever delay a write a still-running sweep was + * going to supersede. The worst case a kill can hit is one window's worth of work, and the + * resume logic re-explores that node rather than losing it. + * + * Pure except for the `write` and the timer it is handed, so the discipline above is testable + * without a device, a project directory or 38KB of JSON. + */ +export interface CoalescedWriter { + /** Something changed. Writes now, or schedules a trailing write inside the window. */ + mark(): Promise + /** Write immediately if anything is pending, cancelling any scheduled trailing write. */ + flush(): Promise + /** Writes actually issued — diagnostic; nothing branches on it. */ + readonly writes: number +} + +export function createCoalescedWriter(options: { + write: () => Promise + windowMs: number + now: () => number + /** Reported rather than thrown: a trailing write has no caller left to reject to. */ + onError?: (error: unknown) => void + setTimer?: (fn: () => void, ms: number) => unknown + clearTimer?: (handle: unknown) => void +}): CoalescedWriter { + const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms)) + const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as never)) + let revision = 0 + let saved = 0 + let lastWriteAt = -Infinity + let timer: unknown = null + let writes = 0 + + const writeNow = async (): Promise => { + const pending = revision + if (saved >= pending) return + saved = pending + lastWriteAt = options.now() + writes += 1 + await options.write() + } + + const flush = async (): Promise => { + if (timer !== null) { + clearTimer(timer) + timer = null + } + await writeNow() + } + + return { + get writes() { + return writes + }, + async mark(): Promise { + revision += 1 + if (options.now() - lastWriteAt < options.windowMs) { + if (timer === null) { + timer = setTimer(() => { + timer = null + void writeNow().catch((error: unknown) => options.onError?.(error)) + }, options.windowMs) + ;(timer as { unref?: () => void })?.unref?.() + } + return + } + await flush() + }, + flush + } +} diff --git a/src/app-flow/decision-provider.ts b/src/app-flow/decision-provider.ts new file mode 100644 index 00000000..b421e615 --- /dev/null +++ b/src/app-flow/decision-provider.ts @@ -0,0 +1,85 @@ +import type { TokenUsage } from '../shared/app-flow.ts' + +export type SubAgentThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' +export interface ModelCallDiagnostic { [key: string]: unknown } + +export interface SubAgentOptions { + cwd: string + systemPrompt: string + userPrompt: string + tools?: string[] + thinkingLevel?: SubAgentThinkingLevel + providerId?: string + modelId?: string + imagePath?: string + timeoutMs?: number + onProgress?: (message: string, level?: 'info' | 'warn') => void + onUsage?: (usage: TokenUsage) => void + onDiagnostic?: (diagnostic: ModelCallDiagnostic) => void + diagnosticContext?: { agent: string; fixerExecutionAttempt?: number } + returnPartialOnTimeout?: boolean +} + +export interface SubAgentResult { + text: string + toolCalls: Array<{ id: string; name: string; arguments: Record }> + timedOut: boolean + filesChanged: string[] + tokens: TokenUsage + diagnostics: ModelCallDiagnostic[] +} + +export interface CompactHistoryOptions { + keepRecentUserTurns?: number + placeholder?: string +} +export interface SubAgentSessionOptions { + cwd: string + systemPrompt: string + tools?: string[] + thinkingLevel?: SubAgentThinkingLevel + providerId?: string + modelId?: string + compactHistory?: CompactHistoryOptions +} +export type SubAgentCallOptions = Omit< + SubAgentOptions, + 'cwd' | 'systemPrompt' | 'tools' | 'thinkingLevel' | 'providerId' | 'modelId' +> + +export function parseSubAgentJson(text: string, validate?: (value: unknown) => value is T): T | null { + const candidates: string[] = [] + let start = -1, depth = 0, inString = false, escaped = false + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (inString) { + if (escaped) escaped = false + else if (char === '\\') escaped = true + else if (char === '"') inString = false + continue + } + if (char === '"') inString = true + else if (char === '{') { if (depth === 0) start = index; depth += 1 } + else if (char === '}' && depth > 0) { depth -= 1; if (depth === 0 && start >= 0) candidates.push(text.slice(start, index + 1)) } + } + for (const candidate of candidates.reverse()) { + try { + const value = JSON.parse(candidate) as unknown + if (!validate || validate(value)) return value as T + } catch { /* Try the preceding complete JSON object. */ } + } + return null +} + +export interface AppFlowDecisionSession { + prompt(options: SubAgentCallOptions, label: string): Promise + historyLength(): number + dispose(): void +} +export interface AppFlowDecisionProvider { + run(options: SubAgentOptions, label: string): Promise + createSession(options: SubAgentSessionOptions): Promise + activeNow?(): number + /** Single-pending-request host providers set this false so the engine never hedges. */ + supportsConcurrentRequests?: boolean +} diff --git a/src/app-flow/exploration.ts b/src/app-flow/exploration.ts new file mode 100644 index 00000000..9893b06b --- /dev/null +++ b/src/app-flow/exploration.ts @@ -0,0 +1,1178 @@ +import type { + AppFlowDestinationLocator, + AppFlowNavigationAction, + AppFlowNavigationDecision +} from './capture.ts' +import type { + AppFlowCaptureStatus, + AppFlowGuard, + AppFlowPressable, + AppFlowTrigger +} from '../shared/app-flow.ts' +import { PARAM_VALUE_RE } from './param-store.ts' +import type { ParamResolution, ParamRouteNode, ParamSource } from './param-store.ts' +import { fingerprint, similarity, tokenize } from './screen-signals.ts' + +const LOCATOR_KINDS = new Set(['text', 'label', 'id']) + +/** + * `qualifiers` appends extra selector terms to narrow an otherwise ambiguous match — agent-device's + * selector grammar is a space-separated term list, so `label="Menu" hittable=true` is one selector + * matching only the tappable `Menu`. Used by `executeNavigationActions`'s ambiguity retry; see there + * for why a plain locator is tried first. + */ +export function serializeLocator( + locator: { by: 'text' | 'label' | 'id'; value: string }, + qualifiers?: Readonly> +): string { + const value = locator.value.trim() + if (!LOCATOR_KINDS.has(locator.by) || !value) throw new Error('Invalid navigation locator') + const base = `${locator.by}=${JSON.stringify(value)}` + const extra = Object.entries(qualifiers ?? {}).map( + ([key, term]) => `${key}=${typeof term === 'boolean' ? term : JSON.stringify(term)}` + ) + return [base, ...extra].join(' ') +} + +/** agent-device refuses to act on a selector matching more than one element, rather than guessing + * which one the caller meant. Recognised so a caller can narrow and retry instead of giving up. */ +export function isAmbiguousSelectorMessage(message: string): boolean { + return /matched \d+ distinct actionable elements/i.test(message) +} + +export function parseNavigationDecision(value: unknown): AppFlowNavigationDecision | null { + if (!value || typeof value !== 'object') return null + const decision = value as Record + if (decision.decision === 'arrived') { + if (typeof decision.reason !== 'string') return null + // A malformed `destinationLocator` is dropped rather than failing the decision. The locator + // only decides whether the walk's recording can be published; the arrival itself was judged by + // a separate screenshot verdict. Rejecting the whole decision over it would throw away a + // correct, already-walked journey because the model named a landmark the wrong way — the + // caller's landmark fallback handles the gap far better than a retry loop would. + const destinationLocator = asDestinationLocator(decision.destinationLocator) + return destinationLocator + ? { decision: 'arrived', reason: decision.reason, destinationLocator } + : { decision: 'arrived', reason: decision.reason } + } + if (decision.decision === 'blocked') { + return typeof decision.reason === 'string' + ? { decision: 'blocked', reason: decision.reason } + : null + } + if (decision.decision !== 'act' || !isNavigationAction(decision.action)) return null + return { + decision: 'act', + action: decision.action, + reason: typeof decision.reason === 'string' ? decision.reason : '' + } +} + +function isNavigationAction(value: unknown): value is AppFlowNavigationAction { + if (!value || typeof value !== 'object') return false + const action = value as Record + if (!['tap', 'swipe', 'input', 'wait'].includes(String(action.type))) return false + if (action.type === 'tap' || action.type === 'input') { + if (!isLocator(action.locator)) return false + } + if (action.type === 'input' && typeof action.value !== 'string') return false + if ( + action.type === 'swipe' && + action.direction !== undefined && + !['up', 'down', 'left', 'right'].includes(String(action.direction)) + ) { + return false + } + if (action.duration !== undefined && typeof action.duration !== 'number') return false + return true +} + +/** + * Narrows an arbitrary value to a locator usable as a saved recording's destination guard, or + * returns null. + * + * `by: 'text'` is deliberately not accepted, and this is the whole reason this is separate from + * `isLocator`. agent-device's publish check wants a `wait` whose matched element it could record a + * re-verifiable identity for, and it only records that identity when the element has an id or an + * accessibility label — a matched node with neither yields no evidence, and the guard is then + * rejected for having none. `label=` and `id=` selectors match *on* those attributes, so anything + * they resolve necessarily carries one; `text=` matches rendered text, which on a plain RN `Text` + * routinely exists with no `nativeID` and no `accessibilityLabel` at all. Accepting `text` would + * therefore produce a guard that works sometimes and silently loses the script the rest of the + * time, which is worse than never offering it. + */ +function asDestinationLocator(value: unknown): AppFlowDestinationLocator | null { + if (!isLocator(value) || value.by === 'text') return null + return { by: value.by, value: value.value.trim() } +} + +// `chromeLocatorValues`, `isChromeLocator` and `pickLandmarkLocator` lived here. All three existed +// to choose the *destination guard* a recorded `.ad` script needed before agent-device would +// publish it — a wait on a durable, non-chrome landmark of the screen the walk ended on. The sweep +// records no scripts now (see `walk-commands.ts`), so nothing needs a guard chosen for it, and the +// manual recorder picks its own from the live snapshot (`manual/locators.ts`'s +// `destinationLocator`). `chrome-inventory.ts` still refers to `pickLandmarkLocator` by name in a +// comment contrasting its own locator rules; that contrast is with the rules, not the function. + +function isLocator(value: unknown): value is { by: 'text' | 'label' | 'id'; value: string } { + if (!value || typeof value !== 'object') return false + const locator = value as Record + return ( + LOCATOR_KINDS.has(String(locator.by)) && + typeof locator.value === 'string' && + !!locator.value.trim() + ) +} + +/** Tracks active exploration time while allowing user-controlled waits to be excluded. */ +export class ActiveTimeBudget { + private excludedMs = 0 + private readonly limitMs: number + private readonly startedAt: number + private readonly now: () => number + + constructor(limitMs: number, startedAt = Date.now(), now: () => number = Date.now) { + this.limitMs = limitMs + this.startedAt = startedAt + this.now = now + } + + exclude(ms: number): void { + this.excludedMs += Math.max(0, ms) + } + + get remainingMs(): number { + return Math.max(0, this.limitMs - (this.now() - this.startedAt - this.excludedMs)) + } + + get expired(): boolean { + return this.remainingMs === 0 + } +} + +/** + * Remembers which deep-link URLs the arrival judge has already rejected this sweep, so a caller + * can skip reopening one it already knows fails instead of paying for the open, the + * `waitForRoute`, the screenshot and the judge call again to relearn the same fact. + * + * This exists because `paramFreeDeepLink` truncates every route with an unresolved param to a + * shared ancestor prefix (`/profile/:name…` becomes `/profile`), so every sibling route under + * that ancestor computes the identical URL — and a node that gets requeued after the blocked-run + * breaker fires recomputes the same URL for itself a second time too. A device run against + * social-app hit both: five distinct nodes each independently rediscovered that `/profile` 404s, + * and one of them (`ProfileFollows`) rediscovered it twice after a requeue. Keyed by the exact URL + * opened rather than by node, so it catches both cases without knowing anything about the route + * tree. + * + * Deliberately in-memory and scoped to one sweep, not persisted on the node — that is a later, + * schema-backed step of the larger plan. This is only the cheap, self-contained subset of it. + */ +export class DeepLinkRejectionMemo { + private readonly rejected = new Set() + + wasRejected(url: string): boolean { + return this.rejected.has(url) + } + + reject(url: string): void { + this.rejected.add(url) + } +} + +/** + * Remembers which in-component guard conditions this build has already been *observed* to hold + * closed, so sibling screens behind the same flag skip the device probe that would rediscover it. + * + * `explainUnavailable` can already name the gate from the source the moment a guarded route 404s — + * but it is only consulted *after* the deep link has been opened, settled, screenshotted and + * judged. That ordering is right for a single node (the parser knows the flag exists, not what the + * build set it to, and only the device can answer that) and wasteful across a family of them: a + * sweep against social-app paid 4.1s apiece to learn four separate times that + * `ax.features.ModerationInboxEnable` is off, each probe ending in the identical 'Page not found' + * frame and the identical static explanation. + * + * So the first node behind a condition still pays full price and settles the question empirically; + * every later node carrying the *same* condition text reads the answer off this memo for free. The + * verdict recorded is therefore always a measured one, never an assumption from static source — + * which is the distinction `explainUnavailable` exists to protect and this memo must not erode. + * + * Keyed by the condition's source text because that is what makes two guards the same gate: the + * fallback component is too coarse (every gated screen in an app tends to render the same + * `NotFoundScreen`) and the node is too fine (the whole point is to span siblings). Two screens + * whose conditions are written differently but evaluate alike simply each pay once, which is the + * cheap failure. + * + * In-memory and scoped to one sweep, exactly like `DeepLinkRejectionMemo` above and for the same + * reason: a feature flag is a fact about one build, and the per-node durable record + * (`deep-link-policy.ts`) is the thing that already rides across sweeps. + */ +export class GuardGateMemo { + private readonly closed = new Set() + + wasClosed(condition: string): boolean { + return this.closed.has(condition) + } + + close(condition: string): void { + this.closed.add(condition) + } +} + +// `discardRecordingCandidate` lived here: unlink a walk's `.candidate.ad` and soft-home the app. +// Exploration writes no candidate file now, so there is nothing to discard and no caller left. + +/** + * What one unresolved hop costs relative to a whole extra screen in the journey. A hop the parser + * resolved to a named control is something the planner can tap; one it could not is a claim that + * the two screens are connected somehow, and walking it means finding the link yourself — often on + * content that only exists once the app has loaded data. Any number of named taps is a better + * journey than one guess, so the penalty is larger than any realistic path length. + */ +const UNRESOLVED_HOP_PENALTY = 1_000 + +/** + * The most reliable path through the parsed route graph from a screen the app opens on to the + * target: fewest hops whose control the parser could not name, and among those, fewest hops. + * + * Hop count alone picks the wrong journey. The graph is built by scanning each screen for + * `navigate('X')`, so a screen that lists other records gets an edge to every screen those records + * open — and that edge is a shortcut on paper only, because the control that walks it is a row the + * app renders at runtime, not something the planner can find on a cold screen. The journey the + * source spells out control-by-control is longer and actually walkable. + * + * `entryIds` are the screens reachable straight from the app's entry chrome — its tabs. A path is + * only returned when it actually starts at one of them: walking back to "whatever has no incoming + * edge" invents journeys that begin on an arbitrary screen the user can't even see at launch. No + * honest path is better than a fabricated one — the caller then tells the planner to explore from + * the tab bar instead. + */ +export function findNavigationPath( + edges: readonly { from: string; to: string | null; trigger?: AppFlowTrigger | null }[], + targetId: string, + entryIds: Iterable +): string[] | null { + const entries = new Set(entryIds) + if (entries.has(targetId)) return [targetId] + + const incoming = new Map() + for (const edge of edges) { + if (!edge.to || edge.from === edge.to) continue + const hop = { from: edge.from, cost: edge.trigger ? 1 : 1 + UNRESOLVED_HOP_PENALTY } + const sources = incoming.get(edge.to) + if (sources) sources.push(hop) + else incoming.set(edge.to, [hop]) + } + + // Walked backwards from the target, cheapest partial journey first. `cheapest` is what stops a + // cyclic graph looping: a screen is only re-expanded when a strictly cheaper way to it turns up. + const cheapest = new Map([[targetId, 0]]) + const frontier: { path: string[]; cost: number }[] = [{ path: [targetId], cost: 0 }] + let best: { path: string[]; cost: number } | null = null + while (frontier.length) { + frontier.sort((a, b) => a.cost - b.cost) + const current = frontier.shift()! + // Everything still queued costs at least this much, so no cheaper journey can come out of it. + if (best && current.cost >= best.cost) break + for (const hop of incoming.get(current.path[0]) ?? []) { + const cost = current.cost + hop.cost + const known = cheapest.get(hop.from) + if (known !== undefined && known <= cost) continue + cheapest.set(hop.from, cost) + const path = [hop.from, ...current.path] + // An entry screen ends the journey rather than extending it: a path is what the user walks + // from launch, so it starts at the first entry it reaches and never passes through another. + if (entries.has(hop.from)) { + if (!best || cost < best.cost) best = { path, cost } + continue + } + frontier.push({ path, cost }) + } + } + return best?.path ?? null +} + +/** + * Renders a journey as the taps that walk it, naming the control for each hop the parser resolved. + * A hop with no known control is stated as such rather than left out, so the planner can see which + * single step it has to work out for itself instead of re-deriving the whole route. + */ +export function describeJourney( + edges: readonly { from: string; to: string | null; trigger?: AppFlowTrigger | null }[], + path: readonly string[] +): string { + if (path.length < 2) return path[0] ?? '' + const hops: string[] = [path[0]] + for (let index = 1; index < path.length; index++) { + const edge = edges.find( + (candidate) => candidate.from === path[index - 1] && candidate.to === path[index] + ) + const trigger = edge?.trigger + hops.push( + trigger + ? `--[tap ${trigger.by}=${JSON.stringify(trigger.value)}]--> ${path[index]}` + : `--[control unknown, find it on screen]--> ${path[index]}` + ) + } + return hops.join(' ') +} + +export type ReachMethod = 'saved-replay' | 'deep-link' | 'agent-navigation' + +export interface ExplorationObservation { + pressables: AppFlowPressable[] + screenshotPath: string +} + +export interface ExplorationResult { + arrived: boolean + actions: AppFlowNavigationAction[] + reason: string + /** + * Set only on an arrival, and only when the planner named a usable one. The caller needs it to + * guard the recording it is about to publish — see `AppFlowNavigationDecision` — and falls back + * to `pickLandmarkLocator` over the arrival observation's controls when it is absent. + */ + destinationLocator?: AppFlowDestinationLocator +} + +/** + * How many planner decisions one exploration attempt gets before it gives up on the target. + * + * The walk itself is short when it is going to work at all: a screen behind a tab bar is one tap, a + * screen behind a drawer is two, and a seeded walk starts a hop away by construction. What the + * higher cap bought was not deeper walks but longer flailing on screens that cannot be reached at + * all from where the explorer is standing — a route whose list is empty so no row exists to open, + * or a destination whose entry point sits behind a feature flag that is off in this build. Those + * look identical to the planner on every step: a plausible screen, no control leading onward. It + * cannot conclude "unreachable" from one frame, so it keeps proposing the next most promising tap + * until something else stops it, and every one of those taps is a screenshot, a control read and a + * vision call. + * + * Seven is chosen to clear a realistic walk with room to recover from a wrong turn, and to stop + * well before a doomed one has spent a whole budget. Two attempts per node (seeded, then from the + * entry screen) still apply, so a genuinely reachable screen that the first walk approached badly + * gets a second, independent seven. + */ +export const EXPLORATION_MAX_DECISIONS = 7 + +/** Screenmap-style observe → one action → observe loop, with injected device/model operations. */ +export async function runInteractiveExploration(options: { + budget: ActiveTimeBudget + maxDecisions?: number + maxNoProgress?: number + maxPlannerFailures?: number + /** + * How many times the planner may claim arrival and be overruled by `verifyArrival` before the + * walk gives up. Two by default. + * + * This is its own budget because a rejected arrival is the one unproductive step that no other + * counter sees. It executes no action, so `actionAttempted` stays false and the no-progress + * fingerprint comparison never runs; it parses fine, so `maxPlannerFailures` is not touched. On + * device that left `maxDecisions` as its only bound, and a `SearchTab/Search` walk spent all of + * it: the planner said "arrived" on steps 2, 3 and 4 of the same unchanged screen, the judge + * said mismatch twice and match on the third, and each round cost a full planner call plus a + * full judge call — about 12k tokens to re-roll a verdict on pixels that never changed. + */ + maxArrivalRejections?: number + observe(step: number): Promise + /** + * `history` carries only what went wrong along the way — a rejected arrival, a failed action, a + * planner call that errored — never a log of every successful step. That is deliberate: `decide` + * is expected to be backed by one long-lived model session per exploration attempt (a fresh + * sub-agent session per call would pay for resending the full history every time, which is the + * quadratic-cost problem this shape exists to avoid), so the model already has its own + * successful actions in its own conversation. Only the failures are new information the model + * would not otherwise have — a rejected `decide` call never becomes a turn in that conversation. + */ + decide( + observation: ExplorationObservation, + history: readonly string[] + ): Promise + act(action: AppFlowNavigationAction): Promise + verifyArrival(observation: ExplorationObservation): Promise + check?(): void + /** + * Fired once per observation, after it is taken but before it feeds the no-progress fingerprint + * comparison or the planner. Exists so the orchestrator can opportunistically recognise a + * *different* pending screen the walk toward this target happened to land on — Settings, Profile + * and the other hub screens this loop passes through on the way to almost anywhere — without this + * module knowing anything about capture maps, slugs, or checkpoints. A failure here must not derail + * the walk toward the real target, so it is swallowed rather than propagated. + */ + observed?(observation: ExplorationObservation): Promise +}): Promise { + const actions: AppFlowNavigationAction[] = [] + const history: string[] = [] + let previous: Set | null = null + let noProgress = 0 + let actionAttempted = false + let successfulActionPendingObservation = false + let plannerFailures = 0 + const maxDecisions = options.maxDecisions ?? 30 + const maxNoProgress = options.maxNoProgress ?? 3 + const maxPlannerFailures = options.maxPlannerFailures ?? 3 + const maxArrivalRejections = options.maxArrivalRejections ?? 2 + let arrivalRejections = 0 + + for (let step = 0; step < maxDecisions && !options.budget.expired; step++) { + options.check?.() + const observation = await options.observe(step) + options.check?.() + if (options.observed) { + try { + await options.observed(observation) + } catch (error) { + // Opportunistic capture is a side effect of observing, never a reason the real target's + // walk should stop — see the doc comment on `observed` above. + console.error('[app-flow] observed hook failed:', error) + } + options.check?.() + } + if (options.budget.expired) break + const current = fingerprint(observation.pressables) + if (actionAttempted) { + if (previous && similarity(previous, current) >= 0.98) noProgress += 1 + else noProgress = 0 + } + previous = current + actionAttempted = false + successfulActionPendingObservation = false + if (noProgress >= maxNoProgress) { + return { + arrived: false, + actions, + reason: 'Three consecutive actions made no visible progress.' + } + } + + let decision: AppFlowNavigationDecision | null + try { + decision = await options.decide(observation, history) + } catch (error) { + // A planner call that times out or errors says nothing about the screen — the app is still + // sitting where the last action left it. Losing the whole target to one slow call throws away + // every step already walked, so the failure costs this step and the loop observes again. + plannerFailures += 1 + const detail = error instanceof Error ? error.message : String(error) + history.push(`Planner call failed: ${detail}; decide again from the fresh observation.`) + options.check?.() + if (plannerFailures >= maxPlannerFailures) { + return { + arrived: false, + actions, + reason: `Planner failed ${plannerFailures} times in a row: ${detail}` + } + } + continue + } + options.check?.() + if (options.budget.expired) break + if (!decision) { + // Counted against the same budget as a thrown planner call, and for a sharper reason than + // symmetry. A call that returns unparseable JSON is the *most* expensive kind: on device, the + // two `/profile/:name` steps that produced no usable decision burned 3,076 and 5,890 reasoning + // tokens before emitting 44 and 203 characters of text. Leaving this path uncounted meant the + // costliest failure mode was the only one with no failure budget at all — it ran until + // `maxDecisions`, and every step after it also re-read the dead trace as input. + plannerFailures += 1 + history.push('Planner returned an invalid decision; inspect the fresh UI and correct it.') + if (plannerFailures >= maxPlannerFailures) { + return { + arrived: false, + actions, + reason: `Planner failed ${plannerFailures} times in a row: returned an invalid decision.` + } + } + continue + } + // Reset here rather than the moment `decide` returns: a call that came back with unparseable + // JSON has not succeeded, and resetting on return would have made the counter above unreachable. + plannerFailures = 0 + if (decision.decision === 'blocked') return { arrived: false, actions, reason: decision.reason } + if (decision.decision === 'arrived') { + if (await options.verifyArrival(observation)) { + return decision.destinationLocator + ? { + arrived: true, + actions, + reason: decision.reason, + destinationLocator: decision.destinationLocator + } + : { arrived: true, actions, reason: decision.reason } + } + arrivalRejections += 1 + history.push(`Arrival rejected: ${decision.reason}`) + // The planner and the judge disagree about the screen in front of them, and nothing the loop + // does next changes that screen — it re-observes the same frame and asks the same two + // questions again. Past a couple of rounds that is not exploration, it is paying twice per + // round to re-roll a non-deterministic verdict, so the walk stops and says so rather than + // spending the rest of `maxDecisions` on it. + if (arrivalRejections >= maxArrivalRejections) { + return { + arrived: false, + actions, + reason: `Planner claimed arrival ${arrivalRejections} times and the arrival check rejected each one: ${decision.reason}` + } + } + continue + } + + try { + actionAttempted = true + await options.act(decision.action) + actions.push(decision.action) + successfulActionPendingObservation = true + // No "action succeeded" note: a successful action needs no correction, and with a + // session-scoped planner (see `decide`'s doc comment) the model already knows what it just + // did — it is sitting right there in its own conversation. Recording it here too would only + // resurrect the per-step history dump this shape exists to avoid. + } catch (error) { + history.push( + `Action failed: ${JSON.stringify(decision.action)} — ${error instanceof Error ? error.message : String(error)}` + ) + } + } + // A decision cap limits model calls, not observations. If the final permitted decision was a + // successful action, inspect its result once before giving up. Previously an action that reached + // the target on decision seven was guaranteed to be reported missing because the loop ended + // before the corresponding observe/arrival check; Moderation did exactly that in the regression + // run. This adds no planner call and only pays the verifier when there is a new final frame. + if (successfulActionPendingObservation && !options.budget.expired) { + options.check?.() + const observation = await options.observe(maxDecisions) + options.check?.() + if (options.observed) { + try { + await options.observed(observation) + } catch (error) { + console.error('[app-flow] observed hook failed:', error) + } + options.check?.() + } + if (!options.budget.expired && (await options.verifyArrival(observation))) { + return { + arrived: true, + actions, + reason: 'The final navigation action reached the target.' + } + } + } + return { + arrived: false, + actions, + reason: options.budget.expired + ? 'Navigation budget exhausted after five minutes.' + : `Navigation stopped after ${maxDecisions} decisions.` + } +} + +/** + * Every ancestor of `urlPath` that is itself a route, deepest first — `/moderation/inbox` then + * `/moderation` for `/moderation/inbox/settings`. Deepest first because the shortest remaining + * journey is the one least likely to go wrong. + * + * Kept here, verbatim, as the ancestry primitive `reach/seed.ts`'s `findSeedSource` builds on — + * that module supersedes this file's old `findSeedAncestor`, which offered only one proof of + * reachability (a deep link opened this run). `findSeedSource` adds a second, stronger one (a + * verified replay) without touching how ancestry itself is computed. + */ +export function findRouteAncestors( + urlPath: string, + nodes: readonly T[] +): T[] { + const byPath = new Map() + for (const node of nodes) { + if (node.urlPath) byPath.set(node.urlPath.replace(/\/+$/, ''), node) + } + const segments = urlPath.replace(/\/+$/, '').split('/') + const found: T[] = [] + for (let cut = segments.length - 1; cut > 1; cut--) { + const ancestor = byPath.get(segments.slice(0, cut).join('/')) + if (ancestor) found.push(ancestor) + } + return found +} + +/** + * The nearest ancestor of `urlPath` that is itself a route, e.g. `/moderation/inbox` for + * `/moderation/inbox/settings`. Used to judge whether a 404 is about this one screen or about a + * whole branch of the app being absent. + */ +export function findRouteAncestor( + urlPath: string, + nodes: readonly T[] +): T | null { + return findRouteAncestors(urlPath, nodes)[0] ?? null +} + +/** + * The settled explanation for a screen its own build gates off, in one place because two callers + * now produce it: `explainUnavailable` below, for the node that discovers the gate by probing it, + * and the deep-link strategy's `GuardGateMemo` short-circuit, for every sibling that then skips + * the probe. The two must read identically — they are the same finding about the same gate, and a + * reader comparing two nodes' recorded reasons should not have to work out whether a wording + * difference means something. + */ +export function explainGuardGate(guard: AppFlowGuard): string { + return `Registered but gated off in this build: the screen renders <${guard.fallback}/> unless \`${guard.condition}\` is false.` +} + +/** + * Explains a deep link that resolved but landed on the app's own not-found screen, or returns null + * when the sweep still has to explore to find out. + * + * A registered route rendering 404 means one of two things: the build gates the screen off, or the + * URL the sweep built was not the one the route wanted. Telling them apart matters — the first is a + * final answer worth recording, the second is exactly the case exploration exists for. + */ +export function explainUnavailable( + node: { + urlPath: string | null + params: readonly string[] + guard: AppFlowGuard | null + }, + ancestorStatus: AppFlowCaptureStatus | null +): string | null { + // A sample param the sweep invented is at least as likely as a gate to be why the route 404s, + // and unlike a gate it is something exploration can get past. + if (node.params.length) return null + if (node.guard) return explainGuardGate(node.guard) + // No gate found in the source — but a 404 under a branch the sweep has already reached cannot be + // a wrong URL, because the surrounding routes resolved. Something in the build withholds it. + if (ancestorStatus === 'ok' || ancestorStatus === 'unavailable') { + return 'Registered but not available in this build: the route resolves under a reachable branch yet renders the not-found screen.' + } + return null +} + +/** The subset of a node `indexDeepLinkPath` reads. */ +export interface IndexDeepLinkNode { + id: string + group: string + file: string | null +} + +/** + * The route a root-path screen (`urlPath: '/'`) should be deep-linked at, or **null when no honest + * answer exists** and the screen has to be reached through the UI instead. + * + * Opening the bare scheme does not switch tabs, so a screen registered at `/` cannot be deep-linked + * directly and something has to stand in for it. Exactly one substitution is grounded: an Expo + * Router route group (`(tabs)/index` → `/(tabs)`) is a real route the app declares, read off the + * node's own id or group. + * + * Everything else used to fall back to the first segment of the source file path, and that is not a + * guess so much as an invention. On Bluesky — React Navigation, no route groups — + * `HomeTab/Home` (`src/view/screens/Home.tsx`) produced `bluesky://src`, a path the app has never + * heard of, and the only thing it could possibly do was render the build's own 404. The device log + * shows the full price paid per root-path node: a URL open, a `waitForRoute`, a screenshot and a + * 3.5s arrival call, all to be told `Page not found`, before the ladder fell through to the walk + * that was always going to be the answer. Worse, the mismatch is indistinguishable from a route the + * build genuinely withholds, so it also fed `registerDeepLinkFailure` — an invented URL voting on + * whether the app's URL scheme works. + * + * Returning null makes "there is no URL for this screen" a first-class answer, which is the truth + * for a React Navigation root screen, and lets `deepLinkStrategy` skip straight to the walk. + * `probe-route.ts` already refuses to *probe* a root path for this same reason; this is the + * per-node half of the same rule. + */ +export function indexDeepLinkPath(node: IndexDeepLinkNode): string | null { + const group = node.id.split('/').find((seg) => seg.startsWith('(') && seg.endsWith(')')) + if (group) return `/${group}` + if (node.group.startsWith('(') && node.group.endsWith(')')) return `/${node.group}` + return null +} + +/** Whether `node` is a root-path screen with no grounded stand-in route — the case where a deep + * link can only be invented, and so must not be attempted at all. */ +export function isUndeeplinkableRoot( + node: IndexDeepLinkNode & { urlPath: string | null } +): boolean { + return (node.urlPath === '/' || node.urlPath === '') && indexDeepLinkPath(node) === null +} + +/** + * The longest prefix of `urlPath` that carries no unresolved param, or null when the route needs one + * in its very first segment. + * + * A route the call sites never supply a value for is not automatically unreachable by URL: apps + * routinely resolve the parameterised route's own prefix on their own — `/profile/:name` opens the + * signed-in user's profile. Trying that costs one open and one arrival check, and the arrival + * verifier is what decides whether it landed on the target, so a prefix that means something else + * simply falls through to exploration. + */ +export function paramFreeDeepLink( + urlPath: string, + unresolvedParams: readonly string[] +): string | null { + if (!unresolvedParams.length) return urlPath + const wanted = new Set(unresolvedParams) + const segments = urlPath.replace(/\/+$/, '').split('/') + const at = segments.findIndex((segment) => wanted.has(paramKey(segment) ?? '')) + if (at === -1) return urlPath + const prefix = segments.slice(0, at).join('/') + return prefix && prefix !== '/' ? prefix : null +} + +/** The param a route segment declares — `:name` and `[name]` alike — or null for a literal one. */ +function paramKey(segment: string): string | null { + if (segment.startsWith(':')) return segment.slice(1) + if (segment.startsWith('[') && segment.endsWith(']')) { + return segment.slice(1, -1).replace(/^\.\.\./, '') + } + return null +} + +/** Longest control caption the planner is shown before it is cut. */ +const PRESSABLE_TEXT_LIMIT = 80 + +/** + * The visible controls trimmed to what a planner can act on. A content-heavy screen — a feed, a + * search result list — exposes hundreds of pressables whose captions are whole posts, and sending + * them verbatim has cost a single navigation step over twenty thousand input tokens and a hundred + * seconds. Captions are cut to a length that still identifies the control, entries that collapse + * onto each other once cut are dropped as the duplicate locators they are, and the count left out + * is reported so the planner knows it is looking at a window rather than the whole screen. + * + * `identifier` — when a control has one — is put first in the object and `text` is left out + * entirely, rather than the two of them sitting alongside `label` as equally-weighted fields. The + * ProfileFollowers walk in session_2026-09-22_22-48-44.log is why: the row's name was only ever + * exposed as `label` (`{"label":"Clockwork Creature","role":"StaticText"}`, no `identifier` at + * all), and the planner still spent its first step on `by:"text"`, which agent-device rejected + * outright ("Selector did not match: text=\"Clockwork Creature\""), then a second step on a + * same-shaped neighbour (`label:"Clockwork Creature's avatar"`) that matched but opened a post + * thread instead of the profile — two of five steps burned, versus the very next screen + * (ProfileFollows) landing in two taps because every control on it carried a testID + * (`bottomBarProfileBtn`, `profileHeaderFollowsButton`). A control list where `text` and `label` + * flank `identifier` as one option among three invites exactly that guessing; leading with + * `identifier` and dropping the field that duplicates `label` most often (`text` is `node.value` in + * capture.ts, which for a plain pressable is usually the same caption `label` already carries) + * makes the stable handle the obvious first read and shrinks the payload besides — this list is + * resent for the two most recent observations on every step, so every byte trimmed here is paid + * back on every subsequent step. + */ +export function summarizePressables( + pressables: readonly AppFlowPressable[], + limit = 60, + maxChars = PRESSABLE_TEXT_LIMIT +): { shown: AppFlowPressable[]; omitted: number } { + const seen = new Set() + const unique: AppFlowPressable[] = [] + for (const pressable of pressables) { + const identifier = pressable.identifier ? cut(pressable.identifier, maxChars) : undefined + const trimmed: AppFlowPressable = identifier + ? { + identifier, + ...(pressable.role ? { role: pressable.role } : {}), + // `text` is dropped only when `label` is there to carry the caption: capture.ts sets + // `text` from node.value and `label` from node.label (two independent fields), and on an + // identified pressable that has both, node.value is almost always the same caption + // node.label already carries — repeating it a third way just spends tokens nudging the + // planner back toward the selectors that keep failing. When there is no `label`, node.value + // is the control's only caption and dropping it would leave `{identifier, role}` saying + // nothing about what the control holds — a text field's current contents, a control whose + // state lives in its value. capture.ts keeps a pressable when ANY of text/label/identifier + // is set, so that shape is one the capture side explicitly expects to emit. + ...(pressable.label + ? { label: cut(pressable.label, maxChars) } + : pressable.text + ? { text: cut(pressable.text, maxChars) } + : {}) + } + : { + ...(pressable.text ? { text: cut(pressable.text, maxChars) } : {}), + ...(pressable.label ? { label: cut(pressable.label, maxChars) } : {}), + ...(pressable.role ? { role: pressable.role } : {}) + } + const identity = JSON.stringify(trimmed) + if (seen.has(identity)) continue + seen.add(identity) + unique.push(trimmed) + } + return { shown: unique.slice(0, limit), omitted: Math.max(0, unique.length - limit) } +} + +function cut(value: string, maxChars: number): string { + const collapsed = value.replace(/\s+/g, ' ').trim() + return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}\u2026` : collapsed +} + +/** + * Whether a message describes the agent-device session or daemon being gone rather than the app + * doing anything in particular — the daemon was replaced mid-walk (agent-device does this + * transparently and logs it to stderr, never to a thrown error) and the session the replaced + * daemon owned dies with it, so the next call against that session fails with wording like + * "No active proxy device lease for this session; run open first." None of that says anything + * about the app; it says the harness's connection to the device needs to be re-established. + * + * `capture.ts`'s `isMissingSessionError` (which decides whether to recover by relaunching) and + * this file's `isToolingFailure` (which decides whether a reach failure counts against the + * no-progress breaker) both need to recognise the same set of messages, so this is the one place + * that wording lives — `isMissingSessionError` defers to it instead of keeping its own regex. + */ +export function isSessionLostMessage(message: string): boolean { + return /no active session|run open first|session_not_found/i.test(message) +} + +/** + * One log-ready clause saying what a recovery site saw when it decided the session was gone: which + * field carried the session-loss wording and what it said. + * + * "no active session, recovering" used to name only the step that caught it. The two causes that + * matter — the literal wording on the message (the daemon was replaced, the session died with it) + * and the wording only on `details.hint` (a replay's post-failure snapshot hit the dead session, + * and the message says "capture-failed") — are different failures with different fixes, and + * neither is recoverable from the bare line. Collapsed and cut so a replay failure that quotes a + * long locator cannot flood the log; the caller adds what it was doing, which only it knows. + */ +export function describeSessionLoss(message: string, hint?: string): string { + const cut = (value: string): string => { + const collapsed = value.replace(/\s+/g, ' ').trim() + return collapsed.length > 160 ? `${collapsed.slice(0, 160)}\u2026` : collapsed + } + if (isSessionLostMessage(message)) return `error message: ${cut(message)}` + if (hint !== undefined && isSessionLostMessage(hint)) { + return `error hint: ${cut(hint)} (message: ${cut(message)})` + } + return `error message: ${cut(message)}` +} + +/** + * Whether a failed reach is about the harness rather than the app — a planner that ran out of time, + * an exhausted budget, a device command that could not complete, or the agent-device session it was + * using having died (see `isSessionLostMessage`, most recently observed when a daemon replacement + * mid-walk killed the session `saveAndReplayRecording` needed). + * + * The no-progress counter exists to spot an app stuck behind one screen it will not leave, and it + * decides that by comparing the UI left behind by consecutive failures. Sibling screens look alike + * — a followers list and a follows list are near-identical — so counting timeouts alongside real + * navigation failures stops the sweep to ask the user to clear a blocker that was never there. + */ +export function isToolingFailure(reason: string | null): boolean { + if (!reason) return false + return ( + /sub-agent timed out|planner failed|budget exhaust|runner busy|screenshot failed/i.test( + reason + ) || isSessionLostMessage(reason) + ) +} + +/** + * Whether a walk seeded at a deep-linked ancestor is worth repeating from the app entry after it + * failed. + * + * A retry earns its cost only when the first attempt left something unlearned. Two things do: the + * walk never ran (the seed's recording could not launch, so the entry walk is the first real try), + * or it died of the harness rather than the app (`isToolingFailure` — a planner timeout or a lost + * session says nothing about whether the target is reachable). A walk that ran its decision loop + * to an ordinary end — decisions spent, no visible progress, the planner returning blocked — has + * already put the same planner, the same target and the same app in front of the question, from a + * start the sweep had *proved* it could reach. Repeating it from the entry screen buys a second + * seven decisions on the same evidence: measured, the retry cost ~55s and ended at the same + * unresolved-param path, and two never-captured screens in the baseline sweep took 129.3s between + * them. The replay-seeded branch already stops here for the same reason (`seedBaseReplayFailed`); + * this extends that gate to the deep-link-seeded one instead of adding a second counter beside the + * no-progress breaker, which counts *sweep-wide* failures and could not see a single node's two. + */ +export function shouldRetryFromEntry(seeded: { + /** The seeded attempt reached the decision loop and ended without arriving. */ + walkRan: boolean + reason: string | null +}): boolean { + return !seeded.walkRan || isToolingFailure(seeded.reason) +} + +/** + * Real values for a route's params, harvested from the call sites that navigate to it. Only params + * this finds evidence for appear in the result: an invented id is not a weaker deep link but a + * different one, pointing at a record the app does not have, and the 404 it lands on says nothing + * about the screen. + */ +export function resolveRouteParams( + node: { id: string; params: readonly string[] }, + edges: readonly { to: string | null; raw: string; target: string | null }[] +): Record { + const incoming = edges.filter((edge) => edge.to === node.id && edge.raw) + const resolved: Record = {} + for (const key of node.params) { + const escaped = escapeParamKey(key) + for (const edge of incoming) { + const candidate = + // `?name=alice` or `/name=alice` in a href-style target. + edge.raw.match(new RegExp(`[/?&]${escaped}=([^&/'"\`\\s)]+)`))?.[1] ?? + // `navigate('Profile', { name: 'alice' })` — the params object at the call site. + edge.raw.match(new RegExp(`\\b${escaped}\\s*:\\s*['"\`]([^'"\`]+)['"\`]`))?.[1] ?? + // A literal segment sitting where the route declares the param. + segmentForParam(edge, key) + if (candidate && PARAM_VALUE_RE.test(candidate)) { + resolved[key] = candidate + break + } + } + } + return resolved +} + +function escapeParamKey(key: string): string { + return key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** The subset of `ParamStore` this resolver needs, so callers and tests can pass a stub. */ +export interface ParamStoreLike { + noteStatic(node: ParamRouteNode, resolved: Record): void + best(node: ParamRouteNode): ParamResolution | null +} + +/** A route's params as far as anything can honestly fill them, and where the values came from. */ +export interface ResolvedRouteParams { + /** Every param with a real value. Complete when `unresolved` is empty, partial otherwise. */ + values: Record + /** Params still without one — what `paramFreeDeepLink` truncates the URL at. */ + unresolved: string[] + /** Where a *complete* set came from, for the sweep log. Null while the set is partial. */ + source: ParamSource | null + /** The screen a complete harvested set was observed on, for the same log line. */ + seenOn: string | null + /** + * The route that owns the record a complete harvested set came from — not always this node; see + * `sharesRouteParams`. Null for a static or partial set, which belong to no record. `demote` is + * keyed on this. + */ + ownedBy: string | null +} + +/** + * The values a route's params can be opened with, from the call sites *and* from what the app has + * been seen holding. + * + * `resolveRouteParams` above stays exactly as pure as it was: it reads literals out of source text + * and nothing else, and its tests are unchanged. This wraps it with the observed layer, and is the + * single place the two meet — the orchestrator previously computed the static half twice per node, + * once for the agent-device manifest's descriptive path and once for the gate that decides whether + * a deep link is attempted at all, which is also how the two could drift. + * + * A complete harvested record **replaces** the static partial outright rather than topping it up. + * That is the whole point of storing records: a `/profile/:name/lists/:rkey` built from a static + * `name` and some other record's `rkey` opens cleanly, captures the wrong list, and nothing + * downstream can tell. Precedence inside the store (`static` first, then navigation observations, + * then learned testID shapes, then guessed ones) decides which single record wins; see `best`. + */ +export function resolveNodeParams( + node: ParamRouteNode & { id: string; params: readonly string[] }, + edges: readonly { to: string | null; raw: string; target: string | null }[], + store?: ParamStoreLike | null +): ResolvedRouteParams { + const fromSource = resolveRouteParams(node, edges) + // Registering the static read is what gives the store its shape baseline: a param whose call + // sites spell out `alice.test` will not later accept a bare `42` scraped off a list row. It also + // promotes a *complete* static read into a record, so the precedence comparison below has + // something to compare against instead of being special-cased here. + store?.noteStatic(node, fromSource) + const harvested = store?.best(node) ?? null + if (harvested) { + return { + values: { ...harvested.values }, + unresolved: [], + source: harvested.source, + seenOn: harvested.seenOn, + ownedBy: harvested.ownedBy + } + } + const unresolved = node.params.filter((key) => !(key in fromSource)) + return { + values: fromSource, + unresolved, + source: !node.params.length || unresolved.length ? null : 'static', + seenOn: null, + ownedBy: null + } +} + +/** The segment of `edge.raw` that lines up with `:key` in the edge's target pattern. */ +function segmentForParam(edge: { raw: string; target: string | null }, key: string): string | null { + if (!edge.target) return null + const pattern = edge.target.split(/[?#]/)[0].split('/') + const actual = edge.raw.split(/[?#]/)[0].split('/') + if (pattern.length !== actual.length) return null + const at = pattern.findIndex((seg) => seg === `:${key}` || seg === `[${key}]`) + return at === -1 ? null : (actual[at] ?? null) +} + +/** What the candidate scorer below needs to know about a still-pending node. */ +export interface OpportunisticCandidateNode { + id: string + title: string + urlPath: string | null +} + +/** + * Splits `ProfileFollowers`-style identifiers into words before tokenizing. Route ids and + * component names are source identifiers, not prose — they carry no spaces — while a live + * observation's pressable text is real on-screen words (`fingerprint` tokenizes actual UI + * labels). Without this, an id's own token never matches anything a screen actually shows. + */ +function splitCamelCase(text: string): string { + return text.replace(/([a-z0-9])([A-Z])/g, '$1 $2') +} + +/** Whether a url segment is a param placeholder (`:name`, `[name]`) rather than static text. */ +function isParamSegment(segment: string): boolean { + return segment.startsWith(':') || (segment.startsWith('[') && segment.endsWith(']')) +} + +/** + * The vocabulary a node's own static description contributes — the same shape `fingerprint` + * builds from a live observation's pressables, so the two are comparable. `id`'s last `/`-segment + * is used rather than the whole id, because parser ids for a duplicate screen name are qualified + * as `layoutName/ScreenName` (see `parser.ts`); the layout half is the same for every screen it + * registers and would dilute every one of that layout's candidates by the same amount rather than + * help tell them apart. Param placeholders in `urlPath` (`:name`, `[id]`) are dropped for the same + * reason: a param name is shared by every route that happens to take one, not evidence this + * particular screen is on screen. + */ +export function expectedNodeTokens(node: OpportunisticCandidateNode): Set { + const parts = [node.title, node.id.split('/').pop() ?? node.id] + if (node.urlPath) { + for (const segment of node.urlPath.split('/')) { + if (segment && !isParamSegment(segment)) parts.push(segment) + } + } + const out = new Set() + for (const part of parts) for (const token of tokenize(splitCamelCase(part))) out.add(token) + return out +} + +/** + * How much of a node's own descriptive vocabulary must actually appear in the live observation + * before it is worth a vision call at all. This is coverage of `expected`, not a symmetric + * (Jaccard) overlap: a content-heavy screen's observation routinely carries dozens of pressable + * words having nothing to do with any one candidate's identity, and dividing by that huge union + * would keep every genuine match near zero. What matters here is simpler — does this node's own + * few words show up on screen — so the score is shared tokens over `expected.size` alone. + * + * Exported (plan Step 3d) so `reach/chrome-inventory.ts` can score a chrome item's pressable text + * against a node's or a layout's own vocabulary with the identical scorer `pickOpportunisticCandidate` + * uses below, rather than forking a second copy of this exact formula that could drift from it. + */ +export function expectedCoverage( + observed: ReadonlySet, + expected: ReadonlySet +): number { + if (expected.size === 0) return 0 + let shared = 0 + for (const token of expected) if (observed.has(token)) shared += 1 + return shared / expected.size +} + +/** + * Coverage an opportunistic candidate needs before it is worth a vision call at all. Below this, + * the shared app chrome most screens expose (a back button, a tab bar) produces enough incidental + * overlap with almost any node's title to nominate something on every single step — exactly the + * per-step-per-pending-node cost this pre-filter exists to avoid paying. + * + * This was 0.5, and a measured run showed that was far too lenient: 19 opportunistic checks were + * made and 17 were rejected by the vision call — a 15% hit rate — costing 33.6s and 15,043 tokens + * to confirm just 2 screens. A node's expected vocabulary is only a handful of words, so coverage + * is coarse (1/2, 2/3, 3/5 all landed at or above 0.5), and a node needing only half its own + * words was routinely nominated by a screen sharing a generic word or two. 0.7 sits just above + * 2/3 and 3/5, the partial matches that dominated the rejected checks, while still admitting 3/4 + * and anything stronger; it is deliberately not higher, because a genuine match can legitimately + * miss a word (a title the screen renders differently from its route name), and each missed + * confirmation only costs a screen its own real exploration, not a wrong capture. + * + * Raised again from 0.7 to 0.85. `session_2026-10-09_17-22-18.log` measured one such check at + * 3.2s with a wrong answer: it proposed `ProfileSearch` while the walk was heading to + * `ProfileFollowers`, two screens whose titles and URLs overlap heavily. 0.7 still admitted + * sibling screens, which are both the case this scorer cannot separate and the most common thing + * to walk past, so only a near-complete match is now worth a model call. + */ +const MIN_OPPORTUNISTIC_OVERLAP = 0.85 + +/** + * How much clearer the best candidate's coverage must be than the runner-up's before it is + * trusted without a vision call to break the tie. Sibling screens that share almost their entire + * vocabulary — a Followers list and a Follows list, see the comment on `isToolingFailure` above — + * sit close together in score; without a margin requirement the pre-filter would confidently + * nominate whichever of the two happened to sort first, and a mistaken opportunistic capture + * writes a result with no replay and no way to notice it was ever wrong. Below this margin, + * neither is proposed and both stay pending for their own real exploration. + * + * Raised from 0.25 to 0.35 alongside `MIN_OPPORTUNISTIC_OVERLAP` for the same measured reason + * (17 of 19 checks rejected, 33.6s and 15,043 tokens for 2 confirmed screens). Only candidates + * that already cleared the overlap floor reach this comparison, so they sit in [0.7, 1.0]; a + * 0.25 margin let a full match beat a 0.75 sibling, which is a near-tie on vocabularies this + * small, and the vision call then rejected it. 0.35 means a 1.0 winner needs the runner-up to + * be at or below 0.65 — in practice a runner-up must be clearly weaker, not merely one word short. + * + * Raised again from 0.35 to 0.5 for the same `session_2026-10-09_17-22-18.log` measurement + * (3.2s, wrong answer, `ProfileSearch` proposed over `ProfileFollowers`): siblings that close + * together still got through at 0.35, so a winner now needs a runner-up at half its coverage or + * less before the pre-filter nominates it. + */ +const MIN_OPPORTUNISTIC_MARGIN = 0.5 + +/** + * Cheap textual pre-filter for opportunistic capture: picks at most one still-pending node whose + * static description (title, id, url) overlaps the current live observation strongly and + * unambiguously enough to be worth a single confirming vision call. Returns null when nothing + * clears the bar or when the top two candidates are too close to call — either way, no candidate + * is worth spending a model call on, so the caller spends nothing. + */ +export function pickOpportunisticCandidate( + observedTokens: ReadonlySet, + pending: readonly T[] +): T | null { + const scored = pending + .map((node) => ({ node, overlap: expectedCoverage(observedTokens, expectedNodeTokens(node)) })) + .filter((entry) => entry.overlap >= MIN_OPPORTUNISTIC_OVERLAP) + .sort((a, b) => b.overlap - a.overlap) + if (scored.length === 0) return null + const [best, runnerUp] = scored + if (runnerUp && best.overlap - runnerUp.overlap < MIN_OPPORTUNISTIC_MARGIN) return null + return best.node +} + +/** + * Withdraw a harvested record once a rung that *used* it has failed on the device, whatever the + * node ended up as. + * + * Demotion used to run only when the whole ladder ended unreached. But the ladder ends with + * exploration, which routinely rescues a node whose deep link and render both just failed on a bad + * value — so the record stayed, and every sibling that borrowed it (`sharesRouteParams`) paid the + * same failed attempts again. Measured on one sweep: a value valid on its own route and rejected by + * two siblings, ~63s of wasted deep-link and render attempts each, ~126s in all, and the node was + * still captured at the end so the old gate never fired. + * + * Keyed on observed failure, not on the value: a rung that declined without touching the device + * says nothing about it, and neither does a tooling failure (timeout, dead session). Static + * literals are the source's own claim and are never in the store to withdraw. After demotion the + * store has nothing for the route, so the next borrower is declined for free and goes straight to + * exploration rather than being handed a less trustworthy substitute. + * + * Returns whether a record was withdrawn. + */ +export function demoteFailedHarvest( + store: { demote(nodeId: string, values: Record): void }, + nodeId: string, + resolution: Pick, + attempts: readonly { + strategy: string + ok: boolean + touchedDevice: boolean + reason: string + }[] +): boolean { + if (resolution.unresolved.length || !resolution.source || resolution.source === 'static') { + return false + } + const failedOnValue = attempts.some( + (attempt) => + (attempt.strategy === 'deep-link' || attempt.strategy === 'render') && + !attempt.ok && + attempt.touchedDevice && + !isToolingFailure(attempt.reason) + ) + if (!failedOnValue) return false + store.demote(resolution.ownedBy ?? nodeId, resolution.values) + return true +} diff --git a/src/app-flow/explore-ui-run.ts b/src/app-flow/explore-ui-run.ts new file mode 100644 index 00000000..d5741b25 --- /dev/null +++ b/src/app-flow/explore-ui-run.ts @@ -0,0 +1,381 @@ +/** + * Wiring for the explore-UI pass: the only file that implements `explore-ui.ts`'s ports against a + * real device, a real Metro and the real store. + * + * It reuses, rather than re-implements, everything it can: the already-launched device, `capture.ts`'s + * settle and screenshot calls (the same two the sweep's delivery uses), `cdp/connection.ts` for the + * injected agent, `presentations-source.ts` / `views-source.ts` / `preview-plans.ts` for the + * catalog, and `presentations-bindings.ts` for the Metro symbolication join. + * + * There are two entry points and one port builder. `runExploreUi` is the standalone + * `mobile_appflow_explore_ui` run: it opens its own connection to the app the user left running + * and writes what it finds to storage. `openSweepUiStateSession` is the screen sweep's: it is + * handed the sweep's *own* CDP connection (a second one would be a second debugger on the same + * Hermes target) and returns the ports for it, and the orchestrator attaches the result itself. + * Both build their ports with `createExploreUiPorts`, so the commands, budgets and telemetry + * cannot differ between them. + */ +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import type { + AppFlowCapture, + AppFlowPresentations, + AppFlowResult, + AppFlowUiState +} from '../shared/app-flow.ts' +import { ExpectedOperationError } from '../shared/error-reporting.ts' +import { bindPresentationSites } from './presentations-bindings.ts' +import { + captureScreenshot, + settleAfterRoute, + type CaptureContext, + type CaptureDevice +} from './capture.ts' +import { CdpConnection, createTargetResolver } from './cdp/connection.ts' +import type { AgentCommand, NavStateReply } from './cdp/agent-runtime.ts' +import { listMetroTargets } from './cdp/discovery.ts' +import { addSourcePreviewPlans } from './preview-plans.ts' +import { scanPresentations } from './presentations-source.ts' +import { createSourceUnits, createSymbolResolver } from './source-units.ts' +import { scanSourceViews } from './views-source.ts' +import { + exploreUiStates, + EXPLORE_UI_ROLLBACK_TIMEOUT_MS, + type ExploreUiPorts, + type SweepUiStateSession +} from './explore-ui.ts' +import { recordRuntimeTiming } from './runtime-telemetry.ts' +import { getScreenshotPath, readAppFlow, writeAppFlow } from './storage.ts' + +/** The reference's own per-command budgets, kept so a port change is visible as a change. */ +// Setup carries the whole catalog over the wire, reads every initialised module once and forces one +// render pass, so on the first screens of a cold app it is the one command that can run long. The +// first two screens of an otherwise healthy sweep ended `pass-failed` with nothing listed. +const SETUP_TIMEOUT_MS = 20_000 +const LIST_TIMEOUT_MS = 2_000 +const OPEN_TIMEOUT_MS = 5_000 +const CONTENT_TIMEOUT_MS = 2_000 +/** + * Bound on the settle after a presentation opens. + * + * This used to pass `undefined` and inherit `ROUTE_SETTLE_TIMEOUT_MS` (30s). That bound is sized + * for a *deep link* resolving a whole route; a presentation opens over a screen that has already + * settled, so the only wait that matters is the sheet's own transition. The 30s was being paid in + * full on every screen whose state drew nothing — about 30s x 8 of one 304s ten-screen sweep — + * and the frame was then photographed anyway, because `waitForAppReady` captures on timeout. + */ +const UI_STATE_SETTLE_TIMEOUT_MS = 4_000 + +/** Directories a React Native project never keeps its own screens in. */ +const SKIPPED_DIRECTORIES = new Set([ + 'node_modules', '.git', 'ios', 'android', 'build', 'dist', '.expo', '.next', 'coverage', 'vendor' +]) +const SOURCE_EXTENSIONS = /\.(tsx|ts|jsx|js)$/ +/** Bounds on what one scan will parse, so a monorepo cannot stall the pass. */ +const MAX_SOURCE_FILES = 4_000 +const MAX_SOURCE_BYTES = 512_000 + +/** Every project-owned source file, as `{file, text}` pairs with absolute paths. */ +export async function collectProjectSources(root: string): Promise<{ file: string; text: string }[]> { + const sources: { file: string; text: string }[] = [] + const walk = async (dir: string): Promise => { + if (sources.length >= MAX_SOURCE_FILES) return + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (sources.length >= MAX_SOURCE_FILES) return + const path = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name.startsWith('.') || SKIPPED_DIRECTORIES.has(entry.name)) continue + await walk(path) + continue + } + if (!SOURCE_EXTENSIONS.test(entry.name) || /\.d\.ts$/.test(entry.name)) continue + if (/\.(test|spec)\.[tj]sx?$/.test(entry.name)) continue + const text = await readFile(path, 'utf8').catch(() => null) + if (text === null || text.length > MAX_SOURCE_BYTES) continue + sources.push({ file: path, text }) + } + } + await walk(root) + return sources +} + +/** + * The static catalog for one project: presentations, the views they render, and the preview plans + * compiled from both. Pure — nothing here touches a device. + */ +export async function buildPresentationCatalog(root: string): Promise { + const units = createSourceUnits(await collectProjectSources(root)) + const symbol = createSymbolResolver(units, { root }) + const found = scanPresentations(units, root, symbol) + const views = scanSourceViews(units, root, symbol) + return addSourcePreviewPlans({ ...found, views: views.views, viewStates: views.states }) +} + +/** The one thing the ports need from a CDP connection. `CdpConnection` satisfies it. */ +export interface ExploreUiRuntime { + invoke(payload: AgentCommand, timeoutMs: number): Promise +} + +/** Everything the ports are bound to, whichever driver is asking. */ +export interface ExploreUiPortOptions { + projectPath: string + projectId: string + ctx: CaptureContext + device: CaptureDevice + runtime: ExploreUiRuntime + catalog: AppFlowPresentations + signal?: AbortSignal + emit: ExploreUiPorts['emit'] +} + +export interface ExploreUiRunOptions { + projectPath: string + projectId: string + ctx: CaptureContext + device: CaptureDevice + limit?: number + signal?: AbortSignal + emit: ExploreUiPorts['emit'] +} + +export interface ExploreUiRunResult { + nodeId: string | null + captured: number + failed: number + rolledBack: boolean + cancelled: boolean +} + +/** + * Implement `explore-ui.ts`'s ports against a real device, for a connection somebody else owns. + * Closing that connection is the caller's job — the sweep keeps using it after the pass. + */ +export function createExploreUiPorts(options: ExploreUiPortOptions): ExploreUiPorts { + const { catalog } = options + const command = async (payload: AgentCommand, timeoutMs: number, operation: string): Promise => { + const started = performance.now() + try { + return await options.runtime.invoke(payload, timeoutMs) + } finally { + recordRuntimeTiming('app_flow.runtime.command', performance.now() - started, { operation }) + } + } + return { + runtime: { + setup: (catalogue) => + command( + { + type: 'presentation-setup', + states: catalogue.states, + actions: catalogue.actions, + projectRoot: options.projectPath + }, + SETUP_TIMEOUT_MS, + 'presentation-setup' + ), + configure: (matches, checked) => + command( + { + type: 'presentation-configure', + matches: [...matches], + ...(checked?.length ? { checked: [...checked] } : {}) + }, + LIST_TIMEOUT_MS, + 'presentation-configure' + ), + list: () => command({ type: 'presentations' }, LIST_TIMEOUT_MS, 'presentations'), + open: (id) => command({ type: 'presentation-open', id }, OPEN_TIMEOUT_MS, 'presentation-open'), + // Above the in-app deadlines it confirms against (2000ms modal dismissal, 4000ms control + // close), so a slow but successful unwind is never cut off by the transport. + content: (expected) => + command( + { type: 'presentation-content', expected }, + CONTENT_TIMEOUT_MS, + 'presentation-content' + ), + rollback: (level) => + command( + { type: 'presentation-rollback', level, wait: true }, + EXPLORE_UI_ROLLBACK_TIMEOUT_MS, + 'presentation-rollback' + ) + }, + // The same merged state/action set `presentation-setup` was given, so a binding resolved here + // names a site the device also knows about. + bind: (bindings, report) => + bindPresentationSites( + options.projectPath, + [...bindings], + { + states: [...catalog.states, ...(catalog.previewStates ?? []), ...(catalog.viewStates ?? [])], + actions: [...catalog.actions, ...(catalog.previews ?? [])] + }, + { + metroOrigin: `http://localhost:${options.device.metroPort}`, + ...(options.signal ? { signal: options.signal } : {}), + readSource: (file) => readFile(file, 'utf8').catch(() => undefined), + ...(report ? { report } : {}) + } + ), + focusedRoute: async () => { + const reply = await command({ type: 'nav-state' }, LIST_TIMEOUT_MS, 'nav-state') + const routes = reply?.routes ?? null + return routes?.length ? (routes[routes.length - 1]!.name ?? null) : null + }, + // The same settle the Fast delivery path waits on — a frame the app has stopped changing, + // with enough interactive elements to be the presentation rather than its transition. + settle: async () => { + await settleAfterRoute(options.ctx, options.device, options.signal, UI_STATE_SETTLE_TIMEOUT_MS, { + minInteractiveElements: 1 + }) + }, + screenshot: async (slug) => { + const path = getScreenshotPath(options.projectId, slug) + const { relaunched } = await captureScreenshot(options.ctx, options.device, path) + if (relaunched) { + throw new Error('The app restarted while this UI state was being photographed.') + } + return `screens/${slug}.png` + }, + emit: options.emit, + ...(options.signal ? { signal: options.signal } : {}) + } +} + +/** + * The sweep's session: the project's catalog (scanned once, here) bound to the sweep's own + * connection, device and ctx. Nothing here connects to anything — `runtime` is the connection the + * sweep already holds — and nothing here reads the device's focused route, because the sweep + * passes the node it is standing on. Throws a product sentence when there is nothing to capture. + */ +export async function openSweepUiStateSession( + options: Omit +): Promise { + const catalog = await buildPresentationCatalog(options.projectPath) + if (!catalog.actions.length && !(catalog.previews ?? []).length) { + throw new ExpectedOperationError('invalid_input', 'No UI states were found in this project’s source.') + } + return { catalog, ports: createExploreUiPorts({ ...options, catalog }) } +} + +/** + * Attach a connection to the app the user already launched. Throws with a product sentence when + * there is nothing to attach to — the panel shows it verbatim, so it says what to do next. + */ +async function connectToLaunchedApp( + options: ExploreUiRunOptions, + appName: string | null +): Promise { + const started = performance.now() + let connection: CdpConnection | null = null + try { + const targets = await listMetroTargets(options.device.metroPort, { signal: options.signal }) + const normalize = (value: string | null | undefined): string => + (value ?? '').toLowerCase().replace(/[^a-z\d]/g, '') + const app = normalize(appName) + const candidates = targets + .filter((target) => target.supportsMultipleDebuggers) + .filter( + (target) => + target.deviceId === options.device.deviceId || + (!!target.deviceName && + normalize(target.deviceName) === normalize(options.device.deviceName)) + ) + const target = + candidates.find( + (candidate) => + !app || normalize(candidate.title) === app || normalize(candidate.appId) === app + ) ?? (candidates.length === 1 ? candidates[0]! : null) + if (!target) { + throw new Error( + 'No running app could be reached through Metro. Launch the app from the App Flow screen first, then start UI explore.' + ) + } + connection = new CdpConnection({ + resolveTarget: createTargetResolver(options.device.metroPort, target), + sessionId: `explore-ui-${options.projectId}`, + log: (message) => console.log(`[app-flow] explore-ui runtime — ${message}`) + }) + await connection.connect() + return connection + } finally { + recordRuntimeTiming('app_flow.runtime.connect', performance.now() - started, { + outcome: connection?.state === 'ready' ? 'ready' : 'unavailable' + }) + } +} + +/** + * Run the pass against a live app and persist what it found. This is the standalone entry: it + * opens its own connection to whatever the user left running, and attributes the states to the + * screen the app reports as focused. (A screen sweep captures states through + * `openSweepUiStateSession` instead and persists them with the rest of the node's capture.) + * + * The variants are written onto the focused screen's existing capture (`AppFlowCapture.uiStates`); + * no node is minted, no edge is drawn, and the sweep's own fields are untouched. + */ +export async function runExploreUi(options: ExploreUiRunOptions): Promise { + const saved = await readAppFlow(options.projectId) + if (!saved) { + throw new Error( + 'This project has no saved App Flow yet. Build the flow first — UI states attach to screens the flow already captured.' + ) + } + options.emit({ type: 'phase', message: 'Reading UI states from the project’s source…' }) + const catalog = await buildPresentationCatalog(options.projectPath) + if (!catalog.actions.length && !(catalog.previews ?? []).length) { + throw new Error('No UI states were found in this project’s source.') + } + + const connection = await connectToLaunchedApp(options, options.ctx.appName) + const ports = createExploreUiPorts({ ...options, runtime: connection, catalog }) + + try { + const outcome = await exploreUiStates( + { catalog, nodes: saved.nodes, ...(options.limit === undefined ? {} : { limit: options.limit }) }, + ports + ) + if (outcome.nodeId) await persist(options.projectId, saved, outcome.nodeId, outcome.uiStates) + if (!outcome.rolledBack) { + options.emit({ + type: 'error', + message: + 'The app may still be showing a UI state this run opened. Close it in the app, or relaunch it from the App Flow screen.' + }) + } + for (const failure of outcome.discoveryFailures) { + options.emit({ type: 'step', message: `Could not open a UI state — ${failure.reason}` }) + } + return { + nodeId: outcome.nodeId, + captured: outcome.uiStates.filter((state) => state.screenshot).length, + failed: outcome.uiStates.filter((state) => !state.screenshot).length, + rolledBack: outcome.rolledBack, + cancelled: outcome.cancelled + } + } finally { + await connection.close().catch(() => undefined) + } +} + +/** + * Write the variants onto the node's existing capture. + * + * Replaces the node's `uiStates` wholesale rather than merging: a second run is a fresh statement + * about what this screen can open, and a stale variant whose source recipe is gone should not + * outlive the scan that stopped finding it. Nothing else on the capture is touched. + */ +async function persist( + projectId: string, + saved: AppFlowResult, + nodeId: string, + uiStates: AppFlowUiState[] +): Promise { + const nodes = saved.nodes.map((node) => { + if (node.id !== nodeId || !node.capture) return node + const capture: AppFlowCapture = { ...node.capture, uiStates } + return { ...node, capture } + }) + await writeAppFlow(projectId, { ...saved, nodes }) +} diff --git a/src/app-flow/explore-ui.ts b/src/app-flow/explore-ui.ts new file mode 100644 index 00000000..797eff42 --- /dev/null +++ b/src/app-flow/explore-ui.ts @@ -0,0 +1,784 @@ +/** + * The explore-UI pass: open every UI state a screen's own source proves it can open, photograph + * each one, and put the app back exactly as it was found. + * + * It is not a navigator. Nothing is navigated to, nothing is tapped, no model is asked anything, + * and no graph node is minted. The pass asks the already-running app which of the statically + * discovered presentations it can open *right now*, opens them one at a time by calling the app's + * own setter or controller, photographs the result through the same capture path the sweep uses, + * and rolls back. + * + * ## One per-screen primitive, two drivers + * + * `captureScreenUiStates` is the whole pass for ONE screen the device is standing on: collect the + * live bindings, list what can open, open/settle/photograph/roll back each, and unwind to level 0. + * Two callers share it so they cannot drift apart: + * + * - `exploreUiStates` — the standalone `mobile_appflow_explore_ui` run. The app is wherever the + * user left it, so the screen is *attributed* by asking the device which route is focused. + * - `createSweepUiStateCapturer` — the screen sweep's own "Capture UI states" option. The sweep + * already knows which node it is on (it just delivered that node's capture), so the node is + * passed in and nothing is re-derived. The sweep calls it once per delivered screen, then moves + * on; the states are *returned* to the orchestrator, which attaches them to that node's capture + * through the ordinary delivery path rather than this module writing storage. + * + * What is per project (and so done once per sweep) is the static catalog and the connection; what + * is per screen is the live-binding collection. `presentation-setup` binds the catalog to the + * *mounted* React tree inside a bounded patch window and un-patches afterwards, so a screen the + * sweep has only just navigated to has no bindings until the collect pass has run while it is + * mounted. That is why the setup command is part of the per-screen primitive and not of any + * once-per-sweep step. + * + * Everything that touches a device, Metro, the filesystem or the clock is a port, so the whole + * sequence — the limit, the attribution, the failure accounting and above all the rollback + * discipline — runs under `node --test` without a simulator. `explore-ui-run.ts` is the only file + * that implements these ports against real things. + * + * ## Rollback is the safety-critical part + * + * A projected app is a *broken* app from the user's point of view: a sheet they did not open, a + * modal they cannot dismiss, a screen replaced by a preview. Every exit from the per-screen + * primitive — the happy path, a thrown port, an aborted signal, a limit reached, a timeout — goes + * through the same `finally` that unwinds the runtime's undo stack to level 0, and a failed unwind + * stops any further presentation from being opened and is reported on the outcome + * (`rolledBack: false`). The in-app rollback carries its own deadlines (2000ms for a modal + * dismissal, 4000ms for a controller-owned close) and can throw on an iOS detached owner; those + * are honoured by giving the call its own timeout above them and by reporting a failed unwind + * rather than swallowing it. For the sweep this is doubly important: the next screen is reached + * and photographed from this very device, so a sheet left open would corrupt every later capture. + */ +import type { + AppFlowCapture, + AppFlowNode, + AppFlowPresentations, + AppFlowUiState +} from '../shared/app-flow.ts' +import { recordRuntimeAmount, recordRuntimeTiming } from './runtime-telemetry.ts' +import { routeAnswersTo } from './nav-route-target.ts' +import type { SweepUiStatesLog } from './reach/ports.ts' +import type { AppFlowBindingStats } from './presentations-bindings.ts' + +/** One live binding the in-app pass could not name a site for, as the agent reports it. */ +export interface ExploreUiUnboundBinding { + id: string + owner: string + /** `state` is a hook binding; `entry` is opener JSX matched through its creation stack. */ + kind: 'state' | 'entry' + stack: string + source?: { file: string; line: number; column: number } +} + +/** The four runtime commands this pass issues, behind one seam. */ +export interface ExploreUiRuntimePort { + /** `presentation-setup`: hand over the catalog and collect live bindings. */ + setup(catalog: Pick): Promise<{ + ok: boolean + bindings: number + matched: number + unbound?: ExploreUiUnboundBinding[] + /** Opener JSX records live in the app, and how many already name a catalog site. */ + entries?: number + entriesMatched?: number + error?: string + }> + /** `presentation-configure`: label bindings with the sites the host's join resolved. */ + configure( + matches: readonly { binding: string; site: string }[], + /** Records the join looked at and could not place; the device stops offering them. */ + checked?: readonly string[] + ): Promise<{ configured: number }> + /** `presentations`: what the device can actually open, as the tree stands now. */ + list(): Promise<{ + available: { id: string; kind: 'state' | 'control' | 'mount' }[] + unavailable: number + /** Why the unavailable ones were refused, as a count per closed-set reason. */ + rejected?: Record + /** Openers that reached a destination another listed opener already reaches, so not listed. */ + aliased?: number + /** What was listed, by kind. */ + kinds?: Record + }> + /** `presentation-open`. */ + open(id: string): Promise< + | { + ok: true + kind: 'state' | 'control' | 'mount' + checkpoint: number + /** The component this open expects to find drawing, probed by `content` after the settle. */ + expected?: string + } + | { ok: false; error: string; reason?: string; checkpoint: number } + > + /** + * `presentation-content`: is the component the open expected mounted, and does anything under it + * draw? A projection that renders nothing is photographed as a blank frame otherwise. + */ + content(expected: string): Promise<{ found: boolean; hosts: number; visible: number }> + /** `presentation-rollback` to `level`, confirming every native dismissal. */ + rollback(level: number): Promise<{ ok: boolean; error?: string; checkpoint: number; evidence?: string }> +} + +/** What `captureScreenUiStates` needs from the device and the host, for one screen. */ +export interface ExploreUiScreenPorts { + runtime: ExploreUiRuntimePort + /** + * The Metro symbolication join (`bindPresentationSites`). A failure here costs bindings, never + * the pass: an unresolved binding simply means one fewer openable presentation. + */ + bind( + bindings: readonly ExploreUiUnboundBinding[], + /** Receives the join's stage counts once it has run. */ + report?: (stats: AppFlowBindingStats) => void + ): Promise<{ binding: string; site: string }[]> + /** The Fast path's own settle/readiness wait, after a presentation has been opened. */ + settle(): Promise + /** Take one screenshot into `screens/.png`; resolves to the stored relative path. */ + screenshot(slug: string): Promise + /** Progress for the panel. Messages are product prose; no paths, no ids. */ + emit(event: { type: 'phase' | 'step' | 'error' | 'complete'; message: string; completed?: number; total?: number }): void + signal?: AbortSignal + now?(): number +} + +/** The standalone run additionally has to find out which screen it is on. */ +export interface ExploreUiPorts extends ExploreUiScreenPorts { + /** Which route the app is focused on right now — the last entry of the runtime's nav state. */ + focusedRoute(): Promise +} + +/** The screen a variant belongs to, as far as the per-screen primitive cares. */ +export type ExploreUiTarget = Pick + +export interface ExploreUiInput { + /** The static catalog, already assembled by `buildPresentationCatalog`. */ + catalog: AppFlowPresentations + /** The saved graph's nodes, for attributing variants to the focused screen. */ + nodes: readonly AppFlowNode[] + /** Capture at most this many UI states this run. Counted on variants actually captured. */ + limit?: number +} + +export interface ExploreUiOutcome { + /** The node every captured variant was attributed to, or null when none could be. */ + nodeId: string | null + /** The variants, successes and failures alike, in the order they were attempted. */ + uiStates: AppFlowUiState[] + /** Presentations the device could not open, with the reason a panel can show. */ + discoveryFailures: { id: string; reason: string }[] + /** Bindings still unresolved after the symbolication join. */ + unresolvedBindings: number + /** Whether the app was returned to the state it was found in. */ + rolledBack: boolean + cancelled: boolean +} + +/** The in-app rollback's own deadlines (2000ms modal, 4000ms control) bound this. */ +export const EXPLORE_UI_ROLLBACK_TIMEOUT_MS = 8_000 + +/** + * How many UI states one screen sweep captures in total. The user-editable limit the separate + * "Start UI Explore" run had is gone, but a large app (hundreds of sheets across dozens of + * screens, each one a settle plus a screenshot) must still not be able to turn a sweep into an + * hours-long run, so the old default survives as an internal ceiling applied across the whole run. + */ +export const EXPLORE_UI_SWEEP_LIMIT = 10 + +/** Closed set of reasons the device gives for refusing a presentation. Anything else is `other`. */ +const REFUSAL_REASONS = new Set([ + 'unbound', + 'not-in-view', + 'disabled', + 'no-owner', + 'ambiguous-owner', + 'no-control', + 'ambiguous-control', + 'not-loaded', + 'unsupported', + 'no-binding', + 'ambiguous-binding', + 'ambiguous-callback', + 'unmet-condition', + 'already-open', + // The reference's two `mountedExport` guards, and its readiness reasons for a projection that + // mounted nothing (`target`) or drew nothing (`empty`). + 'owner-mismatch', + 'already-mounted', + 'target', + 'empty' +]) + +function refusalReason(reason: string | undefined): string { + return reason !== undefined && REFUSAL_REASONS.has(reason) ? reason : 'other' +} + +/** A closed-set category for an unwind failure, from the device's message. Never the message. */ +export function rollbackFailureCategory(error: string | undefined): string { + if (!error) return 'unknown' + if (/has not finished/i.test(error)) return 'dismissal-timeout' + if (/unconfirmed/i.test(error)) return 'dismissal-unconfirmed' + if (/refused to close/i.test(error)) return 'close-refused' + if (/rollback failed|timed out|timeout/i.test(error)) return 'transport' + return 'other' +} + +/** + * A closed-set category for a failed pass: the stage it stopped in and whether the command timed + * out. A pass that fails before it lists anything has `candidates: 0`, which otherwise could not + * be told apart from a catalog with nothing in it. Never the message. + */ +export function passFailureCategory(stage: 'setup' | 'list' | 'open', error: string): string { + if (stage === 'open') return 'pass-failed' + return `${stage}-${/did not complete within|timed out|timeout/i.test(error) ? 'timeout' : 'failed'}` +} + +function aborted(signal?: AbortSignal): boolean { + return signal?.aborted === true +} + +/** `screens/.png` for one variant, deduped so two variants of one name cannot collide. */ +export function uiStateSlug(nodeSlug: string, actionId: string, used: Set): string { + const base = `${nodeSlug}--state-${actionId}`.replace(/[^a-zA-Z0-9]+/g, '_').replace(/_+/g, '_').toLowerCase() + let slug = base + let index = 2 + while (used.has(slug)) slug = `${base}_${index++}` + used.add(slug) + return slug +} + +/** + * The screen a variant belongs to, for the standalone run only. + * + * The app is wherever the user left it, so the only honest attribution is the route the navigator + * reports as focused — matched to a saved node through the same name identity `nav-route-target.ts` + * uses everywhere else (`id`, or the id's last segment). A focused route no saved node answers to + * is *not* minted as a node: a presentation is evidence about a screen, and inventing the screen it + * is evidence about would put a graph entry in front of the user that no sweep ever captured. The + * pass stops instead, with a reason. The sweep never needs this: it passes the node it is on. + */ +export function attributeToNode(nodes: readonly AppFlowNode[], routeName: string | null): AppFlowNode | null { + if (!routeName) return null + const named = nodes.filter((node) => node.origin !== 'chrome' && routeAnswersTo(node, routeName)) + if (named.length !== 1) return null + return named[0]! +} + +export interface ScreenUiStatesInput { + /** The static catalog, built once per project. */ + catalog: AppFlowPresentations + /** The screen the device is standing on. */ + node: ExploreUiTarget + /** Capture at most this many UI states on this screen. Counted on variants actually captured. */ + limit?: number + /** + * Screenshot slugs already taken this run. Shared across screens so two variants can never + * collide on a file; a fresh set is used when omitted. + */ + used?: Set +} + +export interface ScreenUiStatesOutcome { + /** The variants, successes and failures alike, in the order they were attempted. */ + uiStates: AppFlowUiState[] + /** Presentations the device could not open, with the reason a panel can show. */ + discoveryFailures: { id: string; reason: string }[] + /** Bindings still unresolved after this screen's symbolication join. */ + unresolvedBindings: number + /** Whether the app was returned to the state it was found in. False means a state may be stuck open. */ + rolledBack: boolean + cancelled: boolean + /** What the pass did, as counts and categories, for the run log. See `SweepUiStatesLog`. */ + diagnostics: SweepUiStatesLog + /** + * Set when the pass for this screen stopped on a failure (setup refused, the transport died, ...) + * rather than finishing. `uiStates` still holds whatever was captured before it. Never thrown: + * the sweep must not lose a screen to this, and the standalone run decides for itself. + */ + error?: string +} + +/** + * The pass for one screen: collect, list, then open each presentation, photograph it, roll it + * back. Returns what was learned; writing it down is the caller's job. + * + * The only thing this function guarantees about the device is that it leaves the undo stack at + * level 0 — or says it could not (`rolledBack: false`) — whatever happens. It does not throw on a + * port failure: the failure is on the outcome, after the unwind has run. + */ +export async function captureScreenUiStates( + input: ScreenUiStatesInput, + ports: ExploreUiScreenPorts +): Promise { + const clock = ports.now ?? (() => Date.now()) + const passStartedAt = clock() + const { node } = input + const uiStates: AppFlowUiState[] = [] + const discoveryFailures: { id: string; reason: string }[] = [] + const used = input.used ?? new Set() + // Held as one mutable object rather than as locals, because the rollback verdict is decided in + // the `finally` below — after any `return` expression would already have been evaluated. + const result: ScreenUiStatesOutcome = { + uiStates, + discoveryFailures, + unresolvedBindings: 0, + rolledBack: true, + cancelled: false, + diagnostics: { + candidates: 0, + listed: 0, + listedKinds: {}, + aliased: 0, + refusedAtList: {}, + opened: 0, + refusedAtOpen: {}, + captured: 0, + unbound: 0, + rolledBack: true, + latched: false + } + } + const diag = result.diagnostics + const noteUnwind = (unwound: { ok: boolean; error?: string; evidence?: string }) => { + if (unwound.evidence) diag.dismissEvidence = unwound.evidence + if (!unwound.ok) { + result.rolledBack = false + diag.rollbackFailure = rollbackFailureCategory(unwound.error) + } + } + let opened = false + // Where the pass was when it stopped, so a failure is told apart without keeping its message. + let stage: 'setup' | 'list' | 'open' = 'setup' + let outcome: 'completed' | 'failed' | 'cancelled' = 'completed' + + const actions = new Map(input.catalog.actions.map((action) => [action.id, action])) + const previews = new Map((input.catalog.previews ?? []).map((action) => [action.id, action])) + const recipe = (id: string) => actions.get(id) ?? previews.get(id) + + try { + ports.emit({ type: 'phase', message: 'Reading the app’s UI states…' }) + // Bindings are collected from the *mounted* tree, so this runs on every screen: a screen the + // device has only just arrived on has none until a collect pass has seen it mounted. Bindings + // that already have a site (from an earlier screen that is still mounted) are not re-listed + // as unbound, so the Metro join below only pays for what is new. + const setup = await ports.runtime.setup({ + states: [...input.catalog.states, ...(input.catalog.previewStates ?? []), ...(input.catalog.viewStates ?? [])], + actions: [...input.catalog.actions, ...(input.catalog.previews ?? [])] + }) + if (!setup.ok) throw new Error(setup.error ?? 'The app could not be prepared for UI exploration.') + + // The in-app pass can only attribute a binding whose owner is a candidate for exactly one + // site. Everything else needs Metro's source maps, which only the host can read. + const unbound = setup.unbound ?? [] + if (unbound.length) { + let joined = true + let stats: AppFlowBindingStats | undefined + const matches = await ports.bind(unbound, (reported) => { stats = reported }).catch(() => { + joined = false + return [] + }) + const resolved = new Set(matches.map((match) => match.binding)) + // Only a join that ran may declare a record unplaceable. A failed one (Metro unreachable, or + // any symbolication batch lost) leaves every record offered again on the next screen: a + // record whose frames were never looked up has not been judged. + const trusted = joined && !(stats && stats.failedBatches > 0) + const checked = trusted ? unbound.filter((binding) => !resolved.has(binding.id)).map((binding) => binding.id) : [] + let configured = 0 + if (matches.length || checked.length) { + configured = (await ports.runtime.configure(matches, checked).catch(() => undefined))?.configured ?? 0 + } + result.unresolvedBindings = unbound.filter((binding) => !resolved.has(binding.id)).length + diag.join = { + emittedState: unbound.filter((binding) => binding.kind === 'state').length, + emittedEntry: unbound.filter((binding) => binding.kind === 'entry').length, + entriesLive: setup.entries ?? 0, + entriesPlacedBefore: setup.entriesMatched ?? 0, + noFrames: stats?.noFrames ?? 0, + foreignFrames: stats?.foreignFrames ?? 0, + frames: stats?.frames ?? 0, + resolved: stats?.resolved ?? 0, + failedBatches: stats?.failedBatches ?? (joined ? 0 : 1), + projectFrames: stats?.projectFrames ?? 0, + placed: stats?.placed ?? 0, + entryPlaced: stats?.entryPlaced ?? 0, + configured + } + // The same bounded count per stage the join stopped in, once per screen's pass. + for (const [name, count] of [ + ['no-frames', diag.join.noFrames], + ['foreign-frames', diag.join.foreignFrames], + ['symbolicate-failed', diag.join.failedBatches] + ] as const) { + if (count > 0) recordRuntimeAmount('app_flow.runtime.presentation_unbound', count, { stage: name }) + } + } + diag.unbound = result.unresolvedBindings + recordRuntimeAmount('app_flow.runtime.presentation_unbound', result.unresolvedBindings, { + stage: 'configure' + }) + + stage = 'list' + const listed = await ports.runtime.list() + diag.listed = listed.available.length + diag.aliased = listed.aliased ?? 0 + diag.listedKinds = { ...(listed.kinds ?? {}) } + diag.candidates = listed.available.length + diag.aliased + listed.unavailable + diag.refusedAtList = { ...(listed.rejected ?? {}) } + const limit = input.limit ?? listed.available.length + ports.emit({ + type: 'phase', + message: `Capturing ${Math.min(limit, listed.available.length)} UI state(s) on this screen…`, + completed: 0, + total: Math.min(limit, listed.available.length) + }) + + stage = 'open' + for (const entry of listed.available) { + if (uiStates.filter((state) => state.screenshot).length >= limit) break + if (aborted(ports.signal)) { + result.cancelled = true + outcome = 'cancelled' + break + } + // A presentation that could not be closed leaves the app projected. Opening a second one on + // top of it would stack projections the user would have to find, and (in a sweep) corrupt + // every later capture, so the first failed unwind ends this screen's pass. + if (!result.rolledBack) break + const action = recipe(entry.id) + if (!action) { + discoveryFailures.push({ id: entry.id, reason: 'The device offered a presentation this catalog does not describe.' }) + continue + } + const openStartedAt = clock() + const openedState = await ports.runtime.open(entry.id) + if (!openedState.ok) { + const why = refusalReason(openedState.reason) + diag.refusedAtOpen[why] = (diag.refusedAtOpen[why] ?? 0) + 1 + recordRuntimeTiming('app_flow.runtime.presentation', clock() - openStartedAt, { + outcome: 'failed', + kind: entry.kind + }) + discoveryFailures.push({ id: entry.id, reason: openedState.error }) + uiStates.push({ + id: action.id, + name: action.name, + screenshot: null, + file: action.file, + line: action.line, + ...(action.views?.length ? { sourceViews: action.views } : {}), + error: openedState.error + }) + // A refused open still pushes nothing, but a *partial* one may have; unwind either way, + // and count a failed unwind exactly as the captured path does. + noteUnwind(await ports.runtime.rollback(0).catch(() => ({ ok: false, error: 'rollback failed' }))) + continue + } + opened = true + diag.opened += 1 + try { + await ports.settle() + // A projection that draws nothing is not a UI state. The reference refuses it in its + // readiness chain (reason `'empty'`/`'target'`); this port settles on device pressables, + // which cannot tell "renders nothing" from "still loading", so without this check a blank + // frame was photographed and reported as a capture. + if (openedState.expected) { + const content = await ports.runtime + .content(openedState.expected) + .catch(() => ({ found: true, hosts: 1, visible: 1 })) + if (!content.found || content.visible === 0) { + const why = content.found ? 'empty' : 'target' + diag.refusedAtOpen[why] = (diag.refusedAtOpen[why] ?? 0) + 1 + recordRuntimeTiming('app_flow.runtime.presentation', clock() - openStartedAt, { + outcome: 'failed', + kind: entry.kind + }) + const reason = content.found + ? 'This view renders nothing in this app state.' + : 'The expected component did not mount.' + discoveryFailures.push({ id: entry.id, reason }) + uiStates.push({ + id: action.id, + name: action.name, + screenshot: null, + file: action.file, + line: action.line, + ...(action.views?.length ? { sourceViews: action.views } : {}), + error: reason + }) + continue + } + } + const slug = uiStateSlug(node.slug, action.id, used) + const screenshot = await ports.screenshot(slug) + uiStates.push({ + id: action.id, + name: action.name, + screenshot, + file: action.file, + line: action.line, + ...(action.views?.length ? { sourceViews: action.views } : {}), + openedBy: openedState.kind + }) + recordRuntimeTiming('app_flow.runtime.presentation', clock() - openStartedAt, { + outcome: 'captured', + kind: entry.kind + }) + ports.emit({ + type: 'step', + message: `Captured ${action.name}.`, + completed: uiStates.filter((state) => state.screenshot).length, + total: Math.min(limit, listed.available.length) + }) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + recordRuntimeTiming('app_flow.runtime.presentation', clock() - openStartedAt, { + outcome: 'failed', + kind: entry.kind + }) + discoveryFailures.push({ id: entry.id, reason }) + uiStates.push({ + id: action.id, + name: action.name, + screenshot: null, + file: action.file, + line: action.line, + ...(action.views?.length ? { sourceViews: action.views } : {}), + error: reason + }) + } finally { + // Every opened presentation is closed before the next one is opened, so a failure can + // never leave a stack of projections on screen for the user to find. + noteUnwind(await ports.runtime.rollback(0).catch(() => ({ ok: false, error: 'rollback failed' }))) + } + } + + if (listed.unavailable) { + discoveryFailures.push({ + id: '', + reason: `${listed.unavailable} discovered UI state(s) could not be bound to anything live on this screen.` + }) + } + } catch (error) { + outcome = aborted(ports.signal) ? 'cancelled' : 'failed' + if (outcome === 'cancelled') result.cancelled = true + result.error = error instanceof Error ? error.message : String(error) + } finally { + // The one unconditional thing this module does. `opened` is not a guard on *whether* to + // unwind — a throw mid-open leaves a checkpoint the reply never reported — only on whether a + // failed unwind is worth reporting as a projected app. A pass that stopped on a failure counts + // as opened too: the throw may have landed between the app opening something and the reply + // saying so. + const unwound: { ok: boolean; error?: string; evidence?: string } = await ports.runtime + .rollback(0) + .catch(() => ({ ok: false, error: 'rollback failed' })) + if (unwound.evidence) diag.dismissEvidence = unwound.evidence + if (!unwound.ok && (opened || result.error !== undefined)) { + result.rolledBack = false + diag.rollbackFailure = rollbackFailureCategory(unwound.error) + } + // One bounded count per screen per closed-set reason, never one per presentation. + for (const [reason, count] of Object.entries(diag.refusedAtList)) { + recordRuntimeAmount('app_flow.runtime.presentation_refused', count, { stage: 'list', reason }) + } + for (const [reason, count] of Object.entries(diag.refusedAtOpen)) { + recordRuntimeAmount('app_flow.runtime.presentation_refused', count, { stage: 'open', reason }) + } + diag.rolledBack = result.rolledBack + diag.captured = uiStates.filter((state) => state.screenshot).length + if (result.error !== undefined) diag.failure = outcome === 'cancelled' ? 'cancelled' : passFailureCategory(stage, result.error) + recordRuntimeTiming('app_flow.runtime.presentation_pass', clock() - passStartedAt, { outcome }) + } + return result +} + +/** + * The standalone run: attribute the focused screen, then run the per-screen pass on it. + * + * Throws, as it always has, when the pass cannot start or stops on a failure — the panel shows the + * message verbatim. A failed unwind is folded into that message, because a thrown error would + * otherwise discard the one thing the user most needs to know. + */ +export async function exploreUiStates(input: ExploreUiInput, ports: ExploreUiPorts): Promise { + const route = await ports.focusedRoute() + const node = attributeToNode(input.nodes, route) + if (!node) { + throw new Error( + route + ? `The screen the app is on (${route}) is not one of this project’s captured screens, so its UI states have nowhere to attach. Build the flow first, then open a captured screen.` + : 'The app did not report a focused screen, so its UI states have nowhere to attach.' + ) + } + if (!node.capture) { + throw new Error('The screen the app is on has no capture yet. Build the flow first, then open a captured screen.') + } + const screen = await captureScreenUiStates( + { catalog: input.catalog, node, ...(input.limit === undefined ? {} : { limit: input.limit }) }, + ports + ) + if (screen.error !== undefined && !screen.cancelled) { + throw new Error( + screen.rolledBack + ? screen.error + : `${screen.error.replace(/[.\s]+$/, '')}. The app may still be showing a UI state this run opened.` + ) + } + return { + nodeId: node.id, + uiStates: screen.uiStates, + discoveryFailures: screen.discoveryFailures, + unresolvedBindings: screen.unresolvedBindings, + rolledBack: screen.rolledBack, + cancelled: screen.cancelled + } +} + +/** What a sweep needs to capture UI states: the catalog and the ports bound to its own device. */ +export interface SweepUiStateSession { + catalog: AppFlowPresentations + ports: ExploreUiScreenPorts +} + +export interface SweepUiStateCapturerDeps { + /** + * Builds the catalog and binds the ports to the sweep's own device, ctx and CDP connection. + * Called once, lazily, on the first screen that asks. Throws a product sentence when UI states + * cannot be captured at all (no runtime to attach to, no UI states in the source). + */ + open(): Promise + /** Progress for the sweep's own stream. Product prose only — no paths, ids or counts of states. */ + note(message: string): void + /** Total UI states to capture across the sweep. Defaults to `EXPLORE_UI_SWEEP_LIMIT`. */ + limit?: number + signal?: AbortSignal + /** Where a failure is reported, besides the sweep's own progress stream. */ + report?(error: unknown): void + /** + * One call per screen the pass ran on, with counts and categories only (`SweepUiStatesLog`). + * This is what makes a latched sweep diagnosable after the fact: it says whether presentations + * were refused, opened, or opened and then failed to close. + */ + onScreen?(node: ExploreUiTarget, log: SweepUiStatesLog): void +} + +/** + * The sweep-side driver: one instance per sweep, called once per delivered screen. + * + * It owns the three things that must outlive a single screen — the lazily opened session, the + * sweep-wide cap, and the shared screenshot-slug set — and nothing else. `capture` never throws + * and never fails the sweep: the worst outcome of any problem here is that a node gets no UI + * states (or states carrying their own `error`, which the graph already renders as a disabled + * thumbnail with the reason). + * + * It *latches off* on the two failures that make further attempts pointless or unsafe: a session + * that could not be opened, and an unwind that did not reach level 0. The second is the important + * one — the sweep reaches the next screen from this same device, so an app still showing a sheet + * would make every later capture wrong. The sweep carries on exploring; it simply stops presenting + * things, and says so once. + */ +export function createSweepUiStateCapturer(deps: SweepUiStateCapturerDeps) { + const limit = deps.limit ?? EXPLORE_UI_SWEEP_LIMIT + const used = new Set() + let session: SweepUiStateSession | null = null + let latched = false + let captured = 0 + + return { + /** UI states captured so far across the sweep. */ + get captured(): number { + return captured + }, + /** True once nothing further will be attempted (cap reached, or latched off by a failure). */ + get done(): boolean { + return latched || captured >= limit + }, + async capture(node: ExploreUiTarget): Promise { + if (latched || captured >= limit || aborted(deps.signal)) return undefined + try { + if (!session) { + const openStartedAt = performance.now() + try { + session = await deps.open() + } finally { + recordRuntimeTiming('app_flow.runtime.presentation_open_session', performance.now() - openStartedAt, { + outcome: session ? 'ready' : 'unavailable' + }) + } + } + const screen = await captureScreenUiStates( + { catalog: session.catalog, node, limit: limit - captured, used }, + session.ports + ) + captured += screen.uiStates.filter((state) => state.screenshot).length + if (!screen.rolledBack) screen.diagnostics.latched = true + try { + deps.onScreen?.(node, screen.diagnostics) + } catch { + // A log that cannot be written must not cost the sweep its screen. + } + if (!screen.rolledBack) { + // Reported, not swallowed: it is on the progress stream, the server log and the error + // reporter. Nothing further is opened, because the app may still be projected. + latched = true + deps.note( + 'UI states stopped: the app may still be showing one this sweep opened. Remaining screens will not capture UI states.' + ) + deps.report?.(new Error(screen.error ?? 'UI state rollback did not reach level 0.')) + } else if (screen.error !== undefined && !screen.cancelled) { + deps.report?.(new Error(screen.error)) + } + return screen.uiStates.length ? screen.uiStates : undefined + } catch (error) { + // `open` (or an unexpected bug) threw. Nothing was opened on the device by the session + // that failed to start, and the sweep must carry on without UI states. + latched = true + try { + deps.onScreen?.(node, { + candidates: 0, + listed: 0, + listedKinds: {}, + aliased: 0, + refusedAtList: {}, + opened: 0, + refusedAtOpen: {}, + captured: 0, + unbound: 0, + rolledBack: true, + latched: true, + failure: session ? 'capture-threw' : 'session-unavailable' + }) + } catch { + // See above. + } + if (!aborted(deps.signal)) { + deps.note( + `UI states unavailable — ${error instanceof Error ? error.message : String(error)}` + ) + deps.report?.(error) + } + return undefined + } + } + } +} + +export type SweepUiStateCapturer = ReturnType + +/** + * The sweep's delivery-point fold: the capture to record for a node, with its UI states attached + * when (and only when) the device is verifiably still on that screen. + * + * Only a delivery that *reached* the screen and stored a screenshot qualifies — an unreached or + * unavailable screen has nothing the device is standing on, and presenting things on whatever it + * happens to show would attribute them to the wrong node. The capture is returned, not mutated, + * and is the delivery's own object untouched when nothing was captured, so the sweep's other uses + * of `delivery.capture` (replay evidence, status) are unaffected. Never throws: see + * `createSweepUiStateCapturer`. Resolves only after the rollback to level 0, which is what lets the + * sweep advance from a clean app. + */ +export async function attachSweepUiStates( + capturer: SweepUiStateCapturer | null, + node: ExploreUiTarget, + delivery: { kind: string; capture: AppFlowCapture } +): Promise { + if (!capturer || delivery.kind !== 'reached' || !delivery.capture.screenshot) return delivery.capture + const uiStates = await capturer.capture(node) + return uiStates ? { ...delivery.capture, uiStates } : delivery.capture +} diff --git a/src/app-flow/hedge.ts b/src/app-flow/hedge.ts new file mode 100644 index 00000000..b16f1694 --- /dev/null +++ b/src/app-flow/hedge.ts @@ -0,0 +1,98 @@ +/** + * Re-issues a stalled model call instead of waiting it out. + * + * Measured across two instrumented sweeps, this backend's latency for these calls is tail-dominated + * rather than slow on average: the arrival judge's p50 is 2.1s and its p90 is 8.7s, but single calls + * reached 16.6s and 24.9s. The giveaway is what those calls returned — 165 and 130 output tokens, + * against a p50 call's ~140. They were not deliberating and they were not generating; they were + * waiting. Four such calls across one 39-call sweep cost ~81s, about 16% of the run's wall clock, + * and produced nothing a fast call would not have produced. + * + * A retry cannot recover that, because a retry needs the first attempt to fail and these attempts + * succeed — eventually. The only thing that converts "eventually" into p50 is a second attempt + * racing the first, which is what this does: start one, and if it has not answered within + * `afterMs`, start another and take whichever finishes first. + * + * Safe only for a call that can be run twice with no consequence. `runAgent`'s one-shot, `tools: []` + * judges qualify: one system prompt, one image, no tools, no session history, and a JSON verdict as + * the entire result. Session-backed calls do not, and the caller is where that line is drawn — see + * the guard at this module's one call site. Nothing here can check it. + */ + +export interface HedgeOutcome { + result: T + /** Attempts started, so a caller can bill the ones it did not use. 1 when no hedge fired. */ + attempts: number + /** Which attempt produced `result`, zero-based. 1 means the hedge won. */ + winner: number +} + +/** + * Runs `attempt(0)`, and if it has not settled within `afterMs`, runs `attempt(1)` alongside it and + * returns whichever fulfils first. + * + * `afterMs` of `Infinity` (or anything non-positive) disables hedging, which is how a caller turns + * this off without branching around it. + * + * A rejection does not end the race while the other attempt is still running. The whole premise is + * that one of these calls can be having a bad time independently of the other, and a hedge that + * surfaced the first error would throw away a good answer that was seconds away. Only when both + * have rejected does this reject, and with attempt 0's error: that is the call the caller actually + * asked for, and its failure is the one that describes the real problem rather than the duplicate's. + */ +export async function hedge(options: { + attempt: (index: number) => Promise + afterMs: number + /** Called when the hedge is issued, for logging. Never called when the first attempt wins. */ + onHedge?: () => void +}): Promise> { + const first = options.attempt(0) + if (!(options.afterMs > 0) || !Number.isFinite(options.afterMs)) { + return { result: await first, attempts: 1, winner: 0 } + } + + // Tagged so the race can tell a fulfilment from the timer firing without a sentinel value that a + // generic `T` could legitimately be. + const tagged = (promise: Promise, index: number): Promise<{ index: number; result: T }> => + promise.then((result) => ({ index, result })) + + let timer: ReturnType | undefined + const elapsed = new Promise<'elapsed'>((resolve) => { + timer = setTimeout(() => resolve('elapsed'), options.afterMs) + }) + + // A rejection here is the first attempt failing before the timer fired, and it propagates: there + // is no race left to run and no reason to pay for a duplicate, because a call that fails this + // fast is failing for a reason a second identical call will hit too. Only an *elapsed* timer + // means the attempt is still in flight and worth racing. + let firstOrTimer: { index: number; result: T } | 'elapsed' + try { + firstOrTimer = await Promise.race([tagged(first, 0), elapsed]) + } finally { + clearTimeout(timer) + } + if (firstOrTimer !== 'elapsed') { + return { result: firstOrTimer.result, attempts: 1, winner: 0 } + } + + options.onHedge?.() + const second = options.attempt(1) + // Attached before the race so that whichever promise loses cannot surface as an unhandled + // rejection after this function has already returned the winner's answer. + const settled = [tagged(first, 0), tagged(second, 1)].map((promise) => + promise.then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }) + ) + ) + + const firstSettled = await Promise.race(settled) + if (firstSettled.ok) { + return { result: firstSettled.value.result, attempts: 2, winner: firstSettled.value.index } + } + // One side has failed; the other is still the caller's best chance of an answer. + const both = await Promise.all(settled) + const won = both.find((outcome) => outcome.ok) + if (won?.ok) return { result: won.value.result, attempts: 2, winner: won.value.index } + throw both[0].ok ? new Error('Hedged call failed') : both[0].error +} diff --git a/src/app-flow/host.ts b/src/app-flow/host.ts new file mode 100644 index 00000000..30182374 --- /dev/null +++ b/src/app-flow/host.ts @@ -0,0 +1,44 @@ +import { join } from 'node:path' +import type { AppFlowProgressEvent, ManualRecordingEvent } from '../shared/app-flow.ts' +import type { AppFlowDecisionProvider } from './decision-provider.ts' + +export type AppFlowHostEvent = AppFlowProgressEvent | ManualRecordingEvent + +export interface AppFlowAgentDeviceModule { + createAgentDeviceClient(config?: Record): any +} + +/** Runtime environment supplied by the MCP server. */ +export interface AppFlowHostPorts { + dataDir: string + decisions?: AppFlowDecisionProvider + emit(event: AppFlowHostEvent): void + loadAgentDevice(): Promise + /** Shell-safe executable prefix for the vendored CLI (for example `node '/.../agent-device.mjs'`). */ + agentDeviceCli?: string + screenshotUri?(projectId: string, slug: string): string +} + +let configuredHost: AppFlowHostPorts | null = null + +export function configureAppFlowHost(host: AppFlowHostPorts): void { + configuredHost = host +} + +export function getAppFlowHost(): AppFlowHostPorts { + if (!configuredHost) throw new Error('App Flow host has not been configured.') + return configuredHost +} + +export function appFlowDataDir(): string { + return getAppFlowHost().dataDir +} + +export function appFlowAgentDeviceStateDir(): string { + return join(appFlowDataDir(), 'agent-device') +} + +export function resolveAgentDeviceCommand(command: string): string { + if (!command.startsWith('agent-device')) return command + return `${getAppFlowHost().agentDeviceCli ?? 'agent-device'}${command.slice('agent-device'.length)}` +} diff --git a/src/app-flow/index.ts b/src/app-flow/index.ts new file mode 100644 index 00000000..1df4e677 --- /dev/null +++ b/src/app-flow/index.ts @@ -0,0 +1,344 @@ +import type { + AppFlowProgressEvent, + AppFlowPromptResolution, + StartAppFlowInput +} from '../shared/app-flow.ts' +import { parseProject } from './parser.ts' +import { AppFlowBuilder } from './orchestrator.ts' +import { + bootDevice, + closeDevice, + waitForAppReady, + type CaptureContext, + type CaptureDevice +} from './capture.ts' +import { requiresDecisionProvider, resolveAppFlowStart } from './start-request.ts' +import { clearSavedAppFlow, getProjectId, readAppFlowForRenderer, readManifest } from './storage.ts' +import { configureAppFlowHost, type AppFlowHostPorts } from './host.ts' +import { runExploreUi } from './explore-ui-run.ts' + +const builders = new Map() +/** + * In-flight explore-UI passes, keyed by project. One per project at a time, and cancellable + * through the same `cancel` the panel already calls for a sweep. + */ +const exploring = new Map() +/** + * Apps the user launched from the App Flow screen, kept running for a later sweep to adopt. + * + * The context is kept beside the device because every adopter needs it and none of them can + * reconstruct it faithfully: it names the agent-device session the device's client was opened + * under, and a second context built from `readAppConfig` would be a different session that + * `closeDevice` would then shut down out from under this one. The UI-state pass used to rebuild + * it from `parseProject` on every call; it now borrows this one and adds its own signal. + */ +const launchedApps = new Map() +const launching = new Set() + +export function getAppFlowBuilders(): Map { + return builders +} + +/** + * The apps the user has launched and left running, for the surfaces that can work on one instead + * of opening a device of their own — the sweep (through `launchedDevice`), the UI-state pass, and + * the manual path recorder. Read-only by convention: `launchApp` owns every entry and is the only + * thing that closes one. + */ +export function getLaunchedApps(): ReadonlyMap { + return launchedApps +} + +export function createAppFlowService(host: AppFlowHostPorts): { + getStatus: ( + projectPath: string + ) => Promise<{ exists: boolean; projectId: string; manifest?: unknown; error?: string }> + getResult: (projectPath: string) => Promise + clear: (projectPath: string) => Promise<{ cleared: true; projectId: string }> + start: (options: StartAppFlowInput) => Promise<{ ok: true } | { ok: false; error: string }> + /** + * `expected: true` marks a refusal that is a normal state of the surface rather than a fault — + * a second click while the first is still launching, or a project with no routes to open. The + * host still raises it so the panel shows it, but it is not worth an exception report. + */ + launchApp: ( + projectPath: string + ) => Promise<{ ok: true } | { ok: false; error: string; expected?: boolean }> + isAppLaunched: (projectPath: string) => Promise + /** + * Open and photograph the UI states of the screen the already-launched app is on. Non-blocking: + * it resolves as soon as the pass is under way, and reports through the same event stream. + */ + exploreUi: ( + projectPath: string, + options?: { limit?: number } + ) => Promise<{ ok: true } | { ok: false; error: string; expected?: boolean }> + pause: (projectPath: string) => Promise<{ paused: boolean }> + cancel: (projectPath: string) => Promise<{ cancelled: boolean }> + resolvePrompt: ( + projectPath: string, + resolution: AppFlowPromptResolution, + promptId?: string + ) => Promise<{ resolved: boolean }> +} { + configureAppFlowHost(host) + return { + getStatus: async (projectPath: string) => { + const projectId = getProjectId(projectPath) + const manifest = await readManifest(projectId) + return { exists: manifest !== null, projectId, manifest: manifest ?? undefined } + }, + getResult: async (projectPath: string) => { + const projectId = getProjectId(projectPath) + return readAppFlowForRenderer(projectId) + }, + clear: async (projectPath: string) => { + const projectId = getProjectId(projectPath) + if (builders.get(projectId)?.isRunning() || exploring.has(projectId) || launching.has(projectId)) { + throw new Error('Stop the active App Flow operation before clearing its saved data.') + } + await clearSavedAppFlow(projectId) + return { cleared: true, projectId } + }, + launchApp: async (projectPath: string) => { + const projectId = getProjectId(projectPath) + if (builders.get(projectId)?.isRunning()) { + return { ok: false, error: 'App flow build already in progress.', expected: true } + } + if (launching.has(projectId)) { + return { ok: false, error: 'App is already launching.', expected: true } + } + launching.add(projectId) + try { + const parsed = await parseProject(projectPath) + if (!parsed) { + // No project path in the message: this reaches the panel and, through the tool's error + // channel, the error reporter. The user already knows which project they picked. + return { + ok: false, + error: + 'No supported Expo Router or React Navigation routes were found in the selected project.', + expected: true + } + } + const previous = launchedApps.get(projectId) + if (previous) { + launchedApps.delete(projectId) + await closeDevice(previous.device) + } + const ctx = { + projectPath, + projectId, + scheme: parsed.scheme, + appName: parsed.appName, + bundleId: parsed.bundleId, + slug: parsed.slug, + platform: 'ios' as const, + sessionName: projectId + } + const device = await bootDevice(ctx) + try { + await waitForAppReady(ctx, device) + } catch (error) { + await closeDevice(device) + throw error + } + launchedApps.set(projectId, { device, ctx }) + return { ok: true } + } catch (error) { + console.error('[app-flow] launch failed:', error) + return { ok: false, error: error instanceof Error ? error.message : 'Unknown error' } + } finally { + launching.delete(projectId) + } + }, + isAppLaunched: async (projectPath: string) => launchedApps.has(getProjectId(projectPath)), + exploreUi: async (projectPath: string, options?: { limit?: number }) => { + const projectId = getProjectId(projectPath) + if (builders.get(projectId)?.isRunning()) { + return { ok: false, error: 'App flow build already in progress.', expected: true } + } + if (exploring.has(projectId)) { + return { ok: false, error: 'UI explore is already running for this project.', expected: true } + } + // The app must already be up: this pass opens states on the screen the user left it on, and + // relaunching would throw that screen away before anything could be captured. + const launched = launchedApps.get(projectId) + if (!launched) { + return { + ok: false, + error: 'Launch the app from the App Flow screen first, then start UI explore.', + expected: true + } + } + const device = launched.device + const parsed = await parseProject(projectPath) + if (!parsed) { + return { + ok: false, + error: + 'No supported Expo Router or React Navigation routes were found in the selected project.', + expected: true + } + } + const controller = new AbortController() + exploring.set(projectId, controller) + const ctx = { ...launched.ctx, signal: controller.signal } + const emit = (event: { + type: 'phase' | 'step' | 'error' | 'complete' + message: string + completed?: number + total?: number + }) => { + host.emit({ ...event, phase: 'capture', projectId }) + } + void runExploreUi({ + projectPath, + projectId, + ctx, + device, + ...(options?.limit === undefined ? {} : { limit: options.limit }), + signal: controller.signal, + emit + }) + .then((result) => { + emit({ + type: 'complete', + message: result.cancelled + ? 'UI explore cancelled.' + : `Captured ${result.captured} UI state(s)${result.failed ? `, ${result.failed} could not be opened` : ''}.`, + completed: result.captured + }) + }) + .catch((error: unknown) => { + emit({ + type: 'error', + message: error instanceof Error ? error.message : 'Unknown error' + }) + }) + .finally(() => { + exploring.delete(projectId) + }) + return { ok: true } + }, + start: async (input) => { + // Defaults live in `start-request.ts` so they are testable without Electron. + const { + projectPath, + mode, + screenLimit, + explorationMode, + deepLinkJudgeMode, + captureUiStates, + judgeUnclearScreens + } = resolveAppFlowStart(input) + // Same narrowing as `buildAppFlow`'s own check, and for the same reason: Fast asks no model + // anything it cannot do without (see the throw there), so refusing to start it when the host + // has no decision provider would be this surface inventing a requirement the sweep no longer + // has. Every other mode is model-driven end to end and is still refused here, where the user + // can be told before a simulator boots. + if (requiresDecisionProvider(mode, explorationMode, judgeUnclearScreens) && !host.decisions) { + return { ok: false, error: 'App Flow decision provider is not available.' } + } + + const projectId = getProjectId(projectPath) + let builder = builders.get(projectId) + if (builder?.isRunning()) { + return { ok: false, error: 'App flow build already in progress.' } + } + + if (launching.has(projectId)) return { ok: false, error: 'App is still launching.' } + const launchedDevice = launchedApps.get(projectId)?.device + + builder = new AppFlowBuilder() + builders.set(projectId, builder) + + builder.on('event', (event: AppFlowProgressEvent) => { + host.emit(event) + }) + + builder.on('done', () => { + console.log(`[app-flow] build done (${projectId})`) + builders.delete(projectId) + }) + + builder.on('error', (result: { error: string }) => { + console.error(`[app-flow] build error (${projectId}): ${result.error}`) + // buildAppFlow already emits an `error` event for every failure it handles, but this is + // the last line of defence: the renderer must never be left spinning on a dead build. + host.emit({ + type: 'error', + phase: 'render', + message: result.error, + projectId + }) + builders.delete(projectId) + }) + + console.log(`[app-flow] start requested for ${projectPath} (${projectId})`) + // Run without awaiting so the IPC call returns immediately. + void builder + .start({ + projectPath, + mode, + decisionProvider: host.decisions, + screenLimit, + explorationMode, + deepLinkJudgeMode, + captureUiStates, + judgeUnclearScreens, + launchedDevice + }) + .catch((error: unknown) => { + console.error('[app-flow] builder.start rejected:', error) + builders.delete(projectId) + host.emit({ + type: 'error', + phase: 'render', + message: error instanceof Error ? error.message : 'Unknown error', + projectId + }) + }) + return { ok: true } + }, + resolvePrompt: async ( + projectPath: string, + resolution: AppFlowPromptResolution, + promptId?: string + ) => { + const projectId = getProjectId(projectPath) + const builder = builders.get(projectId) + const resolved = builder?.resolvePrompt(resolution, promptId) ?? false + console.log( + `[app-flow] prompt resolution "${resolution}" for ${projectId} — ${resolved ? 'delivered' : 'nothing waiting'}` + ) + return { resolved } + }, + pause: async (projectPath: string) => { + const projectId = getProjectId(projectPath) + const builder = builders.get(projectId) + console.log( + `[app-flow] pause requested for ${projectId} — ${builder ? 'builder found' : 'no running builder'}` + ) + if (!builder) return { paused: false } + builder.pause() + return { paused: true } + }, + cancel: async (projectPath: string) => { + const projectId = getProjectId(projectPath) + const explore = exploring.get(projectId) + if (explore) { + explore.abort() + console.log(`[app-flow] cancel requested for ${projectId} — UI explore aborting`) + return { cancelled: true } + } + const builder = builders.get(projectId) + console.log( + `[app-flow] cancel requested for ${projectId} — ${builder ? 'builder found' : 'no running builder'}` + ) + if (!builder) return { cancelled: false } + builder.cancel() + return { cancelled: true } + } + } +} diff --git a/src/app-flow/launcher-deferral.ts b/src/app-flow/launcher-deferral.ts new file mode 100644 index 00000000..83f07b65 --- /dev/null +++ b/src/app-flow/launcher-deferral.ts @@ -0,0 +1,35 @@ +/** + * How long to let the dev-client launcher clear on its own before recovering from it. + * + * `startReplayRecording` arms a recording with an `apps.open --saveScript`, and the launcher + * recovery in `waitForAppReady` clears the launcher with an `apps.open` of its own — which + * agent-device treats as "a second successful open" and refuses to publish the script after. The + * deferral exists so that a launcher which is merely slow (a cold bundle build) is waited out + * rather than recovered from, keeping the recording publishable. + * + * That bet is only worth making while it can still be won. On the measured sweep it was not: the + * deferral fired on six separate nodes, ran its full 20s every single time, and every one of those + * six still ended with `This script recording was aborted by a second successful open and cannot + * be published` — because the launcher in that setup is pointed at no Metro, so nothing but the + * reconnect was ever going to clear it. 131s of waiting bought six publication failures that + * happened anyway, on a sweep that was 75s slower than the one before it. + * + * So the deferral is kept, and spent once. The first time it elapses with the launcher still on + * screen, that is the evidence that this device's launcher does not self-clear, and every later + * call recovers immediately. A launcher that *does* clear within the window never records an + * expiry, so a genuinely slow bundle keeps being waited out for the whole sweep. + * + * Deliberately per-device and per-run rather than a constant: "does the launcher clear itself" + * is a property of how this dev client was started, not of the harness, and a device that is + * reconnected to Metro between sweeps should get the benefit of the doubt again. + */ +export type LauncherDeferralOutcome = + /** No deferral has elapsed on this device yet — either none ran, or one ran and the launcher cleared. */ + | 'untested' + /** A deferral ran its full length and the launcher was still on screen at the end of it. */ + | 'expired' + +export function launcherDeferralMs(configuredMs: number, outcome: LauncherDeferralOutcome): number { + if (!(configuredMs > 0)) return 0 + return outcome === 'expired' ? 0 : configuredMs +} diff --git a/src/app-flow/link-harvest.ts b/src/app-flow/link-harvest.ts new file mode 100644 index 00000000..5e971455 --- /dev/null +++ b/src/app-flow/link-harvest.ts @@ -0,0 +1,83 @@ +/** + * Turning the destinations a screen's link elements name into param records. + * + * ## The gap this closes + * + * `ParamStore.best()` only answers with a record covering *every* param a node declares, and + * nothing merges two partial records into one. That is a deliberate safety property, not an + * oversight: two values harvested from two different controls are not known to co-occur, and a + * `name` from one row paired with an `rkey` from another is an invented URL that resolves to + * somebody else's post. So the two accessibility learners refuse to produce multi-param records at + * all — `ingestPressables` requires every param to come out of one identifier, the affinity + * bootstrap is single-param only, and `learnFromArrival` bails on a node declaring more than one. + * + * The consequence, measured on a 78-screen sweep of a real social app: 16 of 19 unreached screens + * were blocked purely on unresolved params, and nine of those are `name` + `rkey` shaped + * (`/profile/:name/post/:rkey` and its siblings). Both halves were demonstrably on screen the whole + * time — the feed resolves `name` from a `profileCard--link` testID and the rows carry the + * rkey — and the store could never put them together. + * + * ## Why a link prop is the right evidence + * + * An `href`/`to` prop is a destination the *app itself* composed. Its `name` and `params` arrived + * as one object, from one row, naming one record. There is no co-occurrence question to answer and + * so no new precedence rule to get wrong: the candidate goes straight into `ingestNavRoute` or + * `ingestUrl`, which are the paths navigator state already uses, and every existing guard + * (`addObserved`'s shape baseline, the unsafe-value rejections, `demote`) applies unchanged. + * + * It also sees what neither other source can. Navigator state knows only routes the app has + * *entered*; the accessibility harvest knows only values that reached a testID. A feed row's link + * names its destination before anybody presses it. + * + * This module is the host half: the walk lives in `cdp/agent-runtime.ts` (`agentCollectLinks`) and + * is pure data by the time it arrives here, so the matching below is testable without a device. + */ + +import type { LinkCandidate } from './cdp/agent-runtime.ts' +import { resolveNavRouteTarget } from './nav-route-target.ts' +import type { ParamRouteNode, ParamStore } from './param-store.ts' + +/** What this module needs from a parsed node: `ParamRouteNode` plus the file the bridge uses. */ +export interface LinkHarvestNode extends ParamRouteNode { + file: string | null +} + +/** Bounded counts only — never a param value, a route name or a path. Safe to meter and log. */ +export interface LinkHarvestResult { + /** Candidates the store accepted a record from. */ + usable: number + /** Candidates that named a destination no param-declaring node answers to. */ + unmatched: number +} + +/** + * Feeds harvested destinations into the store, and reports only how many landed. + * + * A candidate may carry both a path and a `screen`/`params` pair; both are offered, because the + * path matches by template (which can resolve a route the navigator never registered under that + * name) and the pair matches by route identity (which works for a route with no URL at all). The + * store deduplicates, so offering both costs nothing beyond two lookups. + * + * A name is only ever resolved through `resolveNavRouteTarget`, so an ambiguous name — two nodes + * answering to it — attaches to neither, exactly as a navigator-state read does. + */ +export function ingestLinkCandidates( + links: readonly LinkCandidate[], + seenOn: string, + nodes: readonly T[], + store: ParamStore +): LinkHarvestResult { + let usable = 0 + let unmatched = 0 + for (const link of links) { + let landed = false + if (link.path && store.ingestUrl(link.path, seenOn, nodes)) landed = true + if (link.name && link.params) { + const target = resolveNavRouteTarget(nodes, link.name) + if (target && store.ingestNavRoute(target, link.params, seenOn)) landed = true + } + if (landed) usable += 1 + else unmatched += 1 + } + return { usable, unmatched } +} diff --git a/src/app-flow/manual/commands.ts b/src/app-flow/manual/commands.ts new file mode 100644 index 00000000..f712d557 --- /dev/null +++ b/src/app-flow/manual/commands.ts @@ -0,0 +1,29 @@ +/** + * The literal `agent-device …` lines a manual recording falls back to when its recording cannot be + * published as a `.ad` script. + * + * Pure, and deliberately free of any Electron or device import, so the round trip that actually + * matters — that `reachStartScreen` can parse and execute every line this produces — is testable + * under `node --test`. A fallback path nothing can replay is worth nothing. + */ +import type { ManualLocator } from './locators.ts' + +export { quoteCliArg } from '../cli-args.ts' + +/** + * The command builders themselves now live in `../walk-commands.ts`, which the sweep's own + * `pathToReach` emission shares — the manual recorder and the explorer write the same lines for + * the same reasons, and keeping two spellings of `agent-device press` was how they would drift. + * Re-exported from here so every existing import of this module is unchanged. + */ +export { fillCommand, pressCommand, pressPointCommand, scrollCommand } from '../walk-commands.ts' + +/** + * Whether a step survives a rebuild of the app. + * + * A `text=` locator breaks on any copy change and a coordinate breaks on any layout change, so the + * panel marks both so the user can choose to re-record against something sturdier. + */ +export function isPortable(locator: ManualLocator | null): boolean { + return locator !== null && locator.by !== 'text' +} diff --git a/src/app-flow/manual/context.ts b/src/app-flow/manual/context.ts new file mode 100644 index 00000000..91da580a --- /dev/null +++ b/src/app-flow/manual/context.ts @@ -0,0 +1,27 @@ +/** + * The `CaptureContext` every manual-mode device operation runs under — recording and replay alike. + * + * Shared so the two cannot drift on the one field that matters: `sessionName`. It is deliberately + * not the bare `projectId` the sweep uses, because `closeDevice` ends with + * `sessions.close({ shutdown: true })` and on a shared name that would tear down a runner the sweep + * is holding. + */ +import type { CaptureContext } from '../capture.ts' +import { readAppConfig } from '../parser.ts' + +export async function manualCaptureContext( + projectPath: string, + projectId: string +): Promise { + const config = await readAppConfig(projectPath) + return { + projectPath, + projectId, + scheme: config.scheme, + appName: config.name, + bundleId: config.bundleId, + slug: config.slug, + platform: 'ios', + sessionName: `${projectId}-manual` + } +} diff --git a/src/app-flow/manual/index.ts b/src/app-flow/manual/index.ts new file mode 100644 index 00000000..73d3387e --- /dev/null +++ b/src/app-flow/manual/index.ts @@ -0,0 +1,371 @@ +/** + * The manual-recording service — the IPC-facing wrapper around `ManualSession`, built the same way + * `createAppFlowService` wraps `AppFlowBuilder`: a map keyed by project id, events broadcast to the + * renderer, and work started without awaiting so the IPC call returns immediately. + */ +import type { ManualRecordingAction, ManualRecordingEvent } from '../../shared/app-flow.ts' +import { bootDevice, closeDevice, type CaptureContext, type CaptureDevice } from '../capture.ts' +import { isMetroReady } from '../../server/app-flow-deps/metro/port.ts' +import { getAppFlowBuilders, getLaunchedApps } from '../index.ts' +import { getProjectId, readAgentDeviceScreens } from '../storage.ts' +import { manualCaptureContext } from './context.ts' +import { runPathToReach } from '../path-run.ts' +import { runtimeReplay } from '../runtime-replay.ts' +import { ManualSession, type PreparedManualDevice } from './session.ts' +import { configureAppFlowHost, type AppFlowHostPorts } from '../host.ts' + +const sessions = new Map() + +/** + * The in-flight `start()` of each session, so a discard can wait for it to unwind. + * + * `start` is deliberately not awaited — a cold start is a native build, a simulator boot and a + * Metro cold start, and the renderer follows along on the event channel instead of blocking on a + * tool call for minutes. The cost is that a discard arriving during those minutes returns while + * the boot is still running, `isActive` goes false, and the sweep the user starts next walks + * straight past the guard into a device that is still being built. Holding the promise here lets + * `cancel` answer only once the start has seen the discard and released whatever it acquired + * (`ManualSession.abandonIfFinished`). + */ +const starting = new Map>() + +/** + * Devices this module opened and is keeping warm, keyed by project id. + * + * Two kinds of caller park one here, for the same reason. Replay exists to put the app on a screen + * and leave it there, so it cannot close its own device — `closeDevice` shuts the agent-device + * session down, and the app under test goes with the runner. A finished recording is the same + * situation seen from the other end: the user has just saved a path and, far more often than not, + * the next thing they do is record a path to another screen, and closing the device between the + * two meant paying a simulator boot and a Metro cold start again for a device that was healthy. + * + * So a device is parked rather than closed, reused by whatever starts next on this project, and + * closed when it goes stale, when its idle window expires, or when a sweep needs the runner. + */ +const warmDevices = new Map() + +/** + * How long a parked device waits for the next recording before closing itself. + * + * It holds a booted simulator, an agent-device session and a Metro process, and nothing else can + * reap them: the panel can be closed, the project switched, or the user can simply walk away, and + * none of those reach this module. Ten minutes is long enough to cover "record the next screen" + * — the case the parking exists for — and short enough that a forgotten session is not still + * holding the iOS runner an hour later. + */ +const WARM_DEVICE_IDLE_MS = 10 * 60 * 1000 + +async function closeWarmDevice(projectId: string): Promise { + const parked = warmDevices.get(projectId) + if (!parked) return + warmDevices.delete(projectId) + clearTimeout(parked.timer) + await closeDevice(parked.device).catch((error: unknown) => { + console.error('[app-flow:manual] could not close the parked device:', error) + }) +} + +function parkWarmDevice(projectId: string, device: CaptureDevice, ctx: CaptureContext): void { + const timer = setTimeout(() => { + console.log('[app-flow:manual] parked device idle — closing it') + void closeWarmDevice(projectId) + }, WARM_DEVICE_IDLE_MS) + // Never hold the process open for a device nobody is waiting on. + timer.unref?.() + warmDevices.set(projectId, { device, ctx, timer }) +} + +/** Whether the dev server a device was opened against is still answering on its port. */ +async function metroAlive(device: CaptureDevice): Promise { + if (device.launchProcess && device.launchProcess.exitCode !== null) return false + return device.metroPort > 0 ? isMetroReady(device.metroPort) : false +} + +/** + * The device the next recording should run on, cheapest first. + * + * 1. The app the user launched from the App Flow screen. This is the case the panel's "Record + * path" button hits most often — the user has the app up, they can see the screen they want a + * path to — and reusing it skips the native build, the boot and Metro entirely. It is owned by + * `launchApp`, so it is borrowed and never closed here. + * 2. A device a previous recording or replay on this project parked, which is what makes the + * second "Record path" of a sitting cheap after the first one paid for the boot. + * 3. Nothing, meaning the session boots its own. + * + * Every reuse is probed first. A stale device is strictly worse than a slow one: the app would + * relaunch against a dev server that is gone and every step of the recording would fail minutes + * later, where a fresh boot just works. + */ +async function prepareDevice(projectId: string): Promise { + const launched = getLaunchedApps().get(projectId) + if (launched) { + if (await metroAlive(launched.device)) { + // Our own parked device is redundant while a launched one is up, and holding both means two + // simulators and two Metro processes for one project. + await closeWarmDevice(projectId) + return { device: launched.device, ctx: launched.ctx, source: 'launched' } + } + console.log('[app-flow:manual] the launched app’s dev server is gone — not adopting it') + } + + const parked = warmDevices.get(projectId) + if (!parked) return null + warmDevices.delete(projectId) + clearTimeout(parked.timer) + if (!(await metroAlive(parked.device))) { + console.log('[app-flow:manual] parked device’s dev server is gone — closing it and booting fresh') + await closeDevice(parked.device).catch(() => undefined) + return null + } + try { + // Idempotent, and the cheapest proof that the simulator is still there and the agent-device + // session can be re-established: the same call `bootDevice` makes on a cold start. + await parked.device.client.devices.boot({ + platform: 'ios', + target: 'mobile', + udid: parked.device.deviceId + }) + } catch (error) { + console.log(`[app-flow:manual] parked device did not answer (${String(error)}) — booting fresh`) + await closeDevice(parked.device).catch(() => undefined) + return null + } + return { device: parked.device, ctx: parked.ctx, source: 'warm' } +} + +export function createManualRecordingService(host: AppFlowHostPorts): { + isActive: (projectPath: string) => boolean + /** + * Close any device this module is keeping warm for a project. + * + * Called before a sweep, a launch or a UI-state pass starts: a parked agent-device session holds + * the iOS runner lease, and the next thing to want it fails with "iOS runner is already owned by + * another agent-device daemon". Parking buys a cheaper second recording, never at the price of + * the surface beside it. + */ + releaseDevices: (projectPath: string) => Promise + start: ( + projectPath: string, + nodeId: string + ) => Promise<{ ok: true } | { ok: false; error: string }> + act: ( + projectPath: string, + action: ManualRecordingAction + ) => Promise<{ ok: true } | { ok: false; error: string }> + stop: (projectPath: string) => Promise<{ ok: true } | { ok: false; error: string }> + cancel: (projectPath: string) => Promise<{ cancelled: boolean }> + replay: ( + projectPath: string, + nodeId: string + ) => Promise<{ ok: true } | { ok: false; error: string }> + /** + * The other replay variant — see `runtime-replay.ts`. It lives on this service rather than on one + * of its own because what a replay actually needs from here is the device ladder: the app the + * user launched, then a device a previous recording or replay parked, then a boot. Giving runtime + * replay a second pool would mean two simulators and two Metro processes for one project, which + * is the thing `prepareDevice` exists to avoid. + */ + replayRuntime: ( + projectPath: string, + nodeId: string + ) => Promise<{ ok: true; detail: string } | { ok: false; error: string }> +} { + configureAppFlowHost(host) + return { + isActive: (projectPath: string) => sessions.has(getProjectId(projectPath)), + releaseDevices: (projectPath: string) => closeWarmDevice(getProjectId(projectPath)), + start: async (projectPath: string, nodeId: string) => { + const projectId = getProjectId(projectPath) + // A sweep and a manual session would fight over the same simulator and the same `.ad` files. + if (getAppFlowBuilders().get(projectId)?.isRunning()) { + return { ok: false, error: 'An App Flow build is already running for this project.' } + } + if (sessions.has(projectId)) { + return { ok: false, error: 'A recording is already in progress for this project.' } + } + + const prepared = await prepareDevice(projectId) + const session = new ManualSession(projectPath, projectId, nodeId, { + prepared, + /** + * A device the user launched goes back untouched — `launchApp` owns it, and closing it + * here would pull the app out from under the panel's Launch state. One this module opened + * is parked for the next recording unless the session failed, where its state is unknown + * and closing is the only safe answer. + */ + release: async (device, ctx, outcome) => { + if (outcome.source === 'launched') return + if (outcome.reason === 'failed' || !ctx) { + await closeDevice(device) + return + } + parkWarmDevice(projectId, device, ctx) + } + }) + sessions.set(projectId, session) + session.on('event', (event: ManualRecordingEvent) => { + host.emit(event) + if (event.type === 'complete' || event.type === 'error') sessions.delete(projectId) + }) + + console.log(`[app-flow:manual] start ${nodeId} for ${projectPath} (${projectId})`) + // Not awaited: booting a simulator and launching the app takes minutes, and the renderer + // follows along on the event channel. Tracked in `starting` so a discard can still wait for + // it — see that map's comment. + const pending = session + .start() + .catch((error: unknown) => { + console.error('[app-flow:manual] start rejected:', error) + sessions.delete(projectId) + return session.failed(error) + }) + .finally(() => { + if (starting.get(projectId) === pending) starting.delete(projectId) + }) + starting.set(projectId, pending) + return { ok: true } + }, + + act: async (projectPath: string, action: ManualRecordingAction) => { + const session = sessions.get(getProjectId(projectPath)) + if (!session) return { ok: false, error: 'No recording in progress for this project.' } + try { + await session.act(action) + return { ok: true } + } catch (error) { + // An action that fails is not a failed session — a mistyped field or a stale element should + // leave the user on the device, able to try again. + return { ok: false, error: error instanceof Error ? error.message : 'Interaction failed.' } + } + }, + + stop: async (projectPath: string) => { + const projectId = getProjectId(projectPath) + const session = sessions.get(projectId) + if (!session) return { ok: false, error: 'No recording in progress for this project.' } + try { + await session.stop() + return { ok: true } + } catch (error) { + console.error('[app-flow:manual] stop failed:', error) + await session.failed(error) + return { + ok: false, + error: error instanceof Error ? error.message : 'Could not save the recording.' + } + } finally { + sessions.delete(projectId) + } + }, + + cancel: async (projectPath: string) => { + const projectId = getProjectId(projectPath) + const session = sessions.get(projectId) + if (!session) return { cancelled: false } + try { + // `cancel` first, so `finished` is set before the wait below: that is what makes an + // in-flight `start` release the device it is mid-way through acquiring instead of + // finishing into a session nobody holds. + await session.cancel().catch((error: unknown) => { + console.error('[app-flow:manual] cancel failed:', error) + }) + await starting.get(projectId)?.catch(() => undefined) + } finally { + // Deleted only once the device is genuinely released. Dropping it up front made + // `isActive` false while the teardown was still running, so a sweep started straight + // after a discard passed the host's guard and then collided with a live agent-device + // session that still held the iOS runner lease. + sessions.delete(projectId) + } + return { cancelled: true } + }, + + /** + * Walks a screen's stored `pathToReach` and leaves the app sitting on it. + * + * Awaited rather than fired like `start`: there is no panel to follow along on, the renderer + * just wants to know whether it worked, and the one line of feedback it needs is the error when + * it did not. The whole call is a couple of minutes on a cold device, which `ipcMain.handle` + * carries fine. + */ + replay: async (projectPath: string, nodeId: string) => { + const projectId = getProjectId(projectPath) + if (getAppFlowBuilders().get(projectId)?.isRunning()) { + return { ok: false, error: 'An App Flow build is already running for this project.' } + } + if (sessions.has(projectId)) { + return { ok: false, error: 'A recording is in progress for this project.' } + } + + const screens = await readAgentDeviceScreens(projectId) + const entry = Object.values(screens).find((screen) => screen.screenId === nodeId) + if (!entry?.pathToReach.length) { + return { ok: false, error: 'This screen has no recorded path to replay yet.' } + } + + console.log( + `[app-flow:manual] replay ${nodeId} for ${projectPath} (${projectId})` + + // Said out loud before the device is booted, because this is the one place that knows + // which path is about to run and a failure two minutes from now reads very differently + // depending on the answer. `ModerationInbox`'s stored path was reconstructed from a + // chrome reveal and was never travelled; `My-Profile`'s was walked. See + // `AppFlowCapture.pathVerified`. + (entry.pathVerified === false ? ' — inferred path, never travelled' : '') + + (entry.pathContentSelectors?.length + ? ` — matches on rendered content: ${entry.pathContentSelectors.join(', ')}` + : '') + ) + // Same ladder as a recording's: the app the user launched, then a device a previous + // recording parked, then a boot of our own. + const prepared = await prepareDevice(projectId) + const ctx = prepared?.ctx ?? (await manualCaptureContext(projectPath, projectId)) + const device = prepared?.device ?? (await bootDevice(ctx)) + // Parked before the walk, not after: if a step throws, the device is still up and still owns + // a Metro process, and the next start is the only thing that will ever clean it up. A device + // the user launched is not ours to park — `launchApp` already holds it. + if (prepared?.source !== 'launched') parkWarmDevice(projectId, device, ctx) + try { + await runPathToReach(ctx, device, entry.pathToReach) + return { ok: true } + } catch (error) { + console.error('[app-flow:manual] replay failed:', error) + return { + ok: false, + error: error instanceof Error ? error.message : 'Could not replay this screen.' + } + } + }, + + /** + * Navigates to a screen through the app's own runtime, with no recording involved. + * + * Same guards and the same device ladder as `replay` above, for the same reasons — a sweep and + * this would fight over one simulator, and a recording in progress owns the device outright. + * Everything past the device is `runtime-replay.ts`; this is the part that has to live beside + * the pool. + */ + replayRuntime: async (projectPath: string, nodeId: string) => { + const projectId = getProjectId(projectPath) + if (getAppFlowBuilders().get(projectId)?.isRunning()) { + return { ok: false as const, error: 'An App Flow build is already running for this project.' } + } + if (sessions.has(projectId)) { + return { ok: false as const, error: 'A recording is in progress for this project.' } + } + + console.log(`[app-flow:runtime-replay] ${nodeId} for ${projectPath} (${projectId})`) + return await runtimeReplay(projectId, nodeId, { + prepare: async () => { + const prepared = await prepareDevice(projectId) + const ctx = prepared?.ctx ?? (await manualCaptureContext(projectPath, projectId)) + const device = prepared?.device ?? (await bootDevice(ctx)) + // Parked before the dispatch, not after: if it throws, the device is still up and still + // owns a Metro process, and the next start is the only thing that will ever clean it up. + // A device the user launched is not ours to park — `launchApp` already holds it. + if (prepared?.source !== 'launched') parkWarmDevice(projectId, device, ctx) + return { ctx, device } + } + }) + } + } +} diff --git a/src/app-flow/manual/locators.ts b/src/app-flow/manual/locators.ts new file mode 100644 index 00000000..4692f2e4 --- /dev/null +++ b/src/app-flow/manual/locators.ts @@ -0,0 +1,56 @@ +/** + * Locator derivation for the manual recorder. + * + * Pure — no device, no Electron — so the two rules that decide whether a manual recording can be + * published at all are testable under `node --test` without a simulator. + */ +import type { ManualSnapshotNode, ManualSnapshotRect } from '../../shared/app-flow.ts' +import type { AppFlowDestinationLocator } from '../capture.ts' + +export type ManualLocator = { by: 'text' | 'label' | 'id'; value: string } + +/** + * How a clicked element becomes a selector, in descending order of durability. + * + * `identifier` (a `nativeID`/accessibility id) survives copy changes and localisation; `label` is + * next; `value` (the rendered text) is the last resort and is the only one that cannot guard a + * saved recording — see `destinationLocator`. Returns null for a node carrying none of the three, + * which the caller presses by coordinate instead. + */ +export function locatorFor(node: ManualSnapshotNode): ManualLocator | null { + const identifier = node.identifier?.trim() + if (identifier) return { by: 'id', value: identifier } + const label = node.label?.trim() + if (label) return { by: 'label', value: label } + const value = node.value?.trim() + if (value) return { by: 'text', value } + return null +} + +/** + * The landmark that guards the published recording, picked off the screen the session ended on. + * + * agent-device's `sessions.saveScript` refuses to publish an active-session script unless, after + * the last mutating action, there is a `wait` on a selector whose matched element carries a + * re-verifiable identity — the "portable destination guard". Only an id or an accessibility label + * qualifies, which is why `by: 'text'` is excluded here exactly as it is from + * `AppFlowDestinationLocator`. Null means the session has to fall back to literal commands. + */ +export function destinationLocator( + nodes: readonly ManualSnapshotNode[] +): AppFlowDestinationLocator | null { + for (const node of nodes) { + const identifier = node.identifier?.trim() + if (identifier) return { by: 'id', value: identifier } + } + for (const node of nodes) { + const label = node.label?.trim() + if (label) return { by: 'label', value: label } + } + return null +} + +/** The point to press when a node carries no selector at all. */ +export function rectCenter(rect: ManualSnapshotRect): { x: number; y: number } { + return { x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) } +} diff --git a/src/app-flow/manual/path-commands.ts b/src/app-flow/manual/path-commands.ts new file mode 100644 index 00000000..88522d66 --- /dev/null +++ b/src/app-flow/manual/path-commands.ts @@ -0,0 +1,88 @@ +/** + * Reading a `pathToReach` back: the inverse of `commands.ts`, plus the two lines a sweep writes. + * + * `reachStartScreen` ([profiling/device.ts](../../profiling/device.ts)) already does this for + * profiling, but it reaches for a shell for anything that is not `replay` or `open`, and it needs a + * `ProfilingContext` to do it. The Replay button has neither, so it dispatches in process against + * the `CaptureDevice` the recorder already uses — which means the literal `press`/`fill`/`scroll` + * lines a fallback recording produces have to be understood here rather than handed to `/bin/zsh`. + * + * Pure, and free of Electron and device imports, so every form `commands.ts` can emit is round + * tripped in a unit test. Anything unrecognised comes back `null` rather than being guessed at: the + * caller reports the line it could not run, which is far better than silently skipping a step and + * leaving the user on the wrong screen wondering why. + */ +import { tokenizeAgentDeviceCommand } from '../../server/app-flow-deps/profiling/agent-device-command.ts' + +export type PathStep = + /** `boot` and `install` — the device is already up by the time a path runs. */ + | { kind: 'skip' } + | { kind: 'replay'; path: string } + | { kind: 'open'; app: string; url: string | null } + | { kind: 'press'; selector: string } + | { kind: 'pressPoint'; x: number; y: number } + | { kind: 'fill'; selector: string; value: string } + | { kind: 'scroll'; direction: 'up' | 'down' | 'left' | 'right' } + +const SCROLL_DIRECTIONS = new Set(['up', 'down', 'left', 'right']) + +/** + * The positional arguments of a command, with flags and their values removed. + * + * `--settle` takes no value while `--timeout 5000` does, and nothing in the token stream says + * which is which. The rule matches `parseAgentDeviceOpen`'s: a flag swallows the next token unless + * that token is itself a flag, which is right for every flag these commands carry. + */ +function positionals(tokens: readonly string[], from: number): string[] { + const values: string[] = [] + for (let i = from; i < tokens.length; i++) { + const token = tokens[i] + if (!token.startsWith('--')) { + values.push(token) + continue + } + if (!token.includes('=') && tokens[i + 1] && !tokens[i + 1].startsWith('--')) i++ + } + return values +} + +export function parsePathStep(command: string): PathStep | null { + const tokens = tokenizeAgentDeviceCommand(command.trim()) + if (tokens[0] !== 'agent-device') return null + const verb = tokens[1] + const args = positionals(tokens, 2) + + if (verb === 'boot' || verb === 'install') return { kind: 'skip' } + + if (verb === 'replay') { + // `replay run ` is the long form of `replay `. + const path = args[0] === 'run' ? args[1] : args[0] + return path ? { kind: 'replay', path } : null + } + + if (verb === 'open') { + return args[0] ? { kind: 'open', app: args[0], url: args[1] ?? null } : null + } + + if (verb === 'press') { + // `press ` — two numbers, as `pressPointCommand` writes it. + if (args.length >= 2 && args.every((arg) => /^-?\d+(\.\d+)?$/.test(arg))) { + return { kind: 'pressPoint', x: Number(args[0]), y: Number(args[1]) } + } + return args[0] ? { kind: 'press', selector: args[0] } : null + } + + if (verb === 'fill') { + // An empty string is a legitimate value — clearing a field — so only a missing one is a parse + // failure, which is why this tests the count rather than the truthiness of `args[1]`. + return args.length >= 2 && args[0] ? { kind: 'fill', selector: args[0], value: args[1] } : null + } + + if (verb === 'scroll') { + return args[0] && SCROLL_DIRECTIONS.has(args[0]) + ? { kind: 'scroll', direction: args[0] as 'up' | 'down' | 'left' | 'right' } + : null + } + + return null +} diff --git a/src/app-flow/manual/session.ts b/src/app-flow/manual/session.ts new file mode 100644 index 00000000..23331a32 --- /dev/null +++ b/src/app-flow/manual/session.ts @@ -0,0 +1,656 @@ +/** + * A manual, agent-device-assisted recording session — the third way an App Flow screen can acquire + * a `pathToReach`, alongside the parser-only mode (which produces none) and the LLM sweep. + * + * The user drives their own app through a live wireframe of the device while this module issues + * every tap as a real agent-device interaction inside an *active recording*. That is the whole + * trick: the session is opened with `apps.open({ saveScript })`, so the app launch is the first + * recorded step and each interaction appends itself, and on stop the recording publishes to + * `/.ad` — exactly the path `savedReplayStrategy` probes first. A screen recorded here + * is therefore replayed ahead of any deep link or exploration by a later sweep, with no change to + * the sweep or the reach ladder. + * + * No model is consulted anywhere in this file. + */ +import { EventEmitter } from 'node:events' +import { rename, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import type { + AppFlowNode, + AppFlowPressable, + ManualRecordingAction, + ManualRecordingEvent, + ManualRecordingStatus, + ManualSnapshotNode, + ManualSnapshotRect +} from '../../shared/app-flow.ts' +import { captureSnapshotFrame } from '../../server/app-flow-deps/profiling/snapshot.ts' +import { + agentDeviceOpenCommand, + bootDevice, + capturePressables, + captureScreenshot, + closeDevice, + endLiveSession, + pressWithLocator, + replayPathToReach, + saveAndReplayRecording, + startReplayRecording, + waitForRoute, + type CaptureContext, + type CaptureDevice +} from '../capture.ts' +import { serializeLocator } from '../exploration.ts' +import { manualCaptureContext } from './context.ts' +import { defaultScreenInfo, uniqueScreenKey } from '../screen-keys.ts' +import { + getFlowsDir, + getScreenshotPath, + readAgentDeviceScreens, + readAppFlow, + registerProject, + writeAgentDeviceScreens, + writeAppFlow +} from '../storage.ts' +import { fillCommand, isPortable, pressCommand, pressPointCommand, scrollCommand } from './commands.ts' +import { recordRuntimeCount, recordRuntimeTiming } from '../runtime-telemetry.ts' +import { contentMatchedSelectors } from '../path-minimize.ts' +import { destinationLocator, locatorFor, rectCenter } from './locators.ts' +import { errorMessage } from '../../shared/protocol.ts' + +/** + * How long the wireframe rests between refreshes while the user is deciding what to tap. + * + * Measured from the *end* of one snapshot to the start of the next, not on a fixed interval. A + * read of the accessibility tree is itself most of a second on a dense screen, and a fixed 1s + * interval would fire the next tick while the previous one still held the agent-device session: + * every one of those is dropped by the `busy` guard, so the panel would refresh no faster while + * the user's next tap waited behind an in-flight read (`waitForIdle`). Resting a second between + * reads keeps the wireframe about as fresh as the device can report it and still leaves a window + * for the tap, which is the interaction that actually matters. + */ +const SNAPSHOT_REST_MS = 1_000 + +function log(step: string, detail?: string): void { + console.log(detail ? `[app-flow:manual] ${step} — ${detail}` : `[app-flow:manual] ${step}`) +} + +/** + * Where the device a session runs on came from, which is also who owns it afterwards. + * + * `launched` is the app the user started from the App Flow screen — the sweep already adopts it + * (`AppFlowBuilder.start`'s `launchedDevice`), and the recorder now does too, so pressing "Record + * path" on a running app costs a relaunch rather than a build, a boot and a Metro cold start. + * `warm` is a device a previous recording on this project left parked. `booted` is this session's + * own, and the only one `closeDevice` may ever be called on by default. + */ +export type ManualDeviceSource = 'launched' | 'warm' | 'booted' + +/** A device acquired before the session was constructed, with the context it was opened under. */ +export interface PreparedManualDevice { + device: CaptureDevice + ctx: CaptureContext + source: Exclude +} + +/** + * What a finished session does with its device instead of always closing it. + * + * `done` is a stop or a discard — the device is healthy and worth keeping for the next recording. + * `failed` is a session that threw, where the device's state is unknown and closing is the only + * safe answer. + */ +export type ReleaseManualDevice = ( + device: CaptureDevice, + ctx: CaptureContext | null, + outcome: { source: ManualDeviceSource; reason: 'done' | 'failed' } +) => Promise + +export class ManualSession extends EventEmitter { + private readonly projectPath: string + private readonly projectId: string + private readonly nodeId: string + private readonly prepared: PreparedManualDevice | null + private readonly release: ReleaseManualDevice + private deviceSource: ManualDeviceSource = 'booted' + private device: CaptureDevice | null = null + private ctx: CaptureContext | null = null + private node: AppFlowNode | null = null + private candidatePath = '' + private publishedPath = '' + /** The literal `agent-device …` lines, in order, used when the recording cannot be published. */ + private readonly literalCommands: string[] = [] + private latestSnapshot: ManualSnapshotNode[] = [] + private revision = 0 + private pollTimer: NodeJS.Timeout | null = null + /** + * Serializes the poll against user actions. A snapshot taken while a press is mid-flight reports + * a half-transitioned screen, and two overlapping snapshots race the same agent-device session. + */ + private busy = false + private finished = false + + constructor( + projectPath: string, + projectId: string, + nodeId: string, + options: { prepared?: PreparedManualDevice | null; release?: ReleaseManualDevice } = {} + ) { + super() + this.projectPath = projectPath + this.projectId = projectId + this.nodeId = nodeId + this.prepared = options.prepared ?? null + this.release = options.release ?? ((device) => closeDevice(device)) + } + + /** Which device this session is running on — read by the service for its own bookkeeping. */ + get source(): ManualDeviceSource { + return this.deviceSource + } + + private emitEvent(event: ManualRecordingEvent): void { + try { + this.emit('event', event) + } catch (error) { + console.error('Manual recording event listener error:', error) + } + } + + private status(status: ManualRecordingStatus, message?: string): void { + this.emitEvent({ + type: 'status', + projectId: this.projectId, + nodeId: this.nodeId, + status, + ...(message ? { message } : {}) + }) + } + + async start(): Promise { + this.status('starting', 'Resolving screen') + const result = await readAppFlow(this.projectId) + const node = result?.nodes.find((candidate) => candidate.id === this.nodeId) ?? null + if (!node) throw new Error('Screen is not in this project’s app flow. Generate the flow first.') + if (node.origin === 'chrome') { + throw new Error('A navigator surface is not a screen — there is nothing to record a path to.') + } + this.node = node + + const startedAt = Date.now() + this.ctx = this.prepared?.ctx ?? (await manualCaptureContext(this.projectPath, this.projectId)) + + // Creates `flows/` and `screens/` if a previous build left them missing, and refreshes the + // registry's `lastOpenedAt` — the user is working on this project right now. + await registerProject(this.projectPath) + this.candidatePath = join(getFlowsDir(this.projectId), `.${node.slug}.manual.candidate.ad`) + this.publishedPath = join(getFlowsDir(this.projectId), `${node.slug}.ad`) + + if (this.prepared) { + // The expensive half of a start — the native build, the simulator boot and Metro's cold + // start — has already been paid for by whoever opened this device. Skipping it is the whole + // point of handing one in; the relaunch below is still taken, because the recorded script's + // first line has to be `open --relaunch` for it to replay from any state. + this.deviceSource = this.prepared.source + this.device = this.prepared.device + log('start', `reusing the ${this.prepared.source} device — no build, boot or Metro start`) + this.status( + 'starting', + this.prepared.source === 'launched' ? 'Reusing the app you launched' : 'Reusing the running app' + ) + } else { + this.deviceSource = 'booted' + this.status('starting', 'Booting simulator') + this.device = await bootDevice(this.ctx) + } + // Discard can land here: a cold start is a native build, a simulator boot and a Metro cold + // start — minutes during which the panel's × is live, and `cancel` ran while `this.device` + // was still null, so its `teardown` found nothing to release. Without this check the device + // that finished booting afterwards belonged to nobody: the session was already out of + // `sessions`, so `isActive` was false, and nothing was in `warmDevices`, so `releaseDevices` + // closed nothing — while the agent-device session kept the iOS runner lease and the next + // sweep failed with "iOS runner ... is already owned by another agent-device daemon". + if (await this.abandonIfFinished()) return + + this.status('starting', 'Launching app') + // Opens with `saveScript`, so the launch itself is the first line of the recording. This is + // what makes "the app launch is always part of pathToReach" true for both output shapes: the + // published script replays it, and the literal fallback reproduces it as `agent-device open`. + await startReplayRecording(this.ctx, this.device, this.candidatePath, this.device.launchUrl) + // The same check again, because the launch and the `--saveScript` arming are their own wait: + // a transaction left armed on a handed-on device suppresses every later `goHome` and makes + // agent-device refuse to arm the next recording over it. `abandonIfFinished` ends it. + if (await this.abandonIfFinished()) return + + this.status('recording') + // One measurement per started recording, with the device's provenance as a closed-set + // attribute: this is the series that says whether reuse is actually happening and what it + // saves. No screen, node, route or device identity rides on it. + recordRuntimeTiming('app_flow.manual.start', Date.now() - startedAt, { + device: this.deviceSource + }) + await this.tick() + this.schedulePoll() + } + + /** + * Releases a device `start` acquired after the session was already discarded, and says whether + * it did. + * + * `cancel` and `failed` can both run while `start` is still booting, and `teardown` is a no-op + * when `this.device` is null — which it is for the whole expensive half of a start. So the + * checkpoints in `start` call this after every step that can leave something behind, and it + * performs the half of the discard that was skipped: end the live `--saveScript` transaction, + * drop the candidate script, hand the device to the service's `release`. + * + * It releases through the same `teardown('done')` an ordinary discard uses, so a device booted + * for a recording nobody wants is parked for the next session rather than thrown away — the + * boot has been paid for either way, and `releaseDevices` closes it before any sweep. + */ + private async abandonIfFinished(): Promise { + if (!this.finished) return false + log('start', 'discarded while starting — releasing the device it had already acquired') + if (this.device) await endLiveSession(this.device).catch(() => undefined) + await unlink(this.candidatePath).catch(() => undefined) + await this.teardown('done') + return true + } + + /** + * Waits out an in-flight snapshot poll before taking the session. + * + * Bounded rather than an open loop: a snapshot has its own 15s timeout, so anything past that is + * a wedged call, and the user's tap is worth more than the stale tick blocking it. + */ + private async waitForIdle(timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs + while (this.busy && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } + + /** One wireframe refresh. Silently skipped while another device call holds the session. */ + private async tick(): Promise { + if (this.busy || this.finished || !this.device || !this.ctx) return + this.busy = true + try { + const frame = await captureSnapshotFrame(this.device, { + platform: this.ctx.platform, + timeoutMs: 15_000 + }) + this.latestSnapshot = frame.nodes as ManualSnapshotNode[] + this.revision += 1 + this.emitEvent({ + type: 'snapshot', + projectId: this.projectId, + nodeId: this.nodeId, + revision: this.revision, + nodes: this.latestSnapshot, + bounds: frame.bounds as ManualSnapshotRect + }) + } catch (error) { + // A dropped poll is not a failed session: the next tick two seconds later usually succeeds, + // and tearing the recording down over one busy runner would throw away the user's walk. + log('snapshot tick failed', errorMessage(error)) + } finally { + this.busy = false + } + } + + async act(action: ManualRecordingAction): Promise { + if (this.finished) throw new Error('This recording has already finished.') + const device = this.device + const ctx = this.ctx + if (!device || !ctx) throw new Error('Recording is still starting up.') + await this.waitForIdle() + + this.busy = true + this.status('acting') + const startedAt = Date.now() + try { + const step = await this.perform(ctx, device, action) + this.literalCommands.push(step.command) + this.emitEvent({ + type: 'step', + projectId: this.projectId, + nodeId: this.nodeId, + command: step.command, + portable: step.portable + }) + await waitForRoute() + this.status('recording') + recordRuntimeTiming('app_flow.manual.act', Date.now() - startedAt, { + action: action.type, + outcome: 'ok' + }) + } catch (error) { + // A failed interaction is not a failed session, and the panel must say so: without this the + // status stayed on `acting` forever and the header read "Sending the command…" over a + // session that had already given up, with no way to tell it apart from a slow tap. The + // session is still live and the user can try another element — which is exactly what + // `recording` means. + recordRuntimeTiming('app_flow.manual.act', Date.now() - startedAt, { + action: action.type, + outcome: 'failed' + }) + this.status('recording') + throw error + } finally { + this.busy = false + // Both outcomes, and in a `finally` rather than after the try: a failed interaction rethrows + // from the catch above, and re-arming the poll anywhere the throw can skip would freeze the + // wireframe for the rest of the session — the self-rescheduling poll has no standing + // interval to fall back on. A failed tap can still have moved the app, so the refresh is + // worth taking on that path too. + // + // Refreshing now rather than waiting out the rest: agent-device `@ref`s expire after any + // action, and the user is looking at a wireframe of the screen they just left. The poll's + // rest restarts from here, so the queued tick cannot land on top of this one. + await this.tick() + this.schedulePoll() + } + } + + private async perform( + ctx: CaptureContext, + device: CaptureDevice, + action: ManualRecordingAction + ): Promise<{ command: string; portable: boolean }> { + if (action.type === 'scroll') { + await device.client.interactions.scroll({ + platform: ctx.platform, + direction: action.direction + }) + return { command: scrollCommand(action.direction), portable: true } + } + + const locator = locatorFor(action.target) + if (!locator) { + const rect = action.target.rect + if (!rect) throw new Error('That element has neither a selector nor a position to press.') + const point = rectCenter(rect) + if (action.type === 'fill') { + throw new Error('That field has no id or label to type into reliably.') + } + await device.client.interactions.press({ + platform: ctx.platform, + x: point.x, + y: point.y, + settle: true, + timeoutMs: 10_000 + }) + // Coordinates are recorded faithfully but do not survive a layout or device-size change, so + // the panel marks this step as the weak one it is. + return { command: pressPointCommand(point.x, point.y), portable: false } + } + + const selector = serializeLocator(locator) + if (action.type === 'fill') { + await device.client.interactions.fill({ + platform: ctx.platform, + selector, + text: action.value, + settle: true, + timeoutMs: 10_000 + }) + return { + command: fillCommand(selector, action.value), + portable: isPortable(locator) + } + } + + await pressWithLocator(ctx, device, locator) + return { command: pressCommand(selector), portable: isPortable(locator) } + } + + /** + * Ends the walk and writes the artifacts. + * + * Tries hardest first: wait on a durable landmark, publish the recording, verify it by replaying + * it, and hand back a `pathToReach` that is one `agent-device replay`. Everything that can go + * wrong there falls back to the literal command list rather than failing the session — the user + * has already done the work, and a weaker path is worth far more than none. + */ + async stop(): Promise { + if (this.finished) return + const device = this.device + const ctx = this.ctx + const node = this.node + if (!device || !ctx || !node) throw new Error('Recording is still starting up.') + await this.waitForIdle() + this.finished = true + this.stopPolling() + this.status('saving') + + // The screen's own artifacts come first, while the app is still standing on the screen the user + // walked it to. Everything below relaunches: `saveAndReplayRecording` closes the session and + // re-runs the whole script to prove it replays, so by the time it returns the app has been + // through a cold launch and whatever the verification left on screen is what a screenshot would + // catch. Both calls are observation-only, which agent-device excludes from the recording by + // default, so taking them inside the still-open session does not add a step to the script. + const pressables = await this.collectPressables(ctx, device) + const screenshot = await this.collectScreenshot(ctx, device, node) + + let pathToReach: string[] = [] + let replayScript: string | null = null + let replayVerified = false + let fallbackReason: string | undefined + + const destination = destinationLocator(this.latestSnapshot) + if (!destination) { + fallbackReason = + 'This screen has no element with an id or accessibility label, so agent-device cannot guard a saved recording here.' + } else { + try { + await saveAndReplayRecording(ctx, device, this.candidatePath, serializeLocator(destination)) + await rename(this.candidatePath, this.publishedPath) + replayScript = this.publishedPath + replayVerified = true + pathToReach = replayPathToReach(device, this.publishedPath) + } catch (error) { + fallbackReason = `The recording could not be published or verified: ${errorMessage(error)}` + await unlink(this.candidatePath).catch(() => undefined) + } + } + + if (!replayVerified) { + await unlink(this.candidatePath).catch(() => undefined) + pathToReach = [ + ...device.setupCommands, + // `device.launchTarget` verbatim, exactly as `resolveDeepLink` passes it — the launch + // target is already the resolved bundle id by the time `bootDevice` returns. + agentDeviceOpenCommand(device.launchTarget, device.launchUrl ?? null), + ...this.literalCommands + ] + } + + await this.persist(node, { pathToReach, replayScript, replayVerified, pressables, screenshot }) + + this.emitEvent({ + type: 'complete', + projectId: this.projectId, + nodeId: this.nodeId, + pathToReach, + replayVerified, + ...(fallbackReason ? { fallbackReason } : {}) + }) + recordRuntimeCount('app_flow.manual.stop', { + outcome: replayVerified ? 'verified' : 'fallback', + device: this.deviceSource + }) + await this.teardown('done') + } + + private async collectPressables( + ctx: CaptureContext, + device: CaptureDevice + ): Promise { + try { + return await capturePressables(ctx, device) + } catch (error) { + log('pressables failed', errorMessage(error)) + return [] + } + } + + private async collectScreenshot( + ctx: CaptureContext, + device: CaptureDevice, + node: AppFlowNode + ): Promise { + const path = getScreenshotPath(this.projectId, node.slug) + try { + await captureScreenshot(ctx, device, path) + return join('screens', `${node.slug}.png`) + } catch (error) { + log('screenshot failed', errorMessage(error)) + return null + } + } + + /** + * Merges this one screen into both documents. Never rewrites either wholesale: every other + * screen's entry and every other node's capture is read back and preserved, because a manual + * recording is a single-screen edit to files a whole sweep also owns. + */ + private async persist( + node: AppFlowNode, + outcome: { + pathToReach: string[] + replayScript: string | null + replayVerified: boolean + pressables: AppFlowPressable[] + screenshot: string | null + } + ): Promise { + // Named here, once, for both documents. A manual path is the one kind nobody should be + // surprised by — the user watched every step go in — but it can still lean on a rendered + // string (a list, a post, a person's name), and a consumer deciding whether to re-record is + // entitled to know that before a replay on another account fails. + const contentSelectors = contentMatchedSelectors(outcome.pathToReach) + const result = await readAppFlow(this.projectId) + if (result) { + const target = result.nodes.find((candidate) => candidate.id === node.id) + if (target) { + target.capture = { + status: 'ok', + needsNavigation: false, + note: 'Recorded manually', + screenshot: outcome.screenshot, + pressables: outcome.pressables, + captureMethod: 'manual', + replayScript: outcome.replayScript, + replayVerified: outcome.replayVerified, + // True on both branches above, and the one case where it is *more* than `replayVerified` + // claims: a recording that could not be published still produced a literal command list + // of steps the user themselves performed on the device. `pathVerified` asks whether + // anything travelled this path, not whether a script round-tripped, so the fallback + // qualifies exactly as the published script does — and either way this is nothing like + // the chrome-inferred guess the flag exists to mark (`AppFlowCapture.pathVerified`). + pathVerified: outcome.pathToReach.length > 0, + ...(contentSelectors.length ? { pathContentSelectors: contentSelectors } : {}), + // Always true in practice — a manual recording's path opens with the app launch — but + // written from the path itself rather than asserted, so the node and the screens entry + // below cannot disagree about whether there is something to replay. + replayable: outcome.pathToReach.length > 0, + ...(result.manifest.structureRevision + ? { structureRevision: result.manifest.structureRevision } + : {}) + } + } + await writeAppFlow(this.projectId, result) + } + + const screens = await readAgentDeviceScreens(this.projectId) + const existingKey = Object.keys(screens).find((key) => screens[key].screenId === node.id) + const used = new Set(Object.keys(screens).filter((key) => key !== existingKey)) + const key = existingKey ?? uniqueScreenKey(node.title || node.id, used) + screens[key] = { + key, + screenId: node.id, + info: screens[existingKey ?? '']?.info ?? defaultScreenInfo(node), + pressables: outcome.pressables, + pathToReach: outcome.pathToReach, + pathVerified: outcome.pathToReach.length > 0, + ...(contentSelectors.length ? { pathContentSelectors: contentSelectors } : {}), + replayScript: outcome.replayScript, + replayVerified: outcome.replayVerified + } + await writeAgentDeviceScreens(this.projectId, screens) + } + + async cancel(): Promise { + if (this.finished) return + // Waited out for the same reason `stop` waits: everything below releases the device, and since + // a discarded recording now hands that device to the next session rather than closing it, a + // snapshot still in flight would have its session closed underneath it and its read would land + // on whatever opens next. `finished` is set after the wait so the in-flight tick's own + // rescheduling is what `stopPolling` below cancels. + await this.waitForIdle() + this.finished = true + this.stopPolling() + // Ends the `--saveScript` transaction this session armed and clears the marker with it. It + // used to be enough to let `closeDevice` take the whole session down, but a discarded + // recording now hands its device on to the next one: a live transaction left armed on it + // would suppress every later `goHome`, and agent-device would refuse to arm the next + // recording over it. See `endLiveSession` and `armed-recording.ts`. + if (this.device) await endLiveSession(this.device) + await unlink(this.candidatePath).catch(() => undefined) + this.status('stopped', 'Recording discarded') + await this.teardown('done') + } + + /** + * Always runs, on every exit path. A leaked agent-device session keeps the iOS runner lease, and + * the next sweep fails with "iOS runner is already owned by another agent-device daemon". + * + * What "release" means is the service's call, not this class's: a device the user launched is + * handed back untouched, this session's own is parked for the next recording to reuse, and a + * failed session's is closed. The default — used by tests and any caller that hands in nothing — + * is still `closeDevice`, so nothing leaks by omission. + */ + async teardown(reason: 'done' | 'failed' = 'done'): Promise { + this.stopPolling() + const device = this.device + const ctx = this.ctx + this.device = null + if (!device) return + try { + await this.release(device, ctx, { source: this.deviceSource, reason }) + } catch (error) { + console.error('Failed to release manual recording device:', error) + } + } + + /** Queues the next wireframe refresh, a rest after whatever the last one cost. */ + private schedulePoll(): void { + if (this.finished) return + this.stopPolling() + this.pollTimer = setTimeout(() => { + void this.tick().finally(() => this.schedulePoll()) + }, SNAPSHOT_REST_MS) + } + + private stopPolling(): void { + if (!this.pollTimer) return + clearTimeout(this.pollTimer) + this.pollTimer = null + } + + /** Reported to the renderer so a failed start still tears the device down. */ + async failed(error: unknown): Promise { + this.finished = true + this.stopPolling() + // Same reason as `cancel`'s, and it matters even here: a failed session on a device the user + // launched releases that device back untouched rather than closing it. + if (this.device) await endLiveSession(this.device).catch(() => undefined) + await unlink(this.candidatePath).catch(() => undefined) + this.emitEvent({ + type: 'error', + projectId: this.projectId, + nodeId: this.nodeId, + message: errorMessage(error) + }) + await this.teardown('failed') + } +} diff --git a/src/app-flow/nav-route-target.ts b/src/app-flow/nav-route-target.ts new file mode 100644 index 00000000..9a7e40c3 --- /dev/null +++ b/src/app-flow/nav-route-target.ts @@ -0,0 +1,47 @@ +interface RouteTargetNode { + id: string + file: string | null + params: readonly string[] +} + +/** + * Whether a node is the one a live navigator route names. Exported because the explore-UI pass + * attributes its variants to the focused route and must use the same identity this module's own + * param bridge does — a second spelling of "is this the same screen" is how two consumers start + * disagreeing about which capture a frame belongs to. + */ +export const routeAnswersTo = (node: RouteTargetNode, routeName: string): boolean => + node.id === routeName || node.id.split('/').pop() === routeName + +const answersTo = routeAnswersTo + +/** + * The one param-declaring node a live navigator route's params belong to, or null. + * + * A navigator names a screen the way it registered it, and the parser may hold the same component + * under several nodes: one reached from a tab with no path (and so no params), another registered + * for its URL (and so declaring them). In a measured sweep the navigator exposed the route that + * carried the needed value under the first name, no param-declaring node answered to it, and 12 + * nodes stayed blocked behind ~400s of exploration. Two nodes backed by one component file are the + * same screen, so the values are bridged through the file. + * + * A direct name hit is tried first and is never overridden by the bridge. Ambiguity is fatal on + * every path, for the reason the name match always was: two candidates say nothing about which + * record these params belong to, and a wrong attachment is a confidently wrong capture. So the bridge + * is taken only when the name identifies exactly one node, that node has a file, and exactly one + * other node sharing the file declares params; a file shared by two param-declaring nodes yields + * none of them. A direct match that is itself ambiguous does not fall through to the bridge. + */ +export function resolveNavRouteTarget( + nodes: readonly T[], + routeName: string +): T | null { + const direct = nodes.filter((node) => node.params.length > 0 && answersTo(node, routeName)) + if (direct.length) return direct.length === 1 ? direct[0] : null + const named = nodes.filter((node) => answersTo(node, routeName)) + if (named.length !== 1 || !named[0].file) return null + const sharing = nodes.filter( + (node) => node !== named[0] && node.file === named[0].file && node.params.length > 0 + ) + return sharing.length === 1 ? sharing[0] : null +} diff --git a/src/app-flow/nav-state-gate.ts b/src/app-flow/nav-state-gate.ts new file mode 100644 index 00000000..0aabe9b1 --- /dev/null +++ b/src/app-flow/nav-state-gate.ts @@ -0,0 +1,37 @@ +import { findRouteAncestors } from './exploration.ts' + +/** + * Speculative nav-state reads one sweep may spend on screens that ancestry cannot vouch for. The + * worst case a sweep can add beyond the ancestry-gated reads is this many CLI round trips + * (~0.5-1s each); a sweep with nothing blocked adds none. + */ +export const NAV_STATE_SPECULATIVE_READS = 3 + +/** + * Which blocked nodes justify reading the navigator after capturing `from`. + * + * Ancestry is by urlPath prefix (`findRouteAncestors`), which is blind to a screen with no + * `urlPath`: the parser derives `params` from the path, so such a screen has none and is no + * node's prefix either, yet it is exactly the kind of screen (a detail view pushed with params in + * navigation state, never in a URL) that holds the values blocked nodes lack. In a measured sweep + * the values sat on one such screen at 169.7s, were never read, and 12 nodes stayed blocked behind + * ~400s of exploration. The gate is a cost heuristic, not a correctness constraint, so for those + * screens only, it spends from `speculativeBudget` instead of requiring ancestry. A screen with a + * urlPath that is nobody's ancestor still reads nothing: its position is known and says no. + */ +export function selectNavStateWaiters( + from: T, + blocked: readonly T[], + nodes: readonly T[], + speculativeBudget: number +): { waiting: T[]; speculative: boolean } { + if (!blocked.length) return { waiting: [], speculative: false } + const waiting = blocked.filter( + (other) => + other.urlPath && + findRouteAncestors(other.urlPath, nodes).some((ancestor) => ancestor.id === from.id) + ) + if (waiting.length) return { waiting, speculative: false } + if (!from.urlPath && speculativeBudget > 0) return { waiting: [...blocked], speculative: true } + return { waiting: [], speculative: false } +} diff --git a/src/app-flow/nav-state.ts b/src/app-flow/nav-state.ts new file mode 100644 index 00000000..acdc3eee --- /dev/null +++ b/src/app-flow/nav-state.ts @@ -0,0 +1,375 @@ +/** + * The routes the running app's navigator is currently holding, read out of the JS runtime. + * + * `param-store.ts` harvests param values from testIDs, which works only where the app happens to + * stamp a value into one *and* that testID survives into the accessibility tree. Both conditions + * fail often enough that this module is the difference between reaching a parameterised route and + * walking to it. Two ways they fail, measured on a real app rather than imagined: + * + * - **The value is in a testID that never reaches the tree.** An interpolated testID sitting under + * a wrapper that carries its own `accessibilityLabel` is replaced by that label in the read, so + * the row arrives as a sentence and the identifier is simply not there to harvest. + * - **The value is in no testID at all.** Opaque record keys — a post id, a list key — are + * routinely passed as navigation params without ever being stamped into a control. + * + * The navigator knows those values in both cases, because it is what the app passed them to. That + * is the whole reason to read it. + * + * This is **not** the CDP transport rewrite that was deliberately deferred. There is no new socket + * client, no second `DevicePort`, and nothing persistent: it is one `agent-device cdp runtime eval` + * per read, through the same CLI and the same target-selection helpers the CPU profiler already + * uses (`profiling/cpu-evidence.ts`). The whole module is read-only — the expression it evaluates + * must never call `navigate` or touch app state, and it is written so that it cannot. + * + * Everything here degrades to `null`. A release build has no DevTools hook, Metro may not be + * running, Hermes implements only a subset of CDP, and React Navigation's internals move between + * versions. None of those are errors the sweep should care about — they mean "no extra values + * today", and the sweep explores exactly as it does now. The caller latches the first failure (see + * `NavStateStatus`) so a sweep never pays for this twice. + */ + +import { FIND_NAVIGATOR_JS } from './runtime-navigator.ts' + +/** One route out of a flattened navigation state. */ +export interface NavRoute { + /** The navigator's own name for the screen — usually the route id the parser also knows. */ + name: string + /** The URL path the route was entered at, when the app recorded one. */ + path?: string + /** Only values that can honestly become a URL segment; see the expression below. */ + params?: Record +} + +/** What `readNavState` needs from the outside world, so tests never shell out. */ +export interface NavStateDeps { + /** Runs a shell command and returns its combined output. Rejects on a non-zero exit. */ + run(command: string, timeoutMs: number): Promise + /** `agent-device --session cdp `. Injected so the quoting stays in one place. */ + cdpCommand(args: string): string + /** Shell-quotes one argument. */ + quote(value: string): string + log?(message: string): void +} + +/** + * How long the one-off target list and select may take, matching `selectMetroCdpTarget`'s own + * budget. Generous because `agent-device cdp` runs a pinned helper through npm and the very first + * invocation on a machine may download it; a tighter bound here would latch `unavailable` for a + * sweep purely because a package was cold. + */ +const SETUP_TIMEOUT_MS = 30_000 +/** A read that has not answered in this long is not going to; the walk is waiting behind it. */ +const EVAL_TIMEOUT_MS = 5_000 + +/** + * The expression evaluated in the app's JS context. + * + * Written as one self-contained arrow IIFE that can only return a string or null, because anything + * it throws surfaces as a CLI failure that says nothing useful. Three constraints shaped it: + * + * - **No instrumentation to rely on.** The comparison project injects a Babel transform and can + * assume a global navigation ref. We have neither, so the ref has to be *found*: walk the fiber + * tree from React DevTools' own hook and look for anything the app has handed a navigator to. + * - **It runs on the app's JS thread.** An unbounded walk over a large tree would stall the UI, + * which is the one thing a read must never do, so the traversal is bounded and gives up rather + * than finishing at any cost. + * - **Only URL-able values are worth returning.** Params are filtered to strings and finite + * numbers in the page, so a route object holding a whole post or a callback neither crosses the + * wire nor arrives as `[object Object]` pretending to be an id. + * + * ## Why this returned nothing on every real run, and what actually fixed it + * + * The first version of this module logged `navigation state eval returned nothing usable` on every + * sweep against Bluesky, which latched `unavailable` and left the app's ten `:rkey` routes with no + * value source at all. The obvious suspect was the fiber walk, and it was the wrong one. Evaluated + * live against the running app, the original expression returns the full, correct payload: + * + * ``` + * [{"name":"HomeTab"},{"name":"Home"},{"name":"PostThread","params":{"name":"alice.test", + * "rkey":"3kabc7xyz"}},{"name":"PostQuotes","params":{…}},{"name":"SearchTab"},…] + * ``` + * + * **The payload was being cut off in transit.** `agent-device cdp runtime eval` truncates a + * returned string at exactly 200 characters — measured, not inferred: `'X'.repeat(400)` comes back + * as 200 `X`s. A real navigator's flattened state is 400-plus characters, so what reached + * `parseNavStateOutput` was a string containing `[` and no closing `]`. Both candidate forms failed + * to parse, the function returned null, and the module reported the runtime as unable to answer + * when in fact it had answered perfectly. A 200-character ceiling is also why no amount of + * re-running or re-scheduling would ever have helped. + * + * So the read is now **chunked**: the expression stashes its own payload on one namespaced global + * and returns a short header plus the first slice, and `readNavState` asks for the remaining slices + * until it has the whole string. A payload that fits in one slice still costs exactly one eval, + * which is the common case. See `NAV_STATE_CHUNK` for the slice size and the header format. + * + * Stashing is the one write this module performs, and it is deliberate: recomputing the state for + * each slice would be read-only but could interleave slices from two different navigation states + * and assemble a payload that never existed. A single harness-owned global under a reserved name + * is the lesser risk, and it touches nothing the app can observe. + * + * ## Why the carrier search still changed + * + * The truncation fix alone would have been enough on this app, so the discovery changes below are + * robustness rather than the bug fix, and they are kept because each covers a case this app simply + * does not exercise: + * + * - **`getRootState` is the container's method, not a screen's.** It lives on what + * `useNavigationContainerRef()` holds and on `NavigationContainerRefContext`, neither of which is + * guaranteed to sit in the fiber tree. What *is* in the tree, on every screen React Navigation + * renders, is the `navigation` prop, which carries `getState` and `getParent`. That shape is now + * accepted and climbed to the root, as the comparison project's runtime does (`runtime.js`'s + * `navigation(value)` helper). It discriminates on read-only methods rather than on `dispatch`, + * which is what that project uses, so this expression never so much as names a mutating method + * and the test asserting that stays a real guard instead of a thing to be exempted from. + * - **Renderer ids are not stable and 1 was a guess.** On this app id 1 is in fact the React + * Native renderer, but `hook.renderers` is keyed by registration order, so a bundle that + * registers anything first would have left `getFiberRoots(1)` empty. The renderers are now + * enumerated and filtered by `rendererPackageName`, with the fixed-id call kept as a fallback. + * - **A carrier that answers badly must not end the search.** Each `getRootState()` is called in + * its own try/catch and its result is required to have `routes`, so a detached or still-mounting + * container ref is skipped rather than accepted as a successful read of an empty sweep. + * + * The budget moved for a measured reason: the live tree is **17,822 fibers**, and the comparison + * project's own comment records a busy feed exceeding 16,000, which made the original 20,000-fiber + * cap a coin flip on exactly the screens that matter. The real protection for the JS thread is the + * wall-clock deadline, so the node count is now generous and the deadline is what stops the walk. + * A `seen` set guards cycles, which a `child`/`sibling` push cannot do on its own. + */ + +/** + * How much payload one eval may return, and the header that frames it. + * + * `agent-device cdp runtime eval` truncates a returned string at 200 characters. The header is + * `AIHNS1::` — a format marker, the full payload length, and a colon — so 160 characters of + * slice leaves room for a five-digit length and never approaches the ceiling. + */ +const NAV_STATE_CHUNK = 160 +/** The global the expression stashes its payload on, so slices come from one consistent read. */ +const NAV_STATE_STASH = '__aiHarnessNavState' + +export const NAV_STATE_EXPR = `(() => { + try { + ${FIND_NAVIGATOR_JS} + var found = findNavigator() + if (!found) return null + var state = found.state + if (!state) return null + var flat = [] + var walk = function (node, depth) { + if (!node || depth > 12 || !Array.isArray(node.routes)) return + for (var i = 0; i < node.routes.length; i++) { + var route = node.routes[i] + if (!route || typeof route.name !== "string") continue + var entry = { name: route.name } + if (typeof route.path === "string" && route.path) entry.path = route.path + var raw = route.params + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + var kept = {} + var any = false + for (var key in raw) { + var value = raw[key] + if (typeof value === "string") { kept[key] = value; any = true } + else if (typeof value === "number" && isFinite(value)) { kept[key] = String(value); any = true } + } + if (any) entry.params = kept + } + flat.push(entry) + if (route.state) walk(route.state, depth + 1) + } + } + walk(state, 0) + var payload = JSON.stringify(flat) + // Stashed so the remaining slices come from this same read; see the doc comment above. + try { globalThis[${JSON.stringify(NAV_STATE_STASH)}] = payload } catch (error) {} + return "AIHNS1:" + payload.length + ":" + payload.slice(0, ${NAV_STATE_CHUNK}) + } catch (error) { + return null + } +})()` + +/** + * Asks for one more slice of the payload the last `NAV_STATE_EXPR` stashed. + * + * Returns the same `AIHNS1::` framing so the caller has one parser, and `null` when + * the stash is gone — an app reload between slices drops it, and a partial assembly must be + * abandoned rather than parsed, because half a JSON array is not a smaller answer. + */ +export function navStateChunkExpr(offset: number): string { + return `(() => { + try { + var payload = globalThis[${JSON.stringify(NAV_STATE_STASH)}] + if (typeof payload !== "string") return null + return "AIHNS1:" + payload.length + ":" + payload.slice(${Math.trunc(offset)}, ${Math.trunc(offset) + NAV_STATE_CHUNK}) + } catch (error) { + return null + } +})()` +} + +/** + * Pulls `AIHNS1::` back out of the CLI's own reporting. + * + * The slice is raw JSON, so it arrives escaped inside the CLI's `string: "…"` envelope and has to + * be unescaped before it can be concatenated — the same quote-and-parse trick + * `navStateCandidates` uses below. Returns null for anything that is not this framing, which + * covers both an expression that returned null and a CLI that reported an error instead. + */ +export function parseNavStateChunk(output: string): { total: number; slice: string } | null { + const envelope = /"((?:\\.|[^"\\])*)"/g + for (const match of output.matchAll(envelope)) { + const text = tryParse(`"${match[1]}"`) + if (typeof text !== 'string') continue + const framed = /^AIHNS1:(\d{1,9}):([\s\S]*)$/.exec(text) + if (!framed) continue + return { total: Number(framed[1]), slice: framed[2] ?? '' } + } + return null +} + +/** + * Finds the JSON array in whatever `agent-device cdp runtime eval` printed around it. + * + * The CLI wraps the evaluated value in its own reporting, and the exact shape belongs to a pinned + * helper package rather than to this repo, so matching it exactly would be a version dependency + * nobody would remember to update. Scanning for the payload is version-proof, and the cost of + * being wrong is a `null` that latches `unavailable` — the same outcome as not trying. + * + * The expression returns a JSON *string*, so the array usually arrives escaped inside the CLI's + * own JSON; both the escaped and the bare form are tried. + */ +export function parseNavStateOutput(output: string): NavRoute[] | null { + for (const candidate of navStateCandidates(output)) { + const routes = asRoutes(tryParse(candidate)) + if (routes) return routes + } + return null +} + +function* navStateCandidates(output: string): Generator { + // The bare array, as the expression returned it. + const open = output.indexOf('[') + const close = output.lastIndexOf(']') + if (open !== -1 && close > open) yield output.slice(open, close + 1) + // The same array escaped inside the CLI's own JSON envelope, e.g. "value": "[{\"name\":…}]". + for (const match of output.matchAll(/"(\[(?:\\.|[^"\\])*\])"/g)) { + const unescaped = tryParse(`"${match[1]}"`) + if (typeof unescaped === 'string') yield unescaped + } +} + +function tryParse(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return null + } +} + +/** Accepts only the shape this module promises; anything else is treated as "not the payload". */ +function asRoutes(value: unknown): NavRoute[] | null { + if (!Array.isArray(value)) return null + const routes: NavRoute[] = [] + for (const item of value) { + if (!item || typeof item !== 'object' || Array.isArray(item)) return null + const entry = item as { name?: unknown; path?: unknown; params?: unknown } + if (typeof entry.name !== 'string' || !entry.name) return null + const route: NavRoute = { name: entry.name } + if (typeof entry.path === 'string' && entry.path) route.path = entry.path + if (entry.params && typeof entry.params === 'object' && !Array.isArray(entry.params)) { + const params: Record = {} + for (const [key, raw] of Object.entries(entry.params)) { + if (typeof raw === 'string') params[key] = raw + } + if (Object.keys(params).length) route.params = params + } + routes.push(route) + } + return routes +} + +/** + * Selects a Metro CDP target once, so later reads are a single eval. + * + * Separate from the read itself because selection is the expensive, once-per-sweep half and the + * read is the cheap repeatable one. Returns false rather than throwing: every caller's answer to + * "could not reach the runtime" is the same, and it is not an error. + */ +export async function selectNavStateTarget( + metroPort: number, + appLabel: string | null, + deps: NavStateDeps, + pickTarget: (output: string, preferred: Array) => string | null +): Promise { + const metroUrl = `http://127.0.0.1:${metroPort}` + try { + const listed = await deps.run( + deps.cdpCommand(`target list --url ${deps.quote(metroUrl)}`), + SETUP_TIMEOUT_MS + ) + const target = pickTarget(listed, [appLabel, 'React Native']) + if (!target) { + deps.log?.(`no Metro CDP target at ${metroUrl}`) + return false + } + await deps.run(deps.cdpCommand(`target select ${deps.quote(target)}`), SETUP_TIMEOUT_MS) + return true + } catch (error) { + deps.log?.(`could not select a Metro CDP target — ${errorText(error)}`) + return false + } +} + +/** + * The currently mounted navigator's routes, or null when the runtime cannot answer. + * + * **Honest limit, and it decides how this should be scheduled.** This sees the mounted navigator + * state and its stack history — not every record the app holds, only the ones it is showing or has + * shown. So it is worth reading right after capturing a screen that is a parent (or, through + * `findRouteAncestors`, an ancestor) of a still-unreached node with unresolved params, and worth + * nothing at all otherwise. Read on that schedule it is a handful of CLI round trips per sweep; + * read eagerly it is a tax on every screen for values that are not there yet. + */ +export async function readNavState(deps: NavStateDeps): Promise { + try { + const first = parseNavStateChunk( + await deps.run( + deps.cdpCommand(`runtime eval --expr ${deps.quote(NAV_STATE_EXPR)}`), + EVAL_TIMEOUT_MS + ) + ) + if (!first) { + deps.log?.('navigation state eval returned nothing usable') + return null + } + let payload = first.slice + // Bounded by the payload's own declared length, so a stash that keeps answering with the same + // slice cannot loop: every pass must extend `payload` or the read is abandoned. + while (payload.length < first.total) { + const next = parseNavStateChunk( + await deps.run( + deps.cdpCommand(`runtime eval --expr ${deps.quote(navStateChunkExpr(payload.length))}`), + EVAL_TIMEOUT_MS + ) + ) + if (!next?.slice.length || next.total !== first.total) { + // The app reloaded, or the stash went away mid-read. Half a JSON array is not a smaller + // answer, so this is reported the same way an unreachable runtime is. + deps.log?.( + `navigation state read truncated at ${payload.length} of ${first.total} characters` + ) + return null + } + payload += next.slice + } + const routes = parseNavStateOutput(payload) + if (!routes) deps.log?.('navigation state eval returned nothing usable') + return routes + } catch (error) { + deps.log?.(`navigation state eval failed — ${errorText(error)}`) + return null + } +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/app-flow/orchestrator.ts b/src/app-flow/orchestrator.ts new file mode 100644 index 00000000..cc86453a --- /dev/null +++ b/src/app-flow/orchestrator.ts @@ -0,0 +1,4686 @@ +import { EventEmitter } from 'node:events' +import { createHash, randomUUID } from 'node:crypto' +import { copyFile, readFile, unlink } from 'node:fs/promises' +import { basename, isAbsolute, join } from 'node:path' +import * as Sentry from '@sentry/node' +import type { + AgentDeviceScreenEntry, + AgentDeviceScreensMap, + AppFlowCapture, + AppFlowCaptureStatus, + AppFlowBuildMode, + AppFlowChrome, + AppFlowDeepLinkRejection, + AppFlowEdge, + AppFlowManifest, + AppFlowNavigation, + AppFlowNode, + AppFlowPressable, + AppFlowProgressEvent, + AppFlowPrompt, + AppFlowPromptResolution, + AppFlowResult, + AppFlowStep, + AppFlowTrigger, + DeepLinkJudgeMode, + ExplorationMode, + TokenUsage +} from '../shared/app-flow.ts' +import { addTokenUsage, carriedSweepDurationMs } from '../shared/app-flow.ts' +import type { AppFlowDecisionProvider } from './decision-provider.ts' +import { + parseSubAgentJson, + type SubAgentCallOptions, + type SubAgentOptions, + type SubAgentResult, + type SubAgentSessionOptions +} from './decision-provider.ts' +import { emptyTokenUsage, snapshotUsage } from '../server/app-flow-deps/pi/usage.ts' +import { + APP_READY_SETTLE_SIMILARITY, + bootDevice, + buildAgentDevicePathToReach, + capturePressables, + captureScreenshot, + closeDevice, + ensureAdoptedMetro, + executeNavigationActions, + goHome, + isOnDevClientLauncher, + openDeepLink, + replayPathToReach, + reopenBoundApp, + SUBSTANTIVE_FRAME_GRACE_MS, + waitForAppReady, + settleAfterRoute, + type CaptureContext, + type CaptureDevice +} from './capture.ts' +import { normalizeAppFlowScreenLimit, requiresDecisionProvider } from './start-request.ts' +import { parseProject } from './parser.ts' +import { baselineLaunchCommands, walkCommands, walkStepCommand } from './walk-commands.ts' +import { contentMatchedSelectors, minimizeWalkCommands } from './path-minimize.ts' +import { isPathVerified } from './path-provenance.ts' +import { runPathToReach } from './path-run.ts' +import type { ReachMetricsPort } from './reach/ports.ts' +import { createReachPorts } from './reach/adapters.ts' +import { + createAppRuntimeTransport, + type AppRuntimeTransport +} from './cdp/app-runtime-transport.ts' +import type { LinksReply } from './cdp/agent-runtime.ts' +import { LINK_CANDIDATE_CAP } from './cdp/agent-runtime.ts' +import { agentSource } from './cdp/runtime-source.ts' +import { ExpectedOperationError } from '../shared/error-reporting.ts' +import { captureServerError } from '../server/telemetry.ts' +import { attachSweepUiStates, createSweepUiStateCapturer } from './explore-ui.ts' +import { openSweepUiStateSession } from './explore-ui-run.ts' +import { + AUTH_GATE_FRAME_WAIT_MS, + settlesAuthGateForRun, + type AuthGateResolution +} from './reach/auth-gate-policy.ts' +import { formatPhaseSummary, PhaseLedger } from './timing.ts' +import { recordRuntimeCount, recordRuntimeTiming } from './runtime-telemetry.ts' +import { createSweepLog, type SweepLog } from './sweep-log.ts' +import { createCoalescedWriter } from './coalesced-write.ts' +import { hedge } from './hedge.ts' +import { + chromeEdges, + chromeVirtualNodes, + navigatorChrome, + rebuildChromeEdges, + detectChromeInventory, + findDrawerOpener, + findMatchingChrome, + inferChromeKind, + mergeChromeInventory, + type ChromeCandidate +} from './reach/chrome-inventory.ts' +import { buildChromePathToReach } from './reach/chrome-path.ts' +import { + carryForwardDeepLinkRejection, + initialDeepLinkBreakerState, + registerDeepLinkFailure, + registerDeepLinkSuccess, + type DeepLinkBreakerState +} from './reach/deep-link-policy.ts' +import { + chooseDeliverySources, + decideDelivery, + type DeliveryScreenshotOutcome +} from './reach/delivery.ts' +import { runReachLadder, strategiesFor, touchedDevice, type ReachStrategy } from './reach/ladder.ts' +import { + NAVIGATION_SYSTEM_PROMPT, + RUNTIME_SCREEN_SYSTEM_PROMPT +} from './reach/prompts.ts' +import { ReachQueue } from './reach/queue.ts' +import { defaultScreenInfo, fallbackScreenKey, uniqueScreenKey } from './screen-keys.ts' +import { + matchRuntimeNode, + mintRuntimeNode, + recordRuntimeSighting, + sanitizeScreenKey +} from './reach/runtime-nodes.ts' +import { + PROBE_FRAME_ELEMENT_FLOOR, + PROBE_LAUNCH_READY_BUDGET_MS, + isSubstantiveFrame, + judgeProbeFrame, + pickProbeRoute +} from './reach/probe-route.ts' +import { findSeedSource } from './reach/seed.ts' +import { + createWalkCursor, + mergeWalkEdge, + noteWalkAction, + noteWalkArrival, + type WalkCursor +} from './reach/walk-edges.ts' +import { computeStructureKey } from './reach/structure-key.ts' +import { reconcileSweepProgress } from './reach/checkpoint.ts' +import { renderStrategy } from './reach/strategies/render.ts' +import { deepLinkStrategy } from './reach/strategies/deep-link.ts' +import { savedReplayStrategy } from './reach/strategies/saved-replay.ts' +import { isFallbackRoute, orderSweepNodes } from './reach/sweep-order.ts' +import { ExplorationControl, type ExplorationPauseReason } from './reach/exploration-control.ts' +import type { + ArrivalVerdict, + DiscoveredFact, + ReachAttempt, + ReachEffect, + ReachEvidence +} from './reach/types.ts' +import { fingerprint, similarity, tokenize } from './screen-signals.ts' +import { + ActiveTimeBudget, + DeepLinkRejectionMemo, + GuardGateMemo, + EXPLORATION_MAX_DECISIONS, + demoteFailedHarvest, + describeJourney, + expectedNodeTokens, + findNavigationPath, + isToolingFailure, + shouldRetryFromEntry, + parseNavigationDecision, + pickOpportunisticCandidate, + resolveNodeParams, + summarizePressables, + type ParamStoreLike, + runInteractiveExploration, + type ExplorationResult +} from './exploration.ts' +import { + AUTH_CHECKPOINT_SLUG, + clearSweepProgress, + generateSlug, + getFlowsDir, + getProjectId, + getScratchPath, + getScreenshotPath, + prepareScratchDir, + pruneTransientScreens, + registerProject, + readAppFlow, + readAgentDeviceScreens, + readManifest, + readParamStore, + readSweepProgress, + screenshotUrl, + writeAgentDeviceScreens, + writeAppFlow, + writeCaptureStatus, + writeManifest, + writeParamStore, + writeSweepProgress +} from './storage.ts' +import { ParamStore } from './param-store.ts' +import { + readNavState, + selectNavStateTarget, + type NavRoute, + type NavStateDeps +} from './nav-state.ts' +import { NAV_STATE_SPECULATIVE_READS, selectNavStateWaiters } from './nav-state-gate.ts' +import { resolveNavRouteTarget } from './nav-route-target.ts' +import { ingestLinkCandidates } from './link-harvest.ts' +import { runShellCommand } from './shell.ts' +import { + agentDeviceCdp, + parseCdpTargetList, + pickMetroCdpTarget, + shellQuote +} from '../server/app-flow-deps/profiling/cdp.ts' +import { checkSignal } from './cancel.ts' + +export type AppFlowPhase = 'parse' | 'boot' | 'capture' | 'classify' | 'render' + +/** Slug of the screenshot shown to the user when the build stops on a screen it cannot leave. */ +const BLOCKED_SLUG = '--blocked' + +// `computeStructureKey` moved to `reach/structure-key.ts` (plan Step 4b) — that module has no +// Electron import and is unit-testable under `node --test`, which `orchestrator.ts` itself is not. + +/** + * Above this fingerprint overlap, two failed destinations are treated as showing the same UI. + * Only a near-identical screen advances the no-progress counter because shared app chrome alone + * must not condemn a capture. + */ +const ARRIVAL_SIMILARITY = 0.9 + +/** + * How many routes in a row must fail to arrive before the build stops and asks the user. One + * failure is ordinary — a param that resolves to nothing. Three different routes rendering the + * same screen is the app refusing to navigate at all. + */ +const BLOCKED_RUN_LIMIT = 3 + +async function computeFastSourceHash( + projectPath: string, + nodes: readonly AppFlowNode[] +): Promise { + const hash = createHash('sha256') + const files = [...new Set(nodes.map((node) => node.file).filter((file): file is string => !!file))] + .sort() + for (const file of files) { + const absolute = isAbsolute(file) ? file : join(projectPath, file) + hash.update(file).update('\0') + try { + hash.update(await readFile(absolute)) + } catch { + hash.update('unreadable') + } + hash.update('\0') + } + return hash.digest('hex').slice(0, 24) +} + +// The deep-link circuit breaker's limit is `reach/deep-link-policy.ts`'s `DEEPLINK_BREAKER_LIMIT` — +// moved there in landing-order item 4 along with the breaker's state and decisions, so its own +// module can be unit-tested with no device and no sweep-scoped closure. See that module's doc +// comment on `DeepLinkBreakerState`. + +/** + * How many opportunistic arrival checks one walk may spend on screens other than its target. Each + * is a vision call against that walk's own budget, and hub screens recur on nearly every walk, so + * the cap keeps re-confirmation from crowding out the target's own decisions. + */ +const OPPORTUNISTIC_VERIFY_LIMIT = 2 + +/** + * Below this much budget left, opportunistic verification is skipped outright rather than spent — + * a check that itself times out mid-call would burn the walk's last reserves confirming a screen + * it did not come here for, right as the real target most needs whatever time remains. + */ +const OPPORTUNISTIC_MIN_REMAINING_MS = 60_000 + +/** + * How many nodes the sweep will mint for screens the parser registered no route for. Each mint + * costs a model call, and a bad affirmative (a modal or half-loaded frame judged distinct) is only + * ever caught by a human reading the graph, so a wrong judge cannot flood it past this ceiling. + */ +const RUNTIME_NODE_SWEEP_CAP = 10 + +/** + * How long the adopt path re-confirms the pre-launched app before soft re-rooting it. + * + * Deliberately far shorter than the fresh-boot `APP_READY_TIMEOUT_MS`: Launch already ran + * `waitForAppReady` to completion, so the bundle is built and the app re-settles in about two + * reads. This only buys headroom for a slow read or a screen left mid-transition — the recovery + * that follows is cheap, so waiting a cold-start budget here is strictly worse. + */ +const ADOPTED_APP_READY_BUDGET_MS = 15_000 + +export interface AppFlowBuildOptions { + projectPath: string + mode?: AppFlowBuildMode + /** Maximum screens to process in this device sweep. Omitted means no limit. */ + screenLimit?: number + /** + * Maximum screen *attempts* — reached or not — this sweep may spend. Omitted derives it from + * `screenLimit`; see `ExplorationControl`'s `DEFAULT_ATTEMPTS_PER_SCREEN` for why a sweep bounded + * only by successful captures has no bound at all. + */ + attemptLimit?: number + /** Wall-clock (active time, excluding waits on the user) this sweep may spend. Omitted derives it + * from `screenLimit` — see `DEFAULT_MS_PER_SCREEN`. */ + sweepDeadlineMs?: number + /** Cooperative pause request, observed only between durable per-screen checkpoints. */ + pauseRequested?: () => boolean + /** Host-provided decisions. Required for screen sweeps; structure-only builds do not use it. */ + decisionProvider?: AppFlowDecisionProvider + /** + * Which reach mechanisms this sweep may use for a screen no URL can address. Omitted means + * `auto`. See `ExplorationMode`, and `strategiesFor` for the ladder each one produces. + */ + explorationMode?: ExplorationMode + /** + * Whether a deep link's arrival is confirmed by the vision judge. Omitted means `on` — the + * behaviour every sweep had before this option existed. Consumed at exactly one place, the + * deep-link rung's `arrivalJudge`. See `DeepLinkJudgeMode`, and `reach/not-found-signal.ts` for + * what stands in for the judge's `status: 'not-found'` when it is `off`. + */ + deepLinkJudgeMode?: DeepLinkJudgeMode + /** + * Also capture each delivered screen's UI states (sheets, modals, dialogs its own source proves + * it can open) while the device is still standing on it. Omitted means off. A failure here never + * fails the sweep; see `createSweepUiStateCapturer`. + */ + captureUiStates?: boolean + /** Fast only: ask the arrival judge about a screen the runtime snapshot could not vouch for. */ + judgeUnclearScreens?: boolean + /** An app the user already launched. The sweep adopts it instead of building and booting again. */ + launchedDevice?: CaptureDevice + onEvent(event: AppFlowProgressEvent): void + signal?: AbortSignal + /** + * Asks the user to unblock the build by acting on the device, and resolves when they answer. + * The device is left running throughout. Omitted (or resolving to `stop`) ends the sweep while + * preserving the structural graph and partial captures. + */ + requestUserAction?(prompt: AppFlowPrompt): Promise +} + +export interface AppFlowBuildResult { + ok: true + projectId: string + result: AppFlowResult +} + +export interface AppFlowBuildError { + ok: false + error: string +} + +function emit( + projectId: string, + onEvent: (event: AppFlowProgressEvent) => void, + event: AppFlowProgressEvent +): void { + try { + onEvent({ ...event, projectId }) + } catch (error) { + console.error('App flow event listener error:', error) + } +} + +// `sanitizeScreenKey` moved to `reach/runtime-nodes.ts` (plan Step 4a) — a runtime node's id is +// derived from the judge's own `key` through this exact function, so it lives in one place rather +// than risking a second copy drifting from the one this file used to keep privately. + +function buildAgentDeviceScreens( + nodes: AppFlowNode[], + edges: AppFlowEdge[], + runtimeEdges: AppFlowEdge[], + chrome: AppFlowChrome[], + ctx: CaptureContext, + device: CaptureDevice, + openedByNode: Record, + inferred: Record, + captures: Record +): AgentDeviceScreensMap { + const used = new Set() + const screens: AgentDeviceScreensMap = {} + + for (const node of nodes) { + const params = pickSampleParams(node, edges) + const guessed = inferred[node.id] + const key = uniqueScreenKey((guessed?.key ?? '').trim() || fallbackScreenKey(node), used) + const info = guessed?.info?.trim() || defaultScreenInfo(node) + const capture = captures[node.id] + // Four sources, first match wins, and the new first one is what replaced the sweep's `.ad` + // scripts: `capture.pathCommands` is the walk's own `agent-device …` lines, already carrying + // their baseline launch prefix (`walk-commands.ts`). It comes before `replayScript` because a + // node can hold both — a manual recording from an earlier session plus a walk from this one — + // and the command list is the one this sweep just proved. + // + // `replayScript` stays for exactly one producer: the manual recorder, which still publishes a + // real `.ad` file. Nothing in the sweep writes one now. + const pathToReach = capture?.pathCommands?.length + ? [...capture.pathCommands] + : capture?.replayScript + ? replayPathToReach(device, capture.replayScript) + : openedByNode[node.id] + ? buildAgentDevicePathToReach(ctx, device, node, params, openedByNode[node.id]) + : (buildChromePathToReach(node, edges, runtimeEdges, chrome, device) ?? []) + if (pathToReach.length === 0) continue + // Whether anything ever ran this path, as opposed to inferring it — the same four tests in the + // same order as the ladder just above, which is why they live in one pure, tested place rather + // than being spelled out a second time here. See `path-provenance.ts` for why the question + // needs asking at all, and `AppFlowCapture.pathVerified` for what the answer is used for. + const pathVerified = isPathVerified({ + pathCommands: capture?.pathCommands, + replayScript: capture?.replayScript, + replayVerified: capture?.replayVerified, + deepLinkOpened: Boolean(openedByNode[node.id]) + }) + const entry: AgentDeviceScreenEntry = { + key, + screenId: node.id, + info, + pressables: capture?.pressables ?? [], + pathToReach, + pathVerified, + // Reported, not acted on — see `contentMatchedSelectors`. Computed from the finished path + // rather than threaded out of the walk so it covers all four sources uniformly: the stored + // `Profile-List` path leans on `label="Dummy"` (a list this account owns) and the stored + // `TermsOfService` path on `label="Terms of Service"`, and both are facts about the path, not + // about which producer wrote it. + pathContentSelectors: contentMatchedSelectors(pathToReach), + replayScript: capture?.replayScript ?? null, + replayVerified: capture?.replayVerified ?? false + } + screens[key] = entry + } + + return screens +} + +/** + * Stamp each node's capture with whether `buildAgentDeviceScreens` gave it an entry. + * + * The Replay button needs to know whether a screen has a stored `pathToReach`, and the renderer + * never reads `agent-device-screens.json` — see `AppFlowCapture.replayable` for the two ways + * inferring it from `capture.status` went wrong. Taking the answer from the screens map means the + * document and the map cannot disagree about it. + * + * Shared because the sweep writes the document twice from two separately assembled node arrays — + * `persistCaptureUpdate` after every screen, and the final write at the end — and the second one + * lands last. Stamping only the first is the same as not stamping at all: the first sweep after + * this field was added still wrote `replayable: undefined` for all twenty screens, because the + * end-of-sweep rebuild had its own copy of the node assembly and no knowledge of the field. + */ +function withReplayableCaptures( + nodes: readonly T[], + screens: AgentDeviceScreensMap +): T[] { + const entries = new Map( + Object.values(screens) + .filter((entry): entry is AgentDeviceScreenEntry & { screenId: string } => + Boolean(entry.screenId) + ) + .map((entry) => [entry.screenId, entry]) + ) + return nodes.map((node) => { + if (!node.capture) return node + const entry = entries.get(node.id) + return { + ...node, + capture: { + ...node.capture, + replayable: Boolean(entry), + // Carried across for the same reason `replayable` is: the renderer never reads + // `agent-device-screens.json`, so anything the Replay button has to say about the path it + // is about to run has to be on the capture. Stamped from the entry the map actually holds, + // never re-derived, so the document and the map cannot disagree about whether a path was + // walked. + ...(entry ? { pathVerified: entry.pathVerified ?? false } : {}), + ...(entry?.pathContentSelectors?.length + ? { pathContentSelectors: [...entry.pathContentSelectors] } + : {}) + } + } + }) +} + +/** Where exploration starts when a deep link can put it partway to the target. */ +interface DeepLinkSeed { + /** Node id of the ancestor route the app is opened at. */ + id: string + /** Its route pattern, for the planner's benefit. */ + urlPath: string + /** The URL the recorded launch uses. */ + url: string +} + +/** + * Where a bounded last-hop exploration starts when `reach/seed.ts` found a screen this sweep has + * already reached and stored a journey for — plan Step 3e. + * + * `commands` replaces the `script` this used to carry. The walk begins by *running the parent's + * stored `pathToReach`* line by line (`DevicePort.runPathCommands`) rather than by replaying a + * `.ad` file, and the target's own path becomes those same lines plus whatever the last hop takes + * — "replay the parent, then do the last hop" with nothing on disk and nothing to arm, repair or + * publish. `trigger` is the named control the parent's outgoing edge pointed at the target with, + * when `findSeedSource` found one — handed to the planner as a strong hint rather than a command, + * since the recorded control can drift from what is on screen right now. + */ +interface CommandSeed { + id: string + /** The parent's stored `pathToReach`, baseline launch line included, so it stands alone. */ + commands: readonly string[] + trigger: AppFlowTrigger | null +} + +type ExplorationSeed = DeepLinkSeed | CommandSeed + +/** What one role has cost the sweep so far. */ +interface AgentTokenTally { + calls: number + tokens: TokenUsage +} + +/** + * What one model call was actually handed, logged before it runs. + * + * A token line says a call cost 324,000 tokens; it cannot say whether that was one enormous prompt, + * a screenshot, or the agent quietly reading source files for forty seconds. Those need different + * fixes, and telling them apart after the fact was impossible from the log alone. Images are named, + * never dumped — a base64 screenshot would bury every other line in the file — and prompts are + * reported by size with a short head, which is enough to recognise a prompt that has grown without + * anyone intending it to. + */ +function logAgentInput( + label: string, + input: { + systemPrompt?: string + userPrompt: string + imagePath?: string + tools?: string[] + /** Turns already in a session's history when this call was made. Absent for one-shot roles, + * which have none — its whole purpose is to show whether a walk's history stops growing. */ + historyMessages?: number + } +): void { + const parts = [`prompt ${input.userPrompt.length} chars`] + if (input.systemPrompt) parts.push(`system ${input.systemPrompt.length} chars`) + if (input.imagePath) parts.push(`image ${basename(input.imagePath)}`) + if (input.historyMessages !== undefined) parts.push(`history ${input.historyMessages} msgs`) + parts.push(`tools [${(input.tools ?? []).join(', ')}]`) + const head = input.userPrompt.replace(/\s+/g, ' ').slice(0, 160) + console.log(`[app-flow] agent-in ${label} — ${parts.join(', ')} :: ${head}`) +} + +/** + * The other half: what the call did once it had that input. `turns` and `toolCalls` are the two + * numbers that explain a call whose cost bears no relation to the prompt it was given — a role + * granted a file-reading tool can spend an entire budget on turns nobody asked for, and that is + * invisible in a usage total. + */ +function logAgentOutput(label: string, result: SubAgentResult): void { + const tools = result.toolCalls.length + ? result.toolCalls.map((call) => call.name).join(', ') + : 'none' + console.log( + `[app-flow] agent-out ${label} — turns ${result.diagnostics.length}, tool calls ${result.toolCalls.length} (${tools}), ` + + `text ${result.text.length} chars${result.timedOut ? ', TIMED OUT' : ''}` + ) +} + +/** + * Every model call in a sweep is its own disposable session, so a per-call line is the only place the + * cost is legible: a running total alone cannot say whether a long sweep was many cheap arrival + * checks or one exploration that would not stop. + */ +function logAgentTokens( + label: string, + used: TokenUsage, + elapsedMs: number, + running: TokenUsage, + /** + * Recorded here rather than at the four call sites because this is the one function every model + * call reaches on both the success and the failure path. A timed-out judge is the most expensive + * call a sweep can make, and tallying only successes would hide exactly that. + */ + ledger?: PhaseLedger +): void { + ledger?.record('model', elapsedMs) + const cache = + used.cacheRead || used.cacheWrite ? ` cache r${used.cacheRead}/w${used.cacheWrite}` : '' + const reasoning = used.reasoning ? ` reasoning ${used.reasoning}` : '' + const cost = used.cost ? ` $${used.cost.toFixed(4)}` : '' + console.log( + `[app-flow] tokens ${label} — in ${used.input} out ${used.output}${cache}${reasoning} ` + + `total ${used.total}${cost} in ${(elapsedMs / 1000).toFixed(1)}s ` + + `(sweep ${running.total})` + ) +} + +/** + * How long a one-shot judge may go unanswered before a duplicate is raced against it. + * + * Sized from two instrumented sweeps rather than picked: the arrival judge's p50 is 2.1s and its + * p90 is 8.7s, so this sits just above the p90 and leaves the ordinary call untouched. What it + * catches is the shape that is not deliberation — 16.6s for 165 output tokens, 24.9s for 130, where + * a p50 call returns ~140 — and in the measured run four such calls cost ~81s, 16% of the sweep. + * + * Lowering it would catch more stalls and would also start duplicating the calls that are slow + * because they are genuinely reasoning: the same sweep has a 7.6s arrival that spent 858 of its 967 + * output tokens on reasoning, and a duplicate of that call has to do the same thinking, so it pays + * twice for nothing. That is the trade this constant sets, and the next run's p-values are what + * should move it. + */ +const HEDGE_AFTER_MS = 8_000 + +/** + * `HEDGE_AFTER_MS` for a call that is safe to run twice, and `Infinity` — never hedge — for one + * that is not. + * + * Tools are the whole test. A judge with `tools: []` can only read a screenshot and return text, so + * a second copy of it costs tokens and nothing else; anything holding a tool could run that tool + * twice, and a duplicated device action is a corrupted sweep rather than a slow one. + */ +function hedgeableAfterMs(agentOptions: SubAgentOptions): number { + return (agentOptions.tools?.length ?? 0) === 0 ? HEDGE_AFTER_MS : Infinity +} + +/** Companion to `logAgentTokenSummary`: the other half of the same bill. */ +function logPhaseSummary(ledger: PhaseLedger): void { + for (const line of formatPhaseSummary(ledger)) console.log(line) +} + +function tokenUsageSince(before: TokenUsage, after: TokenUsage): TokenUsage { + return { + input: Math.max(0, after.input - before.input), + output: Math.max(0, after.output - before.output), + cacheRead: Math.max(0, after.cacheRead - before.cacheRead), + cacheWrite: Math.max(0, after.cacheWrite - before.cacheWrite), + reasoning: Math.max(0, after.reasoning - before.reasoning), + total: Math.max(0, after.total - before.total), + cost: Math.max(0, after.cost - before.cost) + } +} + +function logAgentFailure(label: string, error: unknown, elapsedMs: number): void { + console.error( + `[app-flow] agent-failed ${label} after ${(elapsedMs / 1000).toFixed(1)}s — ${error instanceof Error ? error.message : String(error)}` + ) +} + +/** Closing breakdown by role, so a sweep's bill can be read without replaying the log. */ +function logAgentTokenSummary( + tallies: ReadonlyMap, + total: TokenUsage, + /** + * Duplicates issued by `hedge`, reported because they are the one thing that stops the rows + * below from summing to the total. + * + * A hedged call bills two requests and keeps one answer. Both land in `total` through `onUsage`, + * which is correct — the sweep really did pay for both — but only the winner's usage is knowable + * at the per-call boundary, so only the winner's reaches a role's row. The gap between the rows + * and the total is exactly these duplicates, and naming the count here is what makes it legible + * instead of looking like a tallying bug. + */ + hedges: number +): void { + const byCost = [...tallies.entries()].sort((a, b) => b[1].tokens.total - a[1].tokens.total) + for (const [role, tally] of byCost) { + const share = total.total ? Math.round((tally.tokens.total / total.total) * 100) : 0 + console.log( + `[app-flow] tokens by role — ${role}: ${tally.calls} calls, ${tally.tokens.total} tokens (${share}%)` + + `${tally.tokens.cost ? `, $${tally.tokens.cost.toFixed(4)}` : ''}` + ) + } + // `reasoning` is a slice of `output`, not an addition to it, so it is reported rather than summed. + // It belongs on this line because it is usually the larger half: a sweep that matched every screen + // on the first try still spent 70% of its output thinking, and that is the knob `thinkingLevel` + // turns — unreadable from a total that only says how many tokens came back. + const reasoning = total.reasoning ? ` reasoning ${total.reasoning}` : '' + if (hedges) + console.log( + `[app-flow] tokens — ${hedges} hedged duplicate${hedges === 1 ? '' : 's'} billed to the total but not to any role above` + ) + console.log( + `[app-flow] tokens total — in ${total.input} out ${total.output}` + + `${reasoning} cache r${total.cacheRead}/w${total.cacheWrite} total ${total.total}` + + `${total.cost ? ` $${total.cost.toFixed(4)}` : ''}` + ) +} + +function pickSampleParams( + node: AppFlowNode, + edges: AppFlowEdge[], + store?: ParamStoreLike | null +): Record { + const resolved = resolveNodeParams(node, edges, store).values + // Display only. The `1` stand-in exists because the descriptive path written into the + // agent-device manifest reads better than a bare `:id`, and nothing opens that string. Every + // caller that builds a URL to open resolves its own params and declines when they are unfilled — + // `buildUrlPath` throws rather than substituting, so a new caller that forgets finds out. + return Object.fromEntries(node.params.map((key) => [key, resolved[key] ?? '1'])) +} + +function captureContext( + projectPath: string, + projectId: string, + parsed: { + scheme: string | null + appName: string | null + bundleId: string | null + slug: string | null + }, + signal?: AbortSignal, + timing?: PhaseLedger +): CaptureContext { + return { + projectPath, + projectId, + scheme: parsed.scheme, + appName: parsed.appName, + bundleId: parsed.bundleId, + slug: parsed.slug, + platform: 'ios', + sessionName: projectId, + signal, + timing + } +} + +export async function buildAppFlow( + options: AppFlowBuildOptions +): Promise { + // `0` is the start API's explicit "all screens" sentinel. Normalize once so progress payloads, + // pause messages, derived attempt/deadline budgets, and the exploration loop all consume the + // engine's single uncapped representation even when a caller bypasses the service resolver. + options = { ...options, screenLimit: normalizeAppFlowScreenLimit(options.screenLimit) } + const { projectPath, signal } = options + const mode = options.mode ?? 'structure-only' + const activeNow = (): number => options.decisionProvider?.activeNow?.() ?? Date.now() + const projectId = getProjectId(projectPath) + let activeManifest: AppFlowManifest | null = null + const startedAt = Date.now() + /** + * Where this sweep's wall clock goes. Started on `startedAt` so its total is the same number the + * manifest records as `durationMs` — a summary whose shares are taken against a different clock + * than the one the UI reports would be unreconcilable with it. + */ + const timing = new PhaseLedger(startedAt) + + /** + * The `cumulativeDurationMs` total every earlier run of *this same* resume chain already spent, + * in ms. Zero until we know a prior sweep's checkpoint is being reused (see `carriedDurationMs` + * below, set once the resume's structure fingerprint matches), so a structure-only build and the + * first sweep of a fresh chain each report only their own clock. + */ + let carriedDurationMs = 0 + + /** + * Writes the manifest that ends this build, stamping how long it ran — both this run alone + * (`durationMs`) and the whole resume chain's total (`cumulativeDurationMs`). + * + * Only the terminal writes go through here. The manifest is rewritten several times mid-build to + * move `sweepStatus` between `running` and `awaiting-user`, and stamping those would record the + * time up to an arbitrary checkpoint — the number would keep changing while a sweep sat waiting + * on the user, which is the opposite of a duration. + */ + const finishManifest = async (final: AppFlowManifest): Promise => { + final.durationMs = timing.wallMs + final.cumulativeDurationMs = carriedDurationMs + timing.wallMs + await writeManifest(projectId, final) + } + + // LLM usage of every sub-agent call in this build. Each emitted event carries a copy, so the + // renderer can show token usage while the build is still running. + let tokens: TokenUsage = emptyTokenUsage() + const onEvent = (event: AppFlowProgressEvent): void => + options.onEvent({ ...event, tokens: snapshotUsage(tokens) }) + + // Keyed by role — the label's first word — so the closing summary says where a sweep's tokens went. + const tallies = new Map() + /** Duplicates `hedge` issued this sweep. See `logAgentTokenSummary`'s `hedges` parameter. */ + let hedges = 0 + + /** + * One agent call's bookkeeping: the role tally, the running token total and the log line, in the + * one place both the one-shot `runAgent` and a long-lived session's `prompt` reach for, so + * `logAgentTokenSummary` reads identically whichever produced the call. + */ + const recordAgentCall = (label: string, startedAt: number, used: TokenUsage): void => { + const role = label.split(' ')[0] + const tally = tallies.get(role) ?? { calls: 0, tokens: emptyTokenUsage() } + tallies.set(role, { calls: tally.calls + 1, tokens: addTokenUsage(tally.tokens, used) }) + logAgentTokens(label, used, Date.now() - startedAt, tokens, timing) + } + + const runAgent = async ( + agentOptions: SubAgentOptions, + label: string + ): Promise => { + logAgentInput(label, agentOptions) + const startedAt = Date.now() + const tokensBefore = snapshotUsage(tokens) + const callOnce = (): Promise => { + if (!options.decisionProvider) throw new Error('A decision provider is required for a screen sweep.') + return options.decisionProvider.run(agentOptions, label) + } + let result: SubAgentResult + try { + const outcome = await hedge({ + attempt: callOnce, + // The guard `hedge` cannot make for itself. A call that can run twice harmlessly is one + // with no tools to invoke and no session history to append to, which is every judge that + // arrives here: one system prompt, one screenshot, a JSON verdict. A call carrying tools + // could act on the device twice, so it waits however long it waits. + afterMs: options.decisionProvider?.supportsConcurrentRequests === false + ? Infinity + : hedgeableAfterMs(agentOptions), + onHedge: () => + console.log( + `[app-flow] hedge ${label} — no answer in ${(HEDGE_AFTER_MS / 1000).toFixed(1)}s, racing a duplicate` + ) + }) + result = outcome.result + hedges += outcome.attempts - 1 + if (outcome.attempts > 1) + console.log( + `[app-flow] hedge ${label} — ${outcome.winner === 0 ? 'the original' : 'the duplicate'} answered first after ${((Date.now() - startedAt) / 1000).toFixed(1)}s` + ) + } catch (error) { + recordAgentCall(label, startedAt, tokenUsageSince(tokensBefore, tokens)) + logAgentFailure(label, error, Date.now() - startedAt) + throw error + } + tokens = addTokenUsage(tokens, result.tokens) + recordAgentCall(label, startedAt, result.tokens) + logAgentOutput(label, result) + return result + } + + // One sub-agent session per exploration attempt, rather than a fresh call — and its cold + // session, resent history and lost prompt-cache reuse — per navigation step. + const runAgentSession = async ( + sessionOptions: SubAgentSessionOptions + ): Promise<{ + prompt: (callOptions: SubAgentCallOptions, label: string) => Promise + dispose: () => void + }> => { + if (!options.decisionProvider) throw new Error('A decision provider is required for a screen sweep.') + const hostSession = await options.decisionProvider.createSession(sessionOptions) + const historyLength = (): number => hostSession.historyLength() + return { + prompt: async (callOptions, label) => { + logAgentInput(label, { + ...callOptions, + systemPrompt: sessionOptions.systemPrompt, + tools: sessionOptions.tools, + historyMessages: historyLength() + }) + const startedAt = Date.now() + const tokensBefore = snapshotUsage(tokens) + let result: SubAgentResult + try { + result = await hostSession.prompt(callOptions, label) + } catch (error) { + recordAgentCall(label, startedAt, tokenUsageSince(tokensBefore, tokens)) + logAgentFailure(label, error, Date.now() - startedAt) + throw error + } + tokens = addTokenUsage(tokens, result.tokens) + recordAgentCall(label, startedAt, result.tokens) + logAgentOutput(label, result) + return result + }, + dispose: () => hostSession.dispose() + } + } + + try { + checkSignal(signal) + emit(projectId, onEvent, { type: 'phase', phase: 'parse', message: 'Parsing routes...' }) + console.log('[app-flow] parse routes') + + const { name } = await registerProject(projectPath) + console.log(`[app-flow] registered project "${name}" (${projectId}) at ${projectPath}`) + /** + * The chain total the *previous* build recorded, read before this build's own manifest + * overwrites it below. Only folded into `cumulativeDurationMs` when this build turns out to + * resume that build's checkpoint; see `carriedDurationMs`. + */ + const previousManifest = await readManifest(projectId) + const parseStartedAt = Date.now() + const parsed = await parseProject(projectPath) + if (!parsed) { + // Thrown (not returned) so the catch below emits an `error` event — a bare return would + // leave the renderer stuck on "Parsing routes..." forever. + throw new Error( + `No supported Expo Router or React Navigation routes found in ${projectPath}.` + ) + } + console.log( + `[app-flow] parse done in ${Date.now() - parseStartedAt}ms — ${parsed.nodes.length} routes, ` + + `${parsed.edges.length} edges, mode=${parsed.mode}, appDir=${parsed.appDir}` + ) + + const structureRevision = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}` + const structureKey = computeStructureKey(parsed.nodes, parsed.edges) + const fastSourceHash = await computeFastSourceHash(projectPath, parsed.nodes) + const fastFingerprint = createHash('sha256').update(agentSource()).digest('hex').slice(0, 24) + // Persisted verbatim as `AppFlowResult.navigation` (plan Step 4d) so a reader of `app-flow.json` + // can say where a journey starts without re-parsing the project. Built once here, alongside + // `structureKey`, from the exact same parse the sweep runs against. + const navigation: AppFlowNavigation = { + layouts: parsed.layouts, + rootNavigator: parsed.rootNavigator, + entryLayouts: parsed.entryLayouts, + entryScreenIds: parsed.entryScreenIds + } + const manifest: AppFlowManifest = { + // Bumped for Step 4's new persisted shape (`node.origin`, `edge.origin`/`evidence`, + // `runtimeEdges`, `chrome`, `navigation`). Nothing branches on this number today; it exists so + // a future reader can refuse a document it cannot represent. + formatVersion: 4, + generator: 'ai-harness/app-flow', + generatedAt: new Date().toISOString(), + project: { + name, + path: projectPath, + scheme: parsed.scheme, + platform: 'ios', + mode: parsed.mode + }, + buildMode: mode, + structureRevision, + sweepStatus: mode === 'structure-only' ? 'not-requested' : 'running' + } + activeManifest = manifest + await writeManifest(projectId, manifest) + + const previous = await readAppFlow(projectId) + const previousById = new Map(previous?.nodes.map((node) => [node.id, node]) ?? []) + const usedSlugs = new Set() + const nodesWithSlug = parsed.nodes.map((node) => { + const baseSlug = generateSlug(node.id) + const slug = usedSlugs.has(baseSlug) + ? `${baseSlug}_${createHash('sha256').update(node.id).digest('hex').slice(0, 8)}` + : baseSlug + usedSlugs.add(slug) + return { + ...node, + slug, + capture: + previousById.get(node.id)?.file === node.file && previousById.get(node.id)?.capture + ? { + ...previousById.get(node.id)!.capture!, + structureRevision: previous?.manifest.structureRevision, + stale: true + } + : null, + // Carried forward alongside `capture`, but independently validated: a rejection survives a + // rebuild only when this node's own structure (id/file/urlPath/params) still matches what it + // was recorded against, not when its capture happens to survive too. + deepLinkRejection: carryForwardDeepLinkRejection( + previousById.get(node.id)?.deepLinkRejection, + node + ) + } + }) + + // Rehydrated so a value observed in a previous sweep lets a re-run deep-link straight to a + // parameterised route instead of re-walking to it. `fromJSON` marks every carried record as + // coming from a prior run, so anything seen live this sweep outranks it — the record it names + // may have been deleted since, and a stale handle should only ever be the fallback. + const paramStore = ParamStore.fromJSON(await readParamStore(projectId)) + + const partialResult: AppFlowResult = { + manifest, + nodes: nodesWithSlug, + edges: parsed.edges, + // This is the structure-only write, before the device sweep that discovers any of this even + // starts — written as empty rather than left absent so a fresh v4 document never looks like a + // mid-migration v3 one. The sweep below fills these in as it runs (plan Steps 3d/4a). + runtimeEdges: [], + chrome: [], + navigation + } + await writeAppFlow(projectId, partialResult) + const activeNodeIds = new Set(nodesWithSlug.map((node) => node.id)) + const previousScreens = await readAgentDeviceScreens(projectId) + await writeAgentDeviceScreens( + projectId, + Object.fromEntries( + Object.entries(previousScreens).filter( + ([, entry]) => entry.screenId && activeNodeIds.has(entry.screenId) + ) + ) + ) + emit(projectId, onEvent, { + type: 'structure-ready', + phase: 'render', + message: `Structure ready: ${nodesWithSlug.length} screens, ${parsed.edges.length} edges.` + }) + + if (mode === 'structure-only') { + // A structure rebuild is a new chain only when it lost the previous build's work: the + // captures it carried forward (rendered as "Previous capture") are what the saved time + // paid for, so keeping them keeps the chain — and the header's total keeps describing the + // map the user is looking at rather than this parse's few seconds. + if (nodesWithSlug.some((node) => node.capture)) { + carriedDurationMs = carriedSweepDurationMs(previousManifest, true) + } + await finishManifest(manifest) + emit(projectId, onEvent, { + type: 'complete', + phase: 'render', + message: 'Structure complete.' + }) + return { ok: true, projectId, result: partialResult } + } + // Every mode but Fast is built around model roles — the navigator, the arrival judge, the + // auth-gate classifier — so starting one without a provider could + // only ever fail at the first node, and failing here says so before a simulator is booted. + // + // Fast is the exception now rather than a mode that happened to need the same thing: its + // ladder is `render` alone and its auth gate is skipped (see `checkAuthGate`). The one model + // role still reachable in a Fast sweep is the arrival judge, and only on a node whose runtime + // evidence was incomplete — which is why the judge port is replaced with a non-asking one just + // below rather than left to throw mid-sweep. A Fast sweep with no provider is therefore a + // complete, if slightly weaker, sweep. + if ( + !options.decisionProvider && + requiresDecisionProvider(mode, options.explorationMode, options.judgeUnclearScreens) + ) { + throw new Error('A decision provider is required for a screen sweep.') + } + + checkSignal(signal) + const bootStartedAt = Date.now() + // Boot is excluded from every time measurement, so its device work is deliberately not + // recorded in the ledger (the boot context carries no `timing`). + const bootCtx = captureContext(projectPath, projectId, parsed, signal) + const reusedDevice = options.launchedDevice + let device: CaptureDevice + let fastRuntime: AppRuntimeTransport | null = null + /** + * The sweep's forced checkpoint flush, published out of the `try` below so the `finally` can + * reach it — `try` and `finally` are separate blocks, and the sweep's state all lives in the + * former. Null until the sweep gets far enough to own a checkpoint. See `persistCheckpoint`. + */ + let flushCheckpointOnExit: (() => Promise) | null = null + /** The per-run Fast log, published out of the `try` for the same reason — see `sweep-log.ts`. */ + let sweepLogOnExit: SweepLog | null = null + if (reusedDevice) { + emit(projectId, onEvent, { + type: 'phase', + phase: 'boot', + message: 'Checking the app you launched...' + }) + device = reusedDevice + } else { + emit(projectId, onEvent, { + type: 'phase', + phase: 'boot', + message: 'Booting the simulator, installing the app, and starting Metro...' + }) + console.log('[app-flow] bootDevice start') + device = await bootDevice(bootCtx) + console.log('[app-flow] bootDevice done') + } + + try { + const ctx = captureContext(projectPath, projectId, parsed, signal, timing) + checkSignal(signal) + if (reusedDevice) { + // Before anything else on this path: the transport. An adopted device carries the + // `metroPort` its Launch recorded, and nothing has re-checked it since — the user's dev + // server may have been stopped or restarted on another port in between. One probe answers + // that once, where the alternative is every node of the sweep answering it separately: the + // 2026-10 Fast run that skipped all nine of its screens with "Runtime transport + // unavailable" spent 25s doing exactly that. Run first, so the readiness probe below (and + // its soft re-root) deals with the aftermath of a restarted bundler. + const metroCheckStartedAt = performance.now() + const adoptedMetro = await ensureAdoptedMetro(bootCtx, device, { + explorationMode: options.explorationMode ?? 'auto' + }) + // Deliberately an attribute value on the existing `app_flow.runtime.connect` series rather + // than a new one: this is the same question that series already answers — did this run get + // a runtime — asked once up front instead of at the first node. + recordRuntimeTiming('app_flow.runtime.connect', performance.now() - metroCheckStartedAt, { + outcome: + adoptedMetro.status === 'degraded' + ? 'unavailable' + : adoptedMetro.status === 'recovered' + ? 'recovered' + : 'ready' + }) + if (adoptedMetro.status === 'recovered') { + emit(projectId, onEvent, { + type: 'phase', + phase: 'boot', + message: `Metro was not running; restarted it on port ${adoptedMetro.metroPort}.` + }) + } + if (adoptedMetro.warning) { + console.warn(`[app-flow] ${adoptedMetro.warning}`) + emit(projectId, onEvent, { + type: 'phase', + phase: 'boot', + message: adoptedMetro.warning + }) + } + // The adopt path's whole purpose is that the app is *already* up: the user pressed Launch, + // `index.ts` ran `waitForAppReady` to completion, and the app has been sitting on a + // settled screen ever since. This branch used to throw that away with + // `relaunchBoundApp(bootCtx, device)` regardless, and on a dev-client build — which has no + // `launchUrl` — a bare `--relaunch` lands on the Expo launcher. The 2026-10-09 social-app + // sweep paid the whole bill for it: warm and settled at t=43.9s, relaunched at t=65.1s, + // and not back on a real screen until t=93.6s. In between it lost the entry-chrome pass + // outright ("app 'xyz.blueskyweb.app' is not running"), spent two ~3.4s `arrival Lists` + // model calls judging the Expo launcher, and cached an auth verdict taken off a dead app. + // Twenty-eight seconds and three wrong answers, to undo the one thing pre-launching is for. + // + // So: ask first, act only if the answer is no. The probe is the same floored wait the + // fresh-boot branch runs below — see item 3's note there — which is what makes "ready" + // mean a frame that both settled *and* cleared `PROBE_FRAME_ELEMENT_FLOOR`. The floor is + // load-bearing here and was the second half of the bug: the Expo launcher settles at 3 + // interactive elements, and an unfloored wait called that ready twice in the log above. + // + // What this deliberately does not check is *which* screen the app is on. A pre-launched + // app the user poked at for twenty seconds (43.9s → 64.2s in that run) can be several + // screens deep, and nothing cheap can tell that from the entry screen. Three reasons that + // is the right trade: the entry-chrome pass reads *chrome*, which in a tabs or drawer app + // is present on every screen and is already wrapped in a `try/catch` that treats a miss as + // "continue without it"; the deep-link probe and every reach strategy open a URL or walk + // from wherever they are rather than assuming home; and the alternative — re-rooting + // unconditionally — is precisely the 28s mistake above. A drifted-but-live app costs at + // worst a slightly poorer chrome inventory. A relaunched one costs the sweep's first + // half-minute every time. + const adoptedAppReady = await waitForAppReady( + bootCtx, + device, + signal, + ADOPTED_APP_READY_BUDGET_MS, + { + minInteractiveElements: PROBE_FRAME_ELEMENT_FLOOR, + minInteractiveElementsGraceMs: SUBSTANTIVE_FRAME_GRACE_MS + } + ) + if (adoptedAppReady) { + console.log( + '[app-flow] adopted the launched app as-is — it is already settled on a substantive frame, so no relaunch' + ) + } else { + // Genuinely not ready: backgrounded, wedged, sitting on the dev-client launcher, or the + // session died while the user was reading the App Flow screen. Re-root with the *soft* + // `goHome` rather than `relaunchBoundApp`, so a dev client that is merely on the wrong + // screen keeps the bundle it has already evaluated; and allow the dev-client fallback, + // because for a `native-run` build the URL that re-roots is `devClientUrl` and + // `launchUrl` is null — opening the null one is the no-op `reconnectDevClient`'s comment + // records sixteen failures of. Safe to ask for it only here, after the wait above has + // established the app is not loaded. + console.log( + '[app-flow] adopted app is not on a substantive frame — soft re-rooting it instead of relaunching' + ) + await goHome(bootCtx, device, { reconnectDevClientWhenUnloaded: true }) + // `goHome`'s own wait only asks "did something settle", which the 3-element launcher + // frame answers yes to. Re-assert the floor so the auth gate below is never handed one. + await waitForAppReady(bootCtx, device, signal, undefined, { + minInteractiveElements: PROBE_FRAME_ELEMENT_FLOOR, + minInteractiveElementsGraceMs: SUBSTANTIVE_FRAME_GRACE_MS + }) + } + } else { + emit(projectId, onEvent, { + type: 'phase', + phase: 'boot', + message: 'Waiting for the app to finish loading...' + }) + console.log('[app-flow] waitForAppReady start') + // See the history of this floor: the wait the sweep always pays must outlast a dev-client + // loading frame, or the auth gate re-waits for the same event. Graced rather than held to + // `timeoutMs` because this wait cannot tell a splash from a login screen. + await waitForAppReady(bootCtx, device, signal, undefined, { + minInteractiveElements: PROBE_FRAME_ELEMENT_FLOOR, + minInteractiveElementsGraceMs: SUBSTANTIVE_FRAME_GRACE_MS + }) + console.log('[app-flow] waitForAppReady done') + } + timing.excludeWall(Date.now() - bootStartedAt) + + await pruneTransientScreens(projectId) + await prepareScratchDir(projectId) + + // The gate runs only after a launch or relaunch, so a launch that is already past auth is + // classified once. `stored` keeps the first launch's screenshot; later launches overwrite it + // only when they turn out to be gated, and the version counter busts the renderer's cache + // for the single reused URL. + let authCheckpointStored = false + let authCheckpointVersion = 0 + /** Consecutive gate checks whose every attempt came back empty — see `runAuthGate`. */ + let consecutiveSilentAuthChecks = 0 + /** Who cleared the gate for this sweep, or null while it still has to be asked — see + * `reach/auth-gate-policy.ts` and the `checkAuthGate` wrapper below. */ + let authSettledBy: 'classifier' | 'user' | null = null + + /** One full run of the gate. Reports *how* it concluded, not just what it returned, because + * that is what decides whether the sweep may stop asking — see `settlesAuthGateForRun`. */ + const runAuthGate = async ( + remaining: number, + budget?: ActiveTimeBudget + ): Promise => { + /** Sticky across the loop below: a Continue sends it round again for a fresh screenshot, + * and the clear that follows is still the user's doing. */ + let userResolved = false + for (;;) { + checkSignal(signal) + if (budget?.expired) + return { outcome: 'exhausted', verdict: null, substantiveFrame: false, userResolved } + // Don't hand the classifier a splash. `waitForAppReady` has already run by now and + // reports a *stable* frame, which a dev-client loading screen is — see + // `AUTH_GATE_FRAME_WAIT_MS` for the run where that cost three calls and a user prompt. + // Bounded, and never a precondition: a real login screen is legitimately sparse, so on + // expiry the gate classifies whatever is on screen exactly as it always did. + const launchElements = await capturePressables(ctx, device) + .then((pressables) => pressables.length) + .catch(() => null) + /** + * Whether the frame about to be classified ever cleared `PROBE_FRAME_ELEMENT_FLOOR`. + * Carried into the resolution because it, and not the classifier, is what decides + * whether the answer may be cached for the sweep — see `substantiveFrame` in + * `reach/auth-gate-policy.ts`. + */ + let substantiveFrame = isSubstantiveFrame(launchElements) + if (!substantiveFrame) { + console.log( + `[app-flow] auth-gate — app shows ${launchElements ?? 'an unreadable frame'} interactive element(s), below the ${PROBE_FRAME_ELEMENT_FLOOR} a settled screen reads; waiting up to ${AUTH_GATE_FRAME_WAIT_MS / 1000}s before classifying` + ) + const waitStartedAt = Date.now() + // One continuous wait rather than a poll loop, and the floor pushed into the wait + // itself — the lesson `probeDeepLinkRoute` records just below, where asking a settled + // loading screen thirteen separate times cost 13.7s to learn nothing. + // + // The return value is kept, which it was not before. `waitForAppReady` reports false + // for three very different endings — the window expired, a launcher would not clear, + // or the device stopped answering three reads in a row — and the last of those is the + // only one that can end a fifteen-second window in 1.9s, which is exactly what the + // 2026-10-09 social-app sweep logged before the classifier was handed a screenshot of + // a dead app and said `clear` about it. + substantiveFrame = await waitForAppReady(ctx, device, signal, AUTH_GATE_FRAME_WAIT_MS, { + minInteractiveElements: PROBE_FRAME_ELEMENT_FLOOR + }) + checkSignal(signal) + console.log( + `[app-flow] auth-gate — classifying after ${((Date.now() - waitStartedAt) / 1000).toFixed(1)}s${substantiveFrame ? '' : ' on a frame that never became readable; this verdict will not be cached for the sweep'}` + ) + } + const probe = getScratchPath(projectId, 'auth-probe') + await captureScreenshot(ctx, device, probe) + let verdict: { verdict: 'auth' | 'clear' | 'uncertain'; reason: string } | null = null + // Distinguishes "the classifier looked and was unsure" from "the classifier never + // answered". Both leave `verdict` null, and treating them the same produced an + // unbreakable loop on device: a provider change made every call return an empty body, the + // gate read the missing verdict as "cannot rule out a login wall", prompted the user, and + // then re-ran the identical failing call when they pressed Continue — forever, with a + // message blaming their launch screen for what was actually a model outage. + let emptyResponses = 0 + let attempts = 0 + for ( + let attempt = 0; + attempt < 2 && (!verdict || verdict.verdict === 'uncertain'); + attempt++ + ) { + attempts++ + const response = await runAgent( + { + cwd: projectPath, + systemPrompt: + 'Classify the mobile screenshot. Return strict JSON: {"verdict":"auth"|"clear"|"uncertain","reason":"short reason"}. Auth includes login/signup landing screens even with guest access. An incidental sign-in link on a normal content screen is clear. Do not infer credentials or suggest actions.', + userPrompt: 'Is the current app UI an authentication gate?', + tools: [], + // The cheapest question the sweep asks: one screenshot, two outcomes, no context to + // weigh. It was nonetheless spending 472 output tokens per call, 91% of them + // reasoning, across 14 calls in one run. + // + // `off`, not `minimal`. This is the call that asked for `minimal` — a level this + // backend answers with `400 Unexpected reasoning effort minimal`, which is what + // produced the empty auth-gate verdicts blamed on the endpoint in `provider.ts`'s + // old comment. `off` maps to `reasoning_effort: "none"`, measured at 0 reasoning + // tokens with the JSON verdict still returned. + thinkingLevel: 'off', + imagePath: probe, + timeoutMs: Math.max(1_000, Math.min(60_000, budget?.remainingMs ?? 60_000)) + }, + 'auth-gate' + ) + if (!response.text.trim()) emptyResponses++ + verdict = parseSubAgentJson(response.text) + } + const classifierSilent = attempts > 0 && emptyResponses === attempts + const stored = getScreenshotPath(projectId, AUTH_CHECKPOINT_SLUG) + if (!authCheckpointStored || verdict?.verdict !== 'clear') { + await copyFile(probe, stored) + authCheckpointStored = true + authCheckpointVersion += 1 + } + await unlink(probe).catch(() => undefined) + if (verdict?.verdict === 'clear') + return { outcome: 'clear', verdict: 'clear', substantiveFrame, userResolved } + // Nothing the user can do at the device fixes a classifier that is not responding, and + // `Continue` only buys another identical failure — so say so plainly and give up rather + // than prompt. One retry round first, because a single empty response can be a blip. + if (classifierSilent) { + consecutiveSilentAuthChecks++ + console.log( + `[app-flow] auth-gate — classifier returned an empty response ${emptyResponses}/${attempts} times (round ${consecutiveSilentAuthChecks}); this is a model or endpoint failure, not the app's launch screen` + ) + if (consecutiveSilentAuthChecks >= 2) { + throw new Error( + 'The screen classifier returned no response. App Flow cannot tell whether the app is showing a login screen, so the build has stopped. This is a model or endpoint problem rather than something to fix in the app.' + ) + } + } else { + consecutiveSilentAuthChecks = 0 + } + if (!options.requestUserAction) + return { + outcome: 'stop', + verdict: verdict?.verdict ?? null, + substantiveFrame, + userResolved + } + manifest.sweepStatus = 'awaiting-user' + await writeManifest(projectId, manifest) + const prompt: AppFlowPrompt = { + id: randomUUID(), + kind: verdict?.verdict === 'auth' ? 'auth' : 'review', + detail: + verdict?.verdict === 'auth' + ? 'Complete auth in the simulator. Once logged in, click Continue.' + : `Could not confidently classify this launch screen${verdict?.reason ? `: ${verdict.reason}` : '.'}`, + screenshot: screenshotUrl( + projectId, + `screens/${AUTH_CHECKPOINT_SLUG}.png?v=${authCheckpointVersion}` + ), + remaining + } + emit(projectId, onEvent, { + type: 'prompt', + phase: 'capture', + message: + verdict?.verdict === 'auth' + ? 'Authentication required.' + : 'Launch screen needs review.', + prompt + }) + const waitStartedAt = Date.now() + const resolution = await options.requestUserAction(prompt) + budget?.exclude(Date.now() - waitStartedAt) + if (resolution === 'stop') + return { + outcome: 'stop', + verdict: verdict?.verdict ?? null, + substantiveFrame, + userResolved + } + userResolved = true + manifest.sweepStatus = 'running' + await writeManifest(projectId, manifest) + if (resolution === 'override' && verdict?.verdict !== 'auth') + return { + outcome: 'clear', + verdict: verdict?.verdict ?? null, + substantiveFrame, + userResolved + } + // Continue intentionally rechecks a fresh screenshot without relaunching. + } + } + + /** + * The gate as the sweep calls it: asked until something decisive comes back, then answered + * from memory for the rest of the run. + * + * The short-circuit is why this wrapper exists rather than the caching living inside + * `runAuthGate`: every caller — sweep start and the `AuthPort` the reach strategies go + * through — gets the skip for free, and none of + * them has to know the rule. A skipped call costs no screenshot and no classifier call, which + * is the whole saving; `settlesAuthGateForRun` decides what counts as decisive. + */ + const checkAuthGate = async ( + remaining: number, + budget?: ActiveTimeBudget + ): Promise<'clear' | 'stop' | 'exhausted'> => { + // Fast does not run the gate at all. The gate is a screenshot plus a classifier call — one + // model call per sweep, made before anything is captured — and Fast's premise is that it + // makes none. Nothing local is substituted for it: a heuristic that guessed at a sign-in + // wall would be a second thing to be wrong about, and the honest division of labour is + // that in Fast the user launches the app and finishes any sign-in *before* the sweep, which + // the pre-run banner tells them to do. + // + // `clear` is the degradation, and it is the right one: it is what the gate returns when it + // sees a usable screen, so every downstream caller (the pre-sweep check, `AuthPort.checkGate` + // inside the rungs) proceeds exactly as it does on a cleared gate. The loss is that a Fast + // sweep run against a signed-out app no longer stops to say so — it captures the sign-in + // wall under every route instead, which is a bad run the user can see, rather than a + // stopped one they cannot. + if (options.explorationMode === 'fast') return 'clear' + if (authSettledBy) return 'clear' + const resolution = await runAuthGate(remaining, budget) + authSettledBy = settlesAuthGateForRun(resolution) + if (authSettledBy) { + console.log( + `[app-flow] auth-gate — cleared by the ${authSettledBy}; settled for this sweep, later launches will not re-check` + ) + } else if (resolution.outcome === 'clear' && !resolution.substantiveFrame) { + // Worth a line of its own: the sweep is continuing on a `clear` it is deliberately + // refusing to remember, and without saying so the next launch's extra gate call looks + // like the duplicate-call bug this memo was added to fix. + console.log( + '[app-flow] auth-gate — cleared, but the classified frame never became readable; not cached, the next launch will ask again' + ) + } + return resolution.outcome + } + + // The seam the reach-attempt closures below call through instead of `capture.ts`/ + // `device.client` directly — see `reach/ports.ts` and `reach/adapters.ts`. Deliberately NOT + // used by `checkAuthGate` above, `resolveBlocked` below, or device boot: those own a user + // prompt, `manifest.sweepStatus`, or are one-shot setup, and the plan keeps them outside + // this seam permanently rather than routing them through a ladder of reach strategies. + /** + * DEEP-LINK ARRIVAL JUDGE — the single place `options.deepLinkJudgeMode` is consumed. + * + * Passed straight through to the deep-link rung's `arrivalJudge` and nowhere else: the + * exploration walk's own arrival checks are deliberately out of this switch's reach (see + * `DeepLinkJudgeMode`). False means the strategy decides a 404 from the controls on screen + * instead of a model call — `reach/not-found-signal.ts`. + * + * "Off" is narrower than it was, and the log line says so rather than overstating it: the + * strategy keeps the judge for any route that declares params, because seven of the eight + * wrong captures in `session_2026-10-13_14-33-40.log` were param routes. The decision lives + * in `routeDeclaresParams` (`reach/strategies/deep-link.ts`), next to the node whose params + * it is about, rather than here where only the sweep-wide setting is known. + */ + const deepLinkArrivalJudge = (options.deepLinkJudgeMode ?? 'on') === 'on' + console.log( + `[app-flow] deep-link arrival judge: ${ + deepLinkArrivalJudge + ? 'on' + : 'off for routes with no params (not-found detected from the screen); routes with params keep the judge' + }` + ) + + /** + * The reach path's own meters, handed to the ports as a sink so `reach/` keeps its distance + * from the Sentry SDK (`ReachMetricsPort`). Same helper as the three `app_flow.runtime.*` + * series above, so `MOBILE_DEV_TELEMETRY=off` is honoured for these too, and the + * `surface: 'app-flow'` attribution is the one the rest of the sweep already carries. + * + * One measurement per operation. The settle loop's 40ms polls and the sweep log's per-node + * records are deliberately *not* here: the first is a frequent sample, and the second is + * local product detail that never leaves the machine. + */ + const reachMetrics: ReachMetricsPort = { + navigate(outcome) { + recordRuntimeCount('app_flow.runtime.navigate', { outcome }) + }, + settle(durationMs, stop) { + recordRuntimeTiming('app_flow.runtime.settle', durationMs, { stop }) + }, + verify(durationMs, path, verdict) { + recordRuntimeTiming('app_flow.runtime.verify', durationMs, { path, verdict }) + } + } + /** + * One persistent Metro/CDP runtime for deterministic Fast navigation and readiness. + * + * The handshake, the failure latch and the three `app_flow.runtime.*` meters moved to + * `cdp/app-runtime-transport.ts` when runtime replay gained a second caller for exactly + * this connection; the sweep's behaviour is unchanged. `fastRuntime` is still the handle + * the rest of this function reaches for — `state`, `invoke` and `close` delegate to the one + * connection underneath, and `connection` is what the UI-state session is handed. + */ + fastRuntime = createAppRuntimeTransport({ + metroPort: device.metroPort, + deviceId: device.deviceId, + deviceName: device.deviceName, + appName: parsed.appName, + sessionId: `fast-${projectId}`, + signal, + // Set from the transport rather than inline so one failed handshake is not retried for + // every later node — the sweep's `navState === 'unknown'` fallback reads this. + onAvailability: (availability) => { + paramStore.navState = availability + } + }) + const renderTransport = fastRuntime.transport + + /** + * The per-run log file — Fast only, and best-effort. + * + * Fast only because it is the mode whose per-node cost this log exists to explain: a + * dispatch, a bounded settle and one verification, each of which already produces the + * numbers written here. The other modes spend their time inside model-driven walks, which + * this record has nothing to say about. + * + * Null whenever the file could not be opened, and every consumer treats the port as + * optional, so a sweep with no writable data directory is a sweep with no log — never a + * sweep that fails. `createSweepLog` also prunes older run files, at sweep start, in the + * style of `pruneTransientScreens`. + */ + const sweepLog = + options.explorationMode === 'fast' + ? await createSweepLog({ + projectId, + // The sweep has no run id of its own (the run store's lives in the server layer), so + // this names the file after a fresh one; the timestamp is what actually identifies + // the run, and the suffix only keeps two sweeps started in the same second apart. + runId: randomUUID(), + startedAt + }).catch(() => null) + : null + sweepLogOnExit = sweepLog + if (sweepLog) console.log(`[app-flow] run log — ${sweepLog.path}`) + + const ports = createReachPorts({ + ctx, + device, + projectId, + projectPath, + runAgent, + checkAuthGate, + signal, + renderTransport, + sweepLog, + metrics: reachMetrics, + expoRouter: parsed.mode === 'expo-router', + // Fast fails the settle poll over at 3s unless the screen is visibly still loading, in + // which case it may run to 8s. Every other mode keeps the single 8s bound it has always + // had, so the poll's behaviour outside Fast is unchanged. + settleDeadlinesMs: options.explorationMode === 'fast' ? [3_000, 8_000] : [8_000] + }) + + /** + * UI-state capture, folded into the sweep: while the device is still standing on a screen + * this sweep has just delivered, open each UI state its own source proves it can open, + * photograph it and roll it back (`explore-ui.ts`). Opt-in, and a second pass over every + * screen is exactly what this replaces. + * + * Per project, once: the static catalog and the connection. This rides the sweep's own CDP + * runtime (`renderTransport.ready()` is idempotent — Fast already holds it, and auto/thorough + * open it here on first use and `close` it with the sweep) rather than dialling a second + * debugger onto the same Hermes target. Per screen, inside the primitive: the live-binding + * collection, because bindings only exist for what is mounted. + * + * Progress goes out as `step` events carrying the sweep's own counters. The pass's + * per-state `completed`/`total` are deliberately dropped: the panel reads those fields as + * the sweep summary, and a state count there would read as a screen count. + */ + const uiStateCapturer = options.captureUiStates + ? createSweepUiStateCapturer({ + signal, + note: (message) => + emit(projectId, onEvent, { + type: 'step', + phase: 'capture', + message, + completed: sweepQueue.completed, + total: sweepQueue.total, + captured: explorationControl.screensExplored, + captureLimit: options.screenLimit + }), + report: (error) => captureServerError(error, 'app_flow.capture_ui_states'), + // Counts and categories into this node's run-log record (closed by `nodeLog.finish`). + onScreen: (screenNode, log) => sweepLog?.node(screenNode.id).uiStates(log), + open: async () => { + const ready = await renderTransport.ready() + const runtime = fastRuntime?.connection + if (!ready.ok || !runtime) { + // A benign state of the surface (Metro down, an app that refused the agent), so the + // reporter counts it rather than filing an exception. + throw new ExpectedOperationError( + 'device_unavailable', + `the app’s runtime could not be reached (${ready.ok ? 'not connected' : ready.reason})` + ) + } + return openSweepUiStateSession({ + projectPath, + projectId, + ctx, + device, + runtime, + signal, + // Per-state `phase`/`step` messages from the pass are not forwarded: see above. + emit: () => undefined + }) + } + }) + : null + + // A sweep with no decision provider is only reachable in Fast (see the throw above), and the + // one role that can still be asked for there is the arrival judge, on a node whose runtime + // evidence came back incomplete. `runAgent` would throw on that call, and nothing between + // the rung and the node loop catches it, so one ambiguous screen would take the whole sweep + // down. An `uncertain` verdict is the honest answer instead: it is what the judge port + // already returns when it cannot produce one, the ladder reads it as a miss, and the node is + // recorded unreached with a reason that says why rather than a screenshot nothing verified. + if (!options.decisionProvider) { + ports.judge = { + verifyArrival: async () => ({ + verdict: 'uncertain', + status: null, + reason: 'No decision provider, so arrival could not be judged from the screenshot.' + }) + } + } + + // Said once, in the progress stream the UI is already reading, so a Fast run that walks + // straight into a sign-in wall is not a mystery afterwards: the check the other modes make + // here was not skipped by accident. `checkAuthGate` below is a no-op in this mode. + if (options.explorationMode === 'fast') { + emit(projectId, onEvent, { + type: 'phase', + phase: 'boot', + message: + 'Fast mode skips the sign-in check — make sure the app is launched and signed in before the sweep.' + }) + } + if ((await checkAuthGate(nodesWithSlug.length)) === 'stop') { + const paused = options.pauseRequested?.() ?? false + manifest.sweepStatus = paused ? 'paused' : 'stopped' + await finishManifest(manifest) + await writeAppFlow(projectId, { ...partialResult, manifest }) + emit(projectId, onEvent, { + type: 'complete', + phase: 'render', + message: paused ? 'Screen sweep paused.' : 'Screen sweep stopped.' + }) + return { ok: true, projectId, result: partialResult } + } + + checkSignal(signal) + emit(projectId, onEvent, { + type: 'phase', + phase: 'capture', + message: 'Capturing screenshots...' + }) + + // A sweep that was stopped, cancelled or crashed leaves a per-screen checkpoint behind. It + // is only reused when the parsed structure still fingerprints the same, so routes that moved + // force a full sweep rather than resuming onto screens that no longer exist. + const resumed = await readSweepProgress(projectId) + const explorationMode = options.explorationMode ?? 'auto' + const reconciled = reconcileSweepProgress(resumed, { + structureKey, + explorationMode, + sourceHash: fastSourceHash, + fastFingerprint + }) + const { resuming, checkpoint } = reconciled + if (resumed?.structureKey === structureKey && !resuming) { + console.log( + `[app-flow] checkpoint pipeline changed — kept ${reconciled.retained} successful capture(s), re-queued unfinished screens` + ) + } + // The one condition under which this build inherits the previous one's elapsed time: it is + // continuing the same sweep the checkpoint describes — the same test, in the same place, the + // captures themselves are reused under. A completed sweep deletes that checkpoint, so the + // next build is a fresh chain no matter what the manifest still shows; a re-parse of a moved + // structure fails this test for the same reason. Both should (and now do) reset the total. + if (resuming) { + carriedDurationMs = carriedSweepDurationMs(previousManifest, true) + } + + const captureMap: Record = { ...checkpoint.captures } + const openedByNode: Record = { ...checkpoint.opened } + const inferredScreens: Record = { + ...checkpoint.inferred + } + + // Runtime state accumulated across the WHOLE sweep (plan Steps 3d, 4a) — never per-node, since + // a chrome or a runtime screen discovered while exploring toward one node is exactly as + // reusable for every other node behind it. Seeded from the checkpoint so a resumed sweep + // reuses the same ids/edges rather than re-minting/re-detecting them under new ones — see + // `AppFlowSweepProgress.runtimeNodes`'s doc comment in `storage.ts`. + const runtimeNodes: AppFlowNode[] = [...(checkpoint.runtimeNodes ?? [])] + const runtimeEdgesList: AppFlowEdge[] = [...(checkpoint.runtimeEdges ?? [])] + const chromeInventory: AppFlowChrome[] = [...(checkpoint.chrome ?? [])] + /** + * Where the walk currently in progress is standing, and what it has tapped since — the state + * `noteWalkArrival` (`reach/walk-edges.ts`) turns into `origin: 'runtime-walk'` edges. Held at + * sweep scope rather than inside `exploreFrom` because the confirmations that feed it arrive + * through `applyDiscoveredFact` below, which is deliberately ignorant of any one walk; null + * whenever no exploration is in flight, so a confirmation that reaches it from anywhere else + * (a deep-link arrival, an opportunistic sighting during delivery) records nothing rather than + * inventing a hop out of whichever screen a previous walk happened to end on. + * + * Not checkpointed: a resumed sweep relaunches the app, so the position a cursor held before + * the interruption says nothing about where the next walk starts. + */ + let walkCursor: WalkCursor | null = null + /** Folds a confirmed arrival into `runtimeEdgesList` when the hop that produced it was a + * single tap from a screen this sweep had already confirmed — see `noteWalkArrival`. */ + const noteArrival = (nodeId: string): void => { + if (!walkCursor) return + const edge = noteWalkArrival(walkCursor, nodeId, { + structureRevision, + observedAt: new Date().toISOString() + }) + if (!edge) return + mergeWalkEdge(runtimeEdgesList, edge) + console.log(`[app-flow] walked edge — ${edge.from} -> ${edge.to} (${edge.raw})`) + } + + // Deliberately seeded from `nodesWithSlug` (the carry-forward already validated against each + // node's current structure signature), never from `checkpoint` — `sweep-progress.json` is + // wiped by `clearSweepProgress` on every completed sweep, exactly when this suppression most + // needs to outlive it. Updated in place as the sweep runs and flushed to `app-flow.json` + // through `persistCaptureUpdate`, the same durable path `capture` already rides. + const deepLinkRejections: Record = {} + for (const node of nodesWithSlug) { + if (node.deepLinkRejection) deepLinkRejections[node.id] = node.deepLinkRejection + } + + // What the app opens on DIRECTLY — each entry layout's own `initialRouteName` (see + // `entryScreenIds` on `ParsedAppGraph`). This intentionally does NOT filter by `group` + // membership: `group` is attribution (which navigator registers a screen — a screen behind a + // shared helper called from five tabs belongs to all five of them), not reachability. Filtering + // by group previously put 78 of social-app's 83 screens "one tap from launch" — most of them + // behind that shared helper — which both fabricated a one-hop journey for screens actually many + // pushes deep and suppressed the "explore from the entry chrome" instruction for genuine + // orphans. `entryScreenIds` is already resolved to real node ids, so it is used as-is. + const entryNodeIds = parsed.entryScreenIds + const appEntryBrief = parsed.rootNavigator + ? `App entry: a ${parsed.rootNavigator} navigator. Its entries are ${parsed.entryLayouts.join(', ')}. Every journey starts there, so when you have no better lead, go back to that chrome and open the entry the target belongs to.` + : 'App entry: unknown; treat the launch screen as the start of every journey.' + const orphans = nodesWithSlug.filter( + (candidate) => !findNavigationPath(parsed.edges, candidate.id, entryNodeIds) + ).length + // How each screen can be reached by URL is the number that predicts what a sweep costs: the + // reach ladder tries the deep-link rung on every node regardless, so a screen with a plain + // path is one cheap `openUrl`, while one whose path has a `[param]` segment cannot be opened + // until something supplies a value, and one with no path (or only the root `/`, which every + // launch already lands on and so proves nothing) has no deep link to try at all. The orphan + // count is a different animal: it is a BFS over the *parsed* edge graph, which only sees + // `router.push` call sites the static parser could resolve, so it counts screens the parser + // failed to connect and says nothing about whether they are reachable. It gates nothing and + // was once reported as "need live exploration" while all 76 of Bluesky's real screens were in + // the linking config; it is kept only as a statistic about the parsed graph. + const hasUrlPath = (node: (typeof nodesWithSlug)[number]): boolean => + Boolean(node.urlPath) && node.urlPath !== '/' + const deepLinkable = nodesWithSlug.filter( + (node) => hasUrlPath(node) && node.params.length === 0 + ).length + const paramBlocked = nodesWithSlug.filter( + (node) => hasUrlPath(node) && node.params.length > 0 + ).length + const noUrl = nodesWithSlug.length - deepLinkable - paramBlocked + console.log( + `[app-flow] entry — root=${parsed.rootNavigator ?? 'unknown'} layouts=[${parsed.entryLayouts.join(', ')}] ` + + `entryScreens=${entryNodeIds.length} — ${deepLinkable}/${nodesWithSlug.length} reachable by deep link, ` + + `${paramBlocked} param-blocked until a value is observed, ${noUrl} with no URL; ` + + `${orphans} have no static path from an entry in the parsed edge graph` + ) + const total = nodesWithSlug.length + const alreadyCaptured = nodesWithSlug.filter((node) => captureMap[node.id]).length + if (alreadyCaptured > 0) { + console.log( + `[app-flow] resuming sweep — ${alreadyCaptured}/${total} screens already checkpointed` + ) + emit(projectId, onEvent, { + type: 'phase', + phase: 'capture', + message: `Resuming: ${alreadyCaptured} of ${total} screens already captured.` + }) + } + + const navStateDeps: NavStateDeps = { + run: (command, timeoutMs) => runShellCommand(command, projectPath, timeoutMs, signal), + cdpCommand: (args) => agentDeviceCdp(ctx.sessionName, args), + quote: shellQuote, + log: (message) => console.log(`[app-flow] nav-state — ${message}`) + } + + /** + * Ask the running app's navigator for the param values nothing else can see — but only when + * this screen is plausibly the one holding them. + * + * The read sees the *mounted* navigator state and its stack history, not the app's whole + * database, so it is worth making right after capturing a screen that is an ancestor of some + * route still blocked on a param, and worth nothing at all otherwise. That matters because + * it is a CLI round trip: run eagerly it would be a tax on all 76 screens for values that + * are not there; run on this schedule it is a handful of calls a sweep. Ten of Bluesky's + * routes carry an `:rkey` that appears in no testID anywhere in the app, so this is the only + * thing that reaches them by URL at all. + * + * "Sits above" is judged by `selectNavStateWaiters`: urlPath ancestry, plus a small fixed + * budget of speculative reads for screens ancestry cannot speak for. + * + * Strictly additive and strictly read-only: every failure path ends in `unavailable`, + * latched for the rest of the sweep, and the sweep carries on exploring exactly as it does + * today. A thrown error here must never cost a capture, hence the outer catch. + */ + let speculativeNavReads = 0 + const harvestNavState = async (from: AppFlowNode): Promise => { + if (paramStore.navState === 'unavailable') return + try { + const blocked = nodesWithSlug.filter( + (other) => + other.id !== from.id && + !captureMap[other.id] && + other.params.length > 0 && + other.urlPath && + resolveNodeParams(other, parsed.edges, paramStore).unresolved.length > 0 + ) + const { waiting, speculative } = selectNavStateWaiters( + from, + blocked, + nodesWithSlug, + NAV_STATE_SPECULATIVE_READS - speculativeNavReads + ) + if (!waiting.length) return + if (speculative) speculativeNavReads += 1 + + // The live Fast connection already holds a selected CDP target and answers structured + // commands, so when it is up this read is one round trip. The subprocess path below is + // the fallback for the modes that have no fast runtime (auto, thorough) and for a Fast + // run that latched `fastUnavailable`: it costs a `selectNavStateTarget` handshake + // (`SETUP_TIMEOUT_MS`, 30s) plus a chunked eval, and that handshake used to sit on the + // critical path of the very first screen that wanted a param value. + const live = fastRuntime?.state === 'ready' + if (!live && paramStore.navState === 'unknown') { + const ready = await selectNavStateTarget( + device.metroPort, + parsed.appName, + navStateDeps, + (output, preferred) => + pickMetroCdpTarget(parseCdpTargetList(output), preferred)?.id ?? null + ) + paramStore.navState = ready ? 'ready' : 'unavailable' + if (!ready) return + } + + const routes = live + ? await renderTransport.navState() + : await readNavState(navStateDeps) + if (!routes) { + paramStore.navState = 'unavailable' + return + } + let usable = 0 + for (const route of routes) { + if (route.path && paramStore.ingestUrl(route.path, from.id, nodesWithSlug)) usable += 1 + if (!route.params) continue + // A navigator names a screen the way the navigator registered it, which may be the + // tail of a route id the parser spells out in full (`Profile` vs `HomeTab/Profile`), + // or a sibling node backed by the same component file. Only an unambiguous match is + // used: two routes answering to one name says nothing about which record these + // params belong to. + const target = resolveNavRouteTarget(nodesWithSlug, route.name) + if (!target) continue + if (paramStore.ingestNavRoute(target, route.params, from.id)) usable += 1 + } + if (usable) { + console.log( + `[app-flow] nav-state — ${routes.length} route(s) read on ${from.urlPath ?? from.id}, ${usable} usable` + ) + } else { + // A read that returns routes and yields nothing is the silent failure that made a + // whole sweep unexplainable: the values were on screen, the store stayed empty, and + // the log said nothing at all. The route names are what discriminates the cause — a + // navigator that registered a screen under a name no node answers to is, in the + // store, indistinguishable from one that exposed no params in the first place. The + // first dozen names are enough to recognise a stack without flooding the log. + const seen = routes + .slice(0, 12) + .map( + (route) => + `${route.name}${route.path ? ' +path' : ''}${route.params ? ' +params' : ''}` + ) + .join(', ') + console.log( + `[app-flow] nav-state — ${routes.length} route(s) read on ${from.urlPath ?? from.id}, none usable, ${waiting.length} node(s) still blocked — saw: ${seen}${routes.length > 12 ? ', …' : ''}` + ) + } + } catch (error) { + paramStore.navState = 'unavailable' + console.log( + `[app-flow] nav-state — read failed, not retrying — ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + /** + * Ask the app for the destinations its link elements name, and feed them to the param store. + * + * This is the only source that can hand the store a *multi-param* record. `ingestPressables` + * and the affinity bootstrap are single-identifier by design (see `link-harvest.ts` for why + * that cannot be relaxed), and navigator state only knows routes the app has already + * entered. A feed row's `href` carries `name` and `rkey` together, composed by the app, for + * a post nobody has opened — which on a measured sweep is nine of the sixteen screens that + * were unreached purely for want of params. + * + * **Fast only.** It rides the live CDP connection that mode already holds, so it is one + * round trip with no target handshake. The modes without that connection (auto, thorough) + * reach these screens through model-driven exploration instead, and giving this path a + * chunked CLI fallback would buy them a slower route to an answer they already have. + * + * Skipped once nothing is left to resolve: if no uncaptured node still has an unresolved + * param, the read can only confirm what the store knows. That is what stops this being a + * flat tax on every screen late in a sweep. + * + * Strictly additive and strictly read-only, like `harvestNavState`: every failure latches + * `linksUnavailable` for the rest of the sweep and the sweep carries on unchanged. + */ + let linksUnavailable = false + const harvestLinks = async (from: AppFlowNode): Promise => { + if (linksUnavailable || fastRuntime?.state !== 'ready') return + const blocked = nodesWithSlug.some( + (other) => + !captureMap[other.id] && + other.params.length > 0 && + resolveNodeParams(other, parsed.edges, paramStore).unresolved.length > 0 + ) + if (!blocked) return + try { + const reply = await fastRuntime.invoke( + { type: 'links', max: LINK_CANDIDATE_CAP }, + 5_000 + ) + const links = reply?.links ?? [] + if (!links.length) { + recordRuntimeCount('app_flow.runtime.links', { outcome: 'empty' }) + return + } + const { usable, unmatched } = ingestLinkCandidates( + links, + from.id, + nodesWithSlug, + paramStore + ) + recordRuntimeCount('app_flow.runtime.links', { + outcome: usable ? 'usable' : 'unmatched' + }) + console.log( + `[app-flow] links — ${links.length} destination(s) on ${from.urlPath ?? from.id}, ${usable} usable, ${unmatched} unmatched${reply.capped ? ', capped' : ''}` + ) + } catch (error) { + linksUnavailable = true + recordRuntimeCount('app_flow.runtime.links', { outcome: 'failed' }) + console.log( + `[app-flow] links — read failed, not retrying — ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + /** + * The crash-resume checkpoint, coalesced. + * + * This write is ~38KB of JSON and it used to go out unthrottled on every screen *and* on + * every opportunistic sighting, chrome detection and runtime-node mint — several times a + * screen on a busy walk, each one re-serialising the whole capture map. The live document + * publisher next door was throttled for exactly this reason; the checkpoint never was. + * + * Two bounds, in `coalesced-write.ts` so the discipline is testable without a device: a + * revision counter, so a flush with nothing new since the last one writes nothing at all, + * and a 1.5s coalescing window, so a burst of mutations costs one write rather than five. A + * deferred write is never dropped — the window schedules a trailing write that carries it + * and everything that joined it. + * + * The flush discipline matters more than the throttle, because crash-resume is the entire + * point of this file: every durable path (stopped, finished, cancelled, failed, and the + * `finally`) calls this with `force`, so the window can only ever delay a write that a + * still-running sweep was going to supersede anyway. The worst case a kill can hit is 1.5s + * of work, and the resume logic re-explores that node rather than losing it — see the + * `checkpoint.captures[node.id]` skip at the top of the sweep loop. + */ + const CHECKPOINT_COALESCE_MS = 1_500 + const checkpointWriter = createCoalescedWriter({ + write: () => writeSweepProgressNow(), + windowMs: CHECKPOINT_COALESCE_MS, + now: activeNow, + onError: (error: unknown) => + console.warn( + `[app-flow] checkpoint write failed — ${error instanceof Error ? error.message : String(error)}` + ) + }) + const persistCheckpoint = async (options?: { force?: boolean }): Promise => { + await checkpointWriter.mark() + if (options?.force) await checkpointWriter.flush() + } + flushCheckpointOnExit = () => checkpointWriter.flush() + const writeSweepProgressNow = async (): Promise => { + await writeSweepProgress(projectId, { + structureKey, + explorationMode, + sourceHash: fastSourceHash, + fastFingerprint, + updatedAt: new Date().toISOString(), + captures: captureMap, + opened: openedByNode, + inferred: inferredScreens, + runtimeNodes, + runtimeEdges: runtimeEdgesList, + chrome: chromeInventory + }) + } + + /** + * The screens manifest as it stands right now — pure, so every writer may call it. + * + * One definition rather than the three identical nine-argument calls this file had, because + * the document's nodes are stamped from this map (`withReplayableCaptures`) and a writer + * working from a differently-built map would write a different answer. + */ + const currentAgentDeviceScreens = (): AgentDeviceScreensMap => + buildAgentDeviceScreens( + [...nodesWithSlug, ...runtimeNodes], + parsed.edges, + runtimeEdgesList, + chromeInventory, + ctx, + device, + openedByNode, + inferredScreens, + captureMap + ) + + /** + * The `app-flow.json` as it stands right now, assembled from the same live maps the finish + * and stopped paths use. + * + * One definition for all three writers because the renderer cannot tell which of them + * produced the document it just read, and a mid-sweep document that disagreed with the final + * one on anything beyond freshness (node set, replayable stamps, chrome filtering) would make + * the graph flicker on completion. `screens` is a parameter rather than a call to + * `currentAgentDeviceScreens` so the finish paths can hand in the same map they persist — + * `withReplayableCaptures` reads `pathVerified` off that map, and a separately-built copy + * could only ever re-derive it. + */ + const currentFlowDocument = (screens: AgentDeviceScreensMap): AppFlowResult => { + // Provisional timing so the header's clock keeps ticking while the sweep runs: the terminal + // writes stamp both fields again with their final values, and a reader that has to tell the + // two apart has `sweepStatus: 'running'` on exactly these documents. + manifest.durationMs = timing.wallMs + manifest.cumulativeDurationMs = carriedDurationMs + timing.wallMs + const screenNodes = withReplayableCaptures( + [ + ...nodesWithSlug.map((node) => ({ + ...node, + capture: captureMap[node.id] ?? node.capture, + deepLinkRejection: deepLinkRejections[node.id] ?? null + })), + ...runtimeNodes + ], + screens + ) + const navigators = navigatorChrome(chromeInventory, screenNodes) + const nodes = [...screenNodes, ...chromeVirtualNodes(navigators)] + return { + manifest, + nodes, + edges: parsed.edges, + runtimeEdges: rebuildChromeEdges(runtimeEdgesList, navigators, nodes), + chrome: chromeInventory, + navigation + } + } + + /** + * Publishes the graph document mid-sweep so the panel can draw screens as they finish. + * + * Throttled, deliberately, rather than one write per screen. The old per-screen document + * write was removed for being O(n²) bytes on the critical path between screens, and that + * reasoning still holds for a 200-screen app; what it missed is that a *bounded* write — + * at most one per interval, whatever the screen rate — gives the user a live graph at a cost + * that stops scaling with n. The per-screen checkpoint remains the only thing a resumed + * sweep reads; this document is a read model, its staleness window is one interval, and the + * terminal writes remain authoritative. + * + * Failures are swallowed with a log: a read model that could not be refreshed must never + * fail the sweep that was writing it. + */ + const LIVE_PUBLISH_INTERVAL_MS = 4_000 + let lastLivePublishAt = 0 + let livePublishInFlight: Promise = Promise.resolve() + const publishLiveDocument = (): Promise => { + const now = activeNow() + if (now - lastLivePublishAt < LIVE_PUBLISH_INTERVAL_MS) return livePublishInFlight + lastLivePublishAt = now + const began = now + livePublishInFlight = (async () => { + try { + await writeAppFlow(projectId, currentFlowDocument(currentAgentDeviceScreens())) + console.log(`[app-flow] live graph published in ${activeNow() - began}ms`) + } catch (error) { + console.warn( + `[app-flow] live graph publish failed — ${error instanceof Error ? error.message : String(error)}` + ) + } + })() + return livePublishInFlight + } + + /** + * The crash-resume checkpoint a captured (or settled-as-unreached) node leaves behind, plus + * the throttled live publication of the graph document. + * + * A checkpoint, not a publication: `persistCheckpoint` is what makes a crashed or stopped + * sweep resume at the right screen, and it is the only thing a resumed sweep reads. The + * flat capture-status map and the screens manifest are still rebuilt wholesale at finish + * and on the stopped path — rewriting those after every screen was O(n²) bytes for an + * intermediate nobody reads. The app-flow document is the one intermediate the renderer + * does read, so it now rides this call through `publishLiveDocument`, interval-bounded + * rather than per-screen. + * + * `deepLinkRejections` is the reason the finish and stopped node builds stamp + * `deepLinkRejection` themselves: the document write used to be its only durable path, and + * without that move every rejection learned this sweep would be lost and the next sweep + * would pay again for deep links already proven to 404. + */ + const persistCaptureUpdate = async (): Promise => { + await persistCheckpoint() + await publishLiveDocument() + } + + /** When the sweep loop started on the node it is working through, for the run log's + * `totalMs` — read by both terminal paths a node has, this one and delivery. */ + let nodeStartedAt = 0 + const recordUnreached = async ( + node: AppFlowNode, + status: AppFlowCaptureStatus, + note: string | null + ): Promise => { + console.log(`[app-flow] ${node.id} recorded ${status} — ${note ?? 'no reason given'}`) + sweepLog + ?.node(node.id) + .finish({ status, totalMs: nodeStartedAt ? Date.now() - nodeStartedAt : 0 }) + captureMap[node.id] = { + status, + // `unavailable` is a settled answer about this build, not a screen still to be reached, + // so it must not come back as outstanding navigation work on the next run. + needsNavigation: status !== 'unavailable', + note, + screenshot: null, + pressables: [], + captureMethod: 'unreached', + replayScript: null, + replayVerified: false + } + await persistCaptureUpdate() + } + + // The static vocabulary a chrome item's pressable text is matched against — every still-known + // node's own title/id/url tokens (`expectedNodeTokens`, `exploration.ts`), plus each entry + // layout's own name for the case where no single node stands for the whole layout. Rebuilt on + // demand rather than cached once: it is cheap pure token math, and it must reflect nodes + // learned about since the sweep started — a runtime node minted earlier this same sweep is + // itself a legitimate destination for a later chrome item. + const chromeCandidates = (): ChromeCandidate[] => [ + ...nodesWithSlug.map((candidate) => ({ + label: candidate.id, + toNodeId: candidate.id, + tokens: expectedNodeTokens(candidate) + })), + ...runtimeNodes.map((candidate) => ({ + label: candidate.id, + toNodeId: candidate.id, + tokens: new Set(candidate.runtimeSignature ?? []) + })), + ...parsed.entryLayouts.map((name) => ({ + label: `layout:${name}`, + toNodeId: null, + tokens: new Set(tokenize(name.replace(/([a-z0-9])([A-Z])/g, '$1 $2'))) + })) + ] + + // Landing-order item 5's real payoff without item 5 itself (`exploreFrom` stays inline this + // round — see the CHECKPOINT): `observed` reports what it noticed as a `DiscoveredFact` + // (`reach/types.ts`) instead of writing `captureMap`/`runtimeNodes`/`chromeInventory` directly, + // and this is the one place, right next to the state it owns, that folds a fact into the + // sweep. Three call sites feed it — the known-node opportunistic capture that used to write + // `captureMap` inline, the chrome detector, and the runtime-node matcher/minter — kept in one + // place so all three write through the same `persistCaptureUpdate` checkpoint. + const applyDiscoveredFact = async (fact: DiscoveredFact): Promise => { + switch (fact.kind) { + case 'opportunistic-capture': { + const match = nodesWithSlug.find((candidate) => candidate.id === fact.nodeId) + if (!match) return + await copyFile(fact.screenshotPath, getScreenshotPath(projectId, match.slug)) + captureMap[match.id] = { + status: fact.arrival.status ?? 'ok', + // There is no saved journey here, only an accidental sighting — a resumed sweep + // still needs to actually navigate to this screen to redo anything with it. + needsNavigation: true, + note: fact.arrival.reason, + screenshot: `screens/${match.slug}.png`, + pressables: [...fact.pressables], + captureMethod: 'opportunistic', + replayScript: null, + replayVerified: false, + structureRevision, + stale: false + } + inferredScreens[match.id] = { key: fact.arrival.key, info: fact.arrival.info } + // The judge just confirmed which screen the walk is standing on, which is exactly the + // fact a walked edge needs — and the only reason this sighting is worth more than the + // capture it already wrote. Recorded before the persist below so the edge rides the same + // checkpoint write as the capture that proved it. + noteArrival(match.id) + await persistCaptureUpdate() + console.log( + `[app-flow] opportunistic capture — reached ${match.id} while exploring toward another screen` + ) + emit(projectId, onEvent, { + type: 'step', + phase: 'capture', + message: `Captured ${match.urlPath ?? match.title} opportunistically`, + completed: nodesWithSlug.filter((candidate) => captureMap[candidate.id]).length, + total + }) + return + } + case 'chrome-observed': { + const kind = inferChromeKind(fact.reveal) + const detected: AppFlowChrome = { + id: `chrome:${kind}:${fact.entryNodeId}`, + kind, + entryNodeId: fact.entryNodeId, + reveal: fact.reveal, + items: fact.items, + observedAt: new Date().toISOString(), + structureRevision + } + // `findMatchingChrome` runs the same kind-plus-item-overlap identity + // `mergeChromeInventory` uses internally, called separately here only so the edges below + // can be built from the record this sighting actually landed on — the chrome's one + // canonical `id`/`entryNodeId`, carried forward from whichever sighting was folded in + // first, not this sighting's own (possibly different) entry screen. Without this, a + // repeat sighting from a new screen would still mint its own parallel `entry -> item` + // edges instead of reinforcing the one edge set the canonical entry already has. + const canonical = findMatchingChrome(chromeInventory, detected) ?? detected + const merged = mergeChromeInventory(chromeInventory, detected) + chromeInventory.splice(0, chromeInventory.length, ...merged) + for (const edge of chromeEdges({ + ...detected, + id: canonical.id, + entryNodeId: canonical.entryNodeId + })) { + const at = runtimeEdgesList.findIndex( + (existing) => existing.from === edge.from && existing.to === edge.to + ) + if (at === -1) runtimeEdgesList.push(edge) + else runtimeEdgesList[at] = edge + } + await persistCaptureUpdate() + console.log( + `[app-flow] chrome inventory recorded from ${fact.entryNodeId} — ${fact.items.length} items (${kind})` + ) + return + } + case 'runtime-screen-sighted': { + if (fact.match) { + const at = runtimeNodes.findIndex((candidate) => candidate.id === fact.match!.id) + if (at !== -1) { + runtimeNodes[at] = recordRuntimeSighting( + runtimeNodes[at], + new Set(fact.observedTokens) + ) + noteArrival(runtimeNodes[at].id) + await persistCaptureUpdate() + console.log(`[app-flow] runtime screen re-sighted — ${fact.match.id}`) + } + return + } + if (runtimeNodes.length >= RUNTIME_NODE_SWEEP_CAP) { + console.log( + `[app-flow] runtime screen discovery capped at ${RUNTIME_NODE_SWEEP_CAP} for this sweep — not minting "${fact.key}"` + ) + return + } + const minted = mintRuntimeNode({ + key: fact.key, + title: fact.info || fact.key, + observedTokens: new Set(fact.observedTokens), + existingIds: new Set([ + ...nodesWithSlug.map((candidate) => candidate.id), + ...runtimeNodes.map((candidate) => candidate.id) + ]), + usedSlugs + }) + await copyFile(fact.screenshotPath, getScreenshotPath(projectId, minted.slug)) + runtimeNodes.push({ + ...minted, + capture: { + status: 'ok', + needsNavigation: true, + note: fact.info, + screenshot: `screens/${minted.slug}.png`, + pressables: [...fact.pressables], + captureMethod: 'opportunistic', + replayScript: null, + replayVerified: false, + structureRevision, + stale: false + } + }) + // A screen the parser never knew about is the case a walked edge matters most for: it + // has no static edges at all, so without this the only thing the graph would ever say + // about it is that it exists. + noteArrival(minted.id) + await persistCaptureUpdate() + console.log(`[app-flow] runtime screen discovered — ${minted.id} ("${fact.key}")`) + return + } + } + } + + // Failed destinations still participate in no-progress detection, including empty plans. + // This compares observed UI directly and never launches a made-up unmatched URL. + let failedReference: Set | null = null + let blockedRun = 0 + let sweepStopped = false + + /** + * The escalate-to-the-user half of getting the app unblocked: take a `BLOCKED_SLUG` + * screenshot, emit an `AppFlowPrompt` with `kind: 'blocked'`, and await + * `options.requestUserAction`. Pulled out of `resolveBlocked` below so the states loop's + * stuck-launcher case (see the hinted-nodes loop further down) can reach the same prompt + * without also carrying `resolveBlocked`'s other half — the `failedReference` fingerprint + * comparison and the silent one-shot relaunch retry are specific to the capture loop's + * no-progress detection, and the states loop has neither a `failedReference` nor a reason to + * relaunch before asking, since a launcher is a launcher on first sight. + * + * `detail` is the only thing that varies between callers — each describes what it actually + * saw — everything else (the screenshot, the manifest `sweepStatus` flips, the emit, the + * resume wait) is exactly what `resolveBlocked` always did, unchanged. + * + * `kind: 'blocked'`'s doc comment in shared/app-flow.ts notes it is "named for what was + * observed, not for what the screen is", which is why a stuck dev-client launcher reuses it + * rather than needing a prompt kind of its own. + */ + const promptToUnblock = async ( + detail: string, + remaining: number + ): Promise => { + const stop = (): 'stop' => 'stop' + if (!options.requestUserAction) return stop() + + const shot = getScreenshotPath(projectId, BLOCKED_SLUG) + await captureScreenshot(ctx, device, shot).catch(() => undefined) + const prompt: AppFlowPrompt = { + id: randomUUID(), + kind: 'blocked', + detail, + screenshot: screenshotUrl(projectId, `screens/${BLOCKED_SLUG}.png`), + remaining + } + manifest.sweepStatus = 'awaiting-user' + await writeManifest(projectId, manifest) + emit(projectId, onEvent, { + type: 'prompt', + phase: 'capture', + message: 'Waiting for you to get the app past this screen...', + prompt + }) + console.log(`[app-flow] blocked — waiting for the user (${remaining} screens left)`) + const resolution = await options.requestUserAction(prompt) + console.log(`[app-flow] blocked — user chose "${resolution}"`) + if (resolution === 'stop') return stop() + + manifest.sweepStatus = 'running' + await writeManifest(projectId, manifest) + + emit(projectId, onEvent, { + type: 'phase', + phase: 'capture', + message: 'Checking the app again...' + }) + await waitForAppReady(ctx, device, signal) + return 'continue' + } + + const resolveBlocked = async (remaining: number): Promise => { + // A React Native error boundary, once tripped, keeps rendering its fallback across every + // navigation after it — the app looks permanently blocked even though nothing but its JS + // state is actually broken. A relaunch clears that in-memory state for free and often + // resolves the block without ever bothering the user, so it is worth trying exactly once + // before escalating. `blockedRun` is deliberately NOT reset here: if the relaunch does not + // clear the block, the next iteration should escalate rather than loop back through another + // three failures before trying to relaunch again. + console.log( + `[app-flow] blocked — relaunching the app before asking the user (${remaining} screens left)` + ) + try { + // `reopenBoundApp` now waits for the relaunched app to settle itself, so the separate + // wait this call site used to need is part of it. + await reopenBoundApp(ctx, device) + const recovered = fingerprint(await capturePressables(ctx, device).catch(() => [])) + const stillBlocked = + failedReference !== null && similarity(recovered, failedReference) >= ARRIVAL_SIMILARITY + if (!stillBlocked) { + console.log( + '[app-flow] blocked — relaunch cleared it, resuming without asking the user' + ) + failedReference = null + blockedRun = 0 + return 'continue' + } + console.log('[app-flow] blocked — relaunch did not clear it, escalating to the user') + } catch (error) { + console.log( + `[app-flow] blocked — relaunch attempt failed, escalating to the user — ${error instanceof Error ? error.message : String(error)}` + ) + } + + const resolution = await promptToUnblock( + 'The app keeps rendering this one screen whatever route is opened, so nothing behind ' + + 'it can be captured. Resolve the visible blocker in the simulator; it stays running, ' + + 'then continue.', + remaining + ) + if (resolution === 'continue') { + failedReference = null + blockedRun = 0 + } + return resolution + } + + // `ArrivalResult` used to be declared inline here; it is now `reach/types.ts`'s + // `ArrivalVerdict`, aliased so every existing use site in this function keeps compiling + // unchanged. + type ArrivalResult = ArrivalVerdict + + // The two-attempt retry loop this used to run inline now lives in `JudgePort.verifyArrival` + // (`reach/adapters.ts`), moved verbatim — this is a pass-through so every call site below + // keeps its existing name and shape. + const verifyCurrentTarget = ( + node: AppFlowNode, + screenshotPath: string, + budget: ActiveTimeBudget + ): Promise => ports.judge.verifyArrival(node, screenshotPath, budget) + + // Whether `tryDeepLink` is allowed to touch the device at all this sweep. Per-node deep-link + // overhead is a URL open, a `waitForRoute`, a screenshot and a vision call — for a build whose + // scheme does not route at all, paying that on every one of up to 81 routes only to fall + // through to `explore()` anyway (with its own fresh five-minute budget) is the single most + // wasteful thing the sweep can do. A one-time probe up front, plus a running breaker below, + // catch that early instead of rediscovering it node by node. + // The breaker's decisions (`registerDeepLinkFailure`/`applyDeepLinkReprobeResult`/ + // `registerDeepLinkSuccess`) are pure, moved to `reach/deep-link-policy.ts` in landing-order + // item 4; this is only the running state they fold into, replacing the old `deepLinkEnabled`/ + // `consecutiveDeepLinkFailures` locals. + // Deliberate entry-chrome inventory pass, run once before the per-node sweep below. Every + // other chrome detection in this file is opportunistic — `observed`'s chrome-observed case + // (further down) only fires when a walk toward some *other* target happens to wander through + // the chrome, so a real app's drawer can go completely unrecorded for an entire sweep if no + // walk ever happens to open it. Bluesky is exactly that case: it has a working drawer (the + // model tapped `label="Open drawer menu"` and then a named item while reaching a screen behind + // it, mid-sweep, proving the control exists and works) but no sweep ever recorded it, because + // recording only ever rode on some unrelated walk's own steps. Reading the tab bar and opening + // the drawer once here means `findNavigationPath` has a locator-bearing hop to every screen + // behind either chrome from the very first node the sweep visits, rather than only from + // whichever node's walk happened to stumble onto it — which is the whole point: a deep link + // that fails now has a recorded, tappable alternative to fall back to immediately, not a maybe. + // + // Placement is load-bearing: this runs *before* the deep-link probe below, not after. The + // probe opens a URL and leaves the app wherever that URL landed, with no navigation back — so + // running afterwards inventoried whatever screen the probe had just produced. In the device + // run that caught this, the probe's own URL was invalid (`bluesky://src`), the app was still + // mid-transition, and the pass read 5 controls and concluded there was no drawer opener; the + // very same screen read 8 controls including `label="Open drawer menu"` moments later. Here + // the app is freshly launched and already settled — `bootDevice`'s `waitForAppReady` and the + // auth gate have both run and seen a stable frame — which is the one moment in the whole + // sweep the app is guaranteed to be sitting on its real entry screen. + // + // Detection reuses `detectChromeInventory` (`reach/chrome-inventory.ts`) — the exact same pure, + // token-only match the opportunistic path already runs — so this costs device round trips only + // and NOT ONE model call: reading a control list and matching its text against known node + // vocabulary is not a vision judgment. Bounded to at most two pressable reads, one tap to open + // the drawer and one best-effort tap to close it again, and wrapped in a `try/catch` a total + // failure never escapes: no drawer control found, a device hiccup, anything at all still leaves + // the sweep exactly where it would have started if this pass did not exist. + if (options.explorationMode !== 'fast') { + try { + const launchEntryNodeId = entryNodeIds[0] ?? 'app-launch' + const tabBarPressables = await ports.device.capturePressables(15_000) + const tabBarChrome = detectChromeInventory(tabBarPressables, chromeCandidates()) + if (tabBarChrome) { + await applyDiscoveredFact({ + kind: 'chrome-observed', + entryNodeId: launchEntryNodeId, + reveal: [], + items: tabBarChrome.items + }) + } + + const drawerOpener = findDrawerOpener(tabBarPressables) + if (drawerOpener) { + const revealStep: AppFlowStep = { type: 'tap', locator: drawerOpener } + // Hoisted out of the try below because the close fallback needs it: the drawer's own + // items are the only controls the sweep has *proved* are reachable while the drawer is + // open, which is exactly the property the fallback turned out to need. + let drawerChrome: ReturnType = null + // Its own try: the tab-bar inventory above has already been recorded by this point, and a + // drawer that refuses to open must not read in the log as the whole pass having failed — + // which is exactly how an ambiguous-selector throw presented before, sending the last + // investigation looking for a broken pass rather than a broken locator. + try { + await ports.device.executeNavigationActions([revealStep], 15_000) + const drawerPressables = await ports.device.capturePressables(15_000) + drawerChrome = detectChromeInventory(drawerPressables, chromeCandidates()) + if (drawerChrome) { + await applyDiscoveredFact({ + kind: 'chrome-observed', + entryNodeId: launchEntryNodeId, + reveal: [revealStep], + items: drawerChrome.items + }) + } + console.log( + `[app-flow] entry-chrome pass — drawer opened via ${drawerOpener.by}="${drawerOpener.value}", ${drawerChrome?.items.length ?? 0} items recorded from ${drawerPressables.length} controls` + ) + } catch (error) { + console.log( + `[app-flow] entry-chrome pass — drawer opener ${drawerOpener.by}="${drawerOpener.value}" could not be used, keeping the tab inventory — ${error instanceof Error ? error.message : String(error)}` + ) + } + // Closing it again is not best-effort, and the comment that used to say so was wrong. + // The claim was that a drawer left open leaves the app "no worse off than a normal walk + // that opened it and moved on". It does not: the very next thing the sweep does is the + // deep-link probe, whose single verdict decides the transport for every route. On the + // social-app run that caught this, the close tap threw in 287ms against a control the + // open drawer was covering, `.catch(() => undefined)` swallowed it without a line in the + // log, and the probe fired its URL into a modal and read the drawer's own 17 controls + // back. One silent toggle failure cost 79 routes their deep links. + // + // So the toggle is now checked rather than assumed, and the check is the opener's own + // visibility: an open drawer covers the control that opened it, so finding that control + // again is the app telling us the drawer is gone. That is the same reading of the same + // control list the pass already does, and it costs one accessibility read. + let closed = false + try { + await ports.device.executeNavigationActions([revealStep], 15_000) + closed = Boolean(findDrawerOpener(await ports.device.capturePressables(15_000))) + } catch (error) { + console.log( + `[app-flow] entry-chrome pass — close tap on ${drawerOpener.by}="${drawerOpener.value}" failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (!closed) { + // The fallback has to tap a control that is *present while the drawer is open*, and + // the tab bar is not one. An earlier version reached for a tab entry on the reasoning + // that it "does not have to be" under the drawer; the device disagreed. An open + // drawer replaces the accessibility tree rather than overlaying it — the read taken + // one line above returns the drawer's own 17 controls and nothing behind them — so a + // tab locator misses for the same reason the opener did, in the same ~165ms, and the + // run that proved it went straight from the failed opener tap to the warning in 178ms + // without the tap ever reaching the device. + // + // The drawer's own items are the one set of controls the pass has just *observed*, so + // they are what the fallback presses. They navigate rather than toggle, which closes + // the drawer as a side effect of going somewhere — and somewhere is fine here: the + // probe below needs a settled real screen, not specifically the entry screen. + // Preferring the item that leads back to the launch entry node keeps it on the screen + // the pass started from when the drawer offers that route, which on a tabbed app it + // usually does. + const drawerItems = (drawerChrome?.items ?? []).filter( + (item) => item.trigger.value !== drawerOpener.value + ) + const exitTarget = ( + drawerItems.find((item) => item.toNodeId === launchEntryNodeId) ?? + drawerItems.find((item) => item.toNodeId !== null) ?? + drawerItems[0] + )?.trigger + if (exitTarget) { + try { + await ports.device.executeNavigationActions( + [{ type: 'tap', locator: exitTarget }], + 15_000 + ) + closed = Boolean(findDrawerOpener(await ports.device.capturePressables(15_000))) + } catch (error) { + console.log( + `[app-flow] entry-chrome pass — exit tap on ${exitTarget.by}="${exitTarget.value}" failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + console.log( + closed + ? `[app-flow] entry-chrome pass — drawer would not toggle shut; left it via ${exitTarget?.by}="${exitTarget?.value}"` + : // Loud on purpose. Everything downstream now starts from a screen the sweep did + // not intend, and the probe's `frameChangedAfterOpen` guard is the only thing + // standing between that and the transport being switched off for every route. + `[app-flow] entry-chrome pass — WARNING: the drawer is still open and could not be dismissed; the next screens are being read through it` + ) + } + } else { + console.log( + `[app-flow] entry-chrome pass — no drawer opener among ${tabBarPressables.length} entry controls` + ) + } + } catch (error) { + console.log( + `[app-flow] entry-chrome pass failed, continuing without it — ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + let deepLinkBreaker: DeepLinkBreakerState = initialDeepLinkBreakerState() + // The exact URL a deep-link attempt opened, once the arrival judge has rejected it this + // sweep. This matters most for a param-free prefix (`paramFreeDeepLink` truncating + // `/profile/:name` to `/profile`, say): every sibling route sharing that ancestor computes + // the identical `target`, and a node that gets requeued after the blocked-run breaker fires + // recomputes the same `target` for itself too. Without this, a build whose `/profile` 404s + // paid a URL open, a `waitForRoute`, a screenshot and a judge call *per node* to relearn + // exactly the same fact — five times in the device run that motivated this fix. This is + // deliberately in-memory and sweep-scoped, not persisted on the node: that is the later, + // schema-backed version of this idea (plan step 3a), and this is only the cheap subset that + // stops the immediate, self-contained waste. + const rejectedDeepLinkUrls = new DeepLinkRejectionMemo() + const closedGuardGates = new GuardGateMemo() + // Fast is runtime-only. A deep-link probe used to add an open, settle, screenshot and vision + // verdict before the first runtime dispatch — often most of a short six-screen run — and + // could then be claimed as a deep-link capture despite the user selecting Fast. + const probeRoute = + options.explorationMode === 'fast' ? null : pickProbeRoute(nodesWithSlug) + + /** + * The reach the one-time deep-link probe below already performed and verified, held for the + * sweep to claim instead of performing it a second time. + * + * The probe does the full thing — opens the URL, settles, screenshots, asks the judge — and + * then the sweep walked up to the same node and did all four again, identically. A device + * run shows the duplicate costing ~14s: `openDeepLink (tabs)/explore` and a `match` verdict, + * immediately followed by `openDeepLink (tabs)/explore` and the same `match` verdict. + * + * Only ever valid for the *first* node the sweep reaches, and `claimProbeCredit` enforces + * exactly that by clearing it on any other node. The app is left sitting on the probe route + * with nothing between the probe and the loop that touches the device, so if the sweep opens + * with that node the screen in front of it is still the verified one; if it opens with any + * other node, that node's own reach moves the app and the credit is worthless. Nothing here + * tries to be cleverer than that — a credit that survived one unrelated navigation would be + * a claim about a screen nobody has looked at. + */ + let probeCredit: { + nodeId: string + arrival: ArrivalResult + /** The frame the probe's judge accepted, for delivery to reuse — see `ReachEvidence.frame`. */ + frame: string + /** The controls read during that same judge call — see `ReachEvidence.pressables`. */ + pressables?: readonly AppFlowPressable[] | null + opened?: { app: string; url: string } + } | null = null + const claimProbeCredit = (nodeId: string): typeof probeCredit => { + if (!probeCredit) return null + const credit = probeCredit + probeCredit = null + return credit.nodeId === nodeId ? credit : null + } + + /** + * Interactive elements the device currently reports, or null when the read failed. + * + * Only the probe uses this, and only to decide whether a frame is substantive enough to draw + * a sweep-wide conclusion from (`isSubstantiveFrame`). A failed read deliberately returns null + * rather than 0: "I could not look" and "there is nothing there" are different claims, and + * `judgeProbeFrame` treats only the second as something to act on. The extra accessibility + * read costs a second or two, once per probe, against the multi-minute cost of the mistake it + * prevents — every route in the sweep losing its deep link to a splash screen. + * + * The counts are comparable with the `settled with N interactive elements` line + * `waitForAppReady` logs: on the reference run the same screens read 9 and 9, 38 and 48. + */ + const countInteractiveElements = async (): Promise => { + try { + return (await ports.device.capturePressables()).length + } catch { + return null + } + } + + /** One open-and-judge pass. `probeDeepLinkRoute` is what callers use; this is the single + * pass it may run twice. `pass` only ever appears in logs, so a device log shows whether a + * mismatch was confirmed or merely first observed. */ + const probeOpenPass = async ( + route: (typeof nodesWithSlug)[number], + pass: 1 | 2 + ): Promise<{ + outcome: 'match' | 'mismatch' | 'inconclusive' + arrival: ArrivalResult | null + opened?: { app: string; url: string } + frame?: string + pressables?: readonly AppFlowPressable[] | null + }> => { + const probeBudget = new ActiveTimeBudget(60_000, activeNow(), activeNow) + // The budget bounds the *judgement*, not the device work ahead of it, so getting the app + // onto the screen is excluded the same way a wait on the user is (see the auth gate's + // `budget.exclude`). Without this the two are charged to one clock, and a device that needs + // recovering spends the judge's entire allowance before it is asked anything: a device log + // shows `openDeepLink` taking ~72s against this 60s budget while the runner was wedged, + // after which `verifyArrival`'s `!budget.expired` guard skipped both attempts, made no model + // call at all, and the probe reported "inconclusive" having never actually looked. Slow is + // not the same as wrong, and only the second is something this probe is entitled to conclude. + const deviceStartedAt = Date.now() + // Settle *before* opening the URL, not only after, and keep settling until the app is + // actually there. `waitForAppReady` reports a stable frame and a dev-client loading screen + // is perfectly stable, so "settled" alone does not mean "ready to route". On the cold-cache + // social-app run the first read returned 2 interactive elements, the URL was fired into + // that, Metro logged `iOS Bundled 16079ms index.ts (6130 modules)` 13 seconds later, and + // the swallowed link left the app on its default screen — judged a mismatch, which took + // deep links away from all 79 routes and tripled the sweep's token cost. That is why the + // wait below has an element floor at all, and why it is a wait rather than the single + // retry it replaced. + let launchElements = await countInteractiveElements() + if (!isSubstantiveFrame(launchElements)) { + console.log( + `[app-flow] deep-link probe — app shows ${launchElements ?? 'an unreadable frame'} interactive element(s), below the ${PROBE_FRAME_ELEMENT_FLOOR} needed to trust a probe verdict; waiting up to ${PROBE_LAUNCH_READY_BUDGET_MS / 1000}s for it to finish loading before opening the URL` + ) + const waitStartedAt = Date.now() + // One continuous wait, not a loop around a wait. `waitForRoute` returns the moment two + // reads agree, and a dev-client loading screen agrees with itself immediately, so asking + // it repeatedly re-paid the whole settle handshake for a frame that had not changed: on + // the measured social-app run this printed the same poll/stale/settled triplet thirteen + // times between t=45.9s and t=59.6s, ~1.1s each, and spent 13.7s discovering what one + // poll would have noticed as it happened. Pushing the element floor *into* the wait lets + // a single poll hold until the frame is both stable and substantial. + await ports.device.waitForRoute({ + minInteractiveElements: PROBE_FRAME_ELEMENT_FLOOR, + timeoutMs: PROBE_LAUNCH_READY_BUDGET_MS + }) + checkSignal(signal) + launchElements = await countInteractiveElements() + const waitedSeconds = ((Date.now() - waitStartedAt) / 1000).toFixed(1) + console.log( + isSubstantiveFrame(launchElements) + ? `[app-flow] deep-link probe — app rendered ${launchElements} interactive element(s) after ${waitedSeconds}s, opening the URL now` + : `[app-flow] deep-link probe — app still shows ${launchElements ?? 'an unreadable frame'} interactive element(s) after ${waitedSeconds}s; opening the URL anyway, but a mismatch cannot be trusted from here` + ) + } + // Whether the app could have received the URL at all. Carried into `judgeProbeFrame` + // because no post-open measurement can recover it — see that function's mismatch branch. + const preOpenSubstantive = isSubstantiveFrame(launchElements) + // The frame as it stood immediately before the open, so the judge can be told whether the + // URL moved the app at all. Read here rather than reused from the settle wait above + // because that wait may have returned seconds ago; this is the last frame before the open + // and nothing else is. Best-effort: a failed read makes the question unanswerable, which + // `judgeProbeFrame` treats as a reason to defer rather than a reason to act. + const preOpenFrame = await ports.device + .capturePressables() + .then((pressables) => fingerprint(pressables)) + .catch(() => null) + const opened = await ports.device.openDeepLink(route, {}) + checkSignal(signal) + // Whether the app reached a stable frame at all. A frame captured while the device was + // still refusing accessibility reads ("runner busy", "still loading … capturing anyway") + // shows a splash or the previous screen no matter where the URL actually routed, so a + // mismatch judged off it is a statement about the device, not about the URL scheme. + const { settled } = await ports.device.waitForRoute() + probeBudget.exclude(Date.now() - deviceStartedAt) + const probeShot = getScratchPath(projectId, 'deep-link-probe') + await ports.device.captureScreenshot(probeShot) + // The controls are read inside the judge's own latency, so a probe that goes on to be + // claimed by `probeCreditRung` can hand delivery a read it did not have to spend — see + // `probeCredit`. Dropped on any outcome but a match, which costs nothing: the read ran + // concurrently with a call that had to happen either way. + const [arrival, probePressables] = await Promise.all([ + verifyCurrentTarget(route, probeShot, probeBudget), + ports.device.capturePressables().catch(() => null) + ]) + // Read the frame the judge actually looked at, so "was this worth judging" is answered + // about that frame rather than about whatever the device shows by the time we ask. The + // concurrent read above is the best available answer to that: it was taken *during* the + // judge call rather than after it, so it is nearer the judged frame than a fresh read + // would be — on the reference run the judge took up to 24.9s to reply, which is a long + // time to let the app drift before asking what it was looking at. `judgeProbeFrame` only + // consults this on a mismatch; the match branch returns before reading it. + const frameElements = + arrival.verdict === 'match' + ? launchElements + : (probePressables?.length ?? (await countInteractiveElements())) + // Compared with the same overlap bar `waitForAppReady` settles on rather than exact + // equality, for the same reason it uses one: a feed mutates under a stationary app, so + // byte-identical frames are the exception even when nothing navigated. + const frameChangedAfterOpen = + preOpenFrame && probePressables + ? similarity(preOpenFrame, fingerprint(probePressables)) < APP_READY_SETTLE_SIMILARITY + : null + const outcome = judgeProbeFrame({ + verdict: arrival.verdict, + settled, + elementCount: frameElements, + preOpenSubstantive, + frameChangedAfterOpen + }) + if (outcome === 'match') + return { outcome, arrival, opened, frame: probeShot, pressables: probePressables } + if (outcome === 'inconclusive' && arrival.verdict === 'mismatch') { + const why = !preOpenSubstantive + ? `was opened into an app showing only ${launchElements ?? 'an unreadable number of'} interactive element(s), which could not have routed it` + : frameChangedAfterOpen === false + ? 'left the screen exactly as it was, so the URL was swallowed rather than routed — something already on screen took it' + : frameChangedAfterOpen === null + ? 'could not be checked against the frame before the open, so there is no evidence the URL reached the app' + : !settled + ? 'mismatched on a frame the app never settled on' + : `mismatched on a frame carrying only ${frameElements ?? 'an unreadable number of'} interactive element(s) — too thin to be evidence about the scheme` + console.log( + `[app-flow] deep-link probe ${route.id} (pass ${pass}) ${why} — inconclusive, not evidence about the scheme` + ) + } + return { outcome, arrival: null } + } + + // The one check both the initial probe and the breaker's re-confirmation need: does opening + // `route` by URL actually land the app on it. Kept in one place so the two call sites can't + // drift into subtly different definitions of "the transport works". + // + // A mismatch is confirmed by a second pass before it is believed. Everything the first pass + // can measure about app readiness it measures *before* opening, but a URL can still be + // swallowed by a dev client that answers accessibility reads while its bundle is not yet + // wired to a navigator — the cold-boot failure `judgeProbeFrame` documents. The second pass + // runs against an app that has demonstrably just rendered a real screen, so it is the one + // observation that cannot be confounded that way. It costs one URL open and one judge call, + // around six seconds, set against the measured cost of a wrong disable: every route in the + // sweep walked by hand, 3.5x the tokens. Given that asymmetry a confirmation is close to + // free, and it is skipped entirely on the overwhelmingly common path where the first pass + // matches. + const probeDeepLinkRoute = async ( + route: (typeof nodesWithSlug)[number] + ): Promise<{ + outcome: 'match' | 'mismatch' | 'inconclusive' + /** The judge's own verdict, so a caller can reuse the reach this probe already proved + * rather than paying for an identical one — see `probeCredit` below. Null whenever the + * probe concluded without a usable verdict. */ + arrival: ArrivalResult | null + /** What `openDeepLink` recorded, so a caller reusing this reach reports the same + * `deep-link-opened` effect the deep-link strategy would have. */ + opened?: { app: string; url: string } + /** The frame the judge accepted, so a caller reusing this reach can deliver it rather + * than screenshot the same screen again. */ + frame?: string + /** The controls read at that same moment, for the same reason — null when the read failed, + * which only costs the claimant a read of its own. */ + pressables?: readonly AppFlowPressable[] | null + }> => { + const first = await probeOpenPass(route, 1) + if (first.outcome !== 'mismatch') return first + console.log( + `[app-flow] deep-link probe ${route.id} looks like the wrong screen — re-opening the URL once against the now-loaded app before disabling deep links for the sweep` + ) + const second = await probeOpenPass(route, 2) + if (second.outcome !== 'mismatch') { + console.log( + `[app-flow] deep-link probe ${route.id} did not reproduce the mismatch on the second open (${second.outcome}) — the first was the app not being ready, deep links stay enabled` + ) + } + return second + } + + if (probeRoute?.urlPath) { + // The probe may only ever *confirm* that deep links work. It must never be what turns them + // off, unless it actually saw the app settle somewhere other than the target. + // + // The asymmetry is brutal and was measured twice on device. Wrongly disabling costs every + // one of up to 84 routes its transport, sending each into a fresh five-minute exploration + // walk — the single most expensive mistake this sweep can make. Wrongly *enabling* costs a + // few per-node attempts before `registerDeepLinkFailure`'s breaker trips on real evidence + // from real nodes, which is a strictly better-informed judge than one screenshot. So + // anything short of a settled, confident mismatch leaves deep links on and defers to it. + // + // What that rules out, both seen in one device log: a sub-agent timeout (an infrastructure + // failure that says nothing about the URL) and a verdict read off a frame captured while + // the runner was busy and `waitForAppReady` had already given up. Neither is evidence, and + // between them they disabled deep links for an entire 84-route sweep. + let outcome: 'match' | 'mismatch' | 'inconclusive' = 'inconclusive' + let lastError: unknown = null + for (let attempt = 1; attempt <= 2 && outcome !== 'match'; attempt++) { + try { + const probed = await probeDeepLinkRoute(probeRoute) + outcome = probed.outcome + if (probed.arrival && probed.frame) + probeCredit = { + nodeId: probeRoute.id, + arrival: probed.arrival, + frame: probed.frame, + pressables: probed.pressables, + opened: probed.opened + } + lastError = null + if (outcome === 'mismatch') break + } catch (error) { + lastError = error + outcome = 'inconclusive' + if (attempt === 1) { + console.log( + `[app-flow] deep-link probe errored, retrying once — ${error instanceof Error ? error.message : String(error)}` + ) + } + } + } + deepLinkBreaker = initialDeepLinkBreakerState(outcome !== 'mismatch') + if (lastError) { + console.log( + `[app-flow] deep-link probe errored twice — inconclusive, leaving deep links enabled for the per-node breaker to judge — ${lastError instanceof Error ? lastError.message : String(lastError)}` + ) + } else if (outcome === 'match') { + console.log( + `[app-flow] deep-link probe ${probeRoute.id} (${probeRoute.urlPath}) matched — deep links enabled for this sweep` + ) + } else if (outcome === 'mismatch') { + console.log( + `[app-flow] deep-link probe ${probeRoute.id} (${probeRoute.urlPath}) settled on the wrong screen — deep links disabled for this sweep` + ) + } else { + console.log( + `[app-flow] deep-link probe ${probeRoute.id} (${probeRoute.urlPath}) inconclusive — leaving deep links enabled for the per-node breaker to judge` + ) + } + } else { + console.log( + '[app-flow] deep-link probe — no eligible probe route (need a urlPath with no params), leaving deep links enabled' + ) + } + + // The breaker's re-probe device call, handed to `deepLinkStrategy` as its `reprobe` callback + // (`reach/strategies/deep-link.ts`) — that module owns *when* to reprobe (a pure decision, + // `reach/deep-link-policy.ts`'s `registerDeepLinkFailure`), this owns *how*, reusing the exact + // same check the initial one-time probe above already runs. Absent when there is no eligible + // probe route, matching the original immediate-disable fallback in that case. + const reprobeDeepLinkRoute = probeRoute + ? async (): Promise => + (await probeDeepLinkRoute(probeRoute)).outcome !== 'mismatch' + : undefined + + // Entry screens are the cheapest to reach (a deep link or a one-hop walk) and are what produce + // the chrome inventory and the parent replays every later screen behind that chrome reuses — + // see `sweep-order.ts`'s doc comment for the full tier breakdown. `nodesWithSlug` itself stays + // in parser order: everything above and below the loop that looks a node up by id, counts + // orphans, or writes the final result reads it as-is, and reordering it in place would only + // make those call sites' order accidentally depend on the sweep's own order too. + // `deepLinkBreaker.enabled` is read here, after the one-time probe above has folded its + // result in, because it decides whether tier 5 may demote a node at all — see + // `OrderSweepNodesOptions.deepLinksEnabled`. With the transport live a param-free URL is a + // way in that owes nothing to the control graph; with it off, the graph is all there is. + const sweptNodes = orderSweepNodes( + nodesWithSlug, + parsed.edges, + parsed.entryScreenIds, + parsed.entryLayouts, + { + deepLinksEnabled: deepLinkBreaker.enabled, + // Only when a credit is actually in hand. `probeCredit` is set by a probe the judge + // accepted and cleared by the first `claimProbeCredit` call, so this is precisely "there + // is verified work on this node waiting to be claimed" — not merely "a route was probed". + probeRouteId: probeCredit?.nodeId ?? null + } + ) + + // `ReachQueue` replaces the old `for (let i = 0; i < nodesWithSlug.length; i++)` — see its doc + // comment for why the raw index could not survive `sweptNodes` reordering `nodesWithSlug`. + // `queue.completed` reads exactly like the old `i` did at the top of the loop body (nodes + // strictly before the current one, not yet counting the one in flight), and `queue.remaining` + // exactly like the old `total - i` — both used at every call site below that used to spell out + // that arithmetic by hand. + const sweepQueue = new ReachQueue(sweptNodes) + let sweepPaused = false + /** Nodes the blocked-run breaker has already re-served once. See the `requeueCurrent` call + * site for why a second retry is always wasted work rather than a second chance. */ + const requeuedIds = new Set() + // `activeNow` rather than `Date.now` for the deadline, for the same reason every + // `ActiveTimeBudget` above uses it: a sweep sitting on a blocked prompt is waiting on the + // user, and charging that to the run's clock would time out a sweep that was never slow. + const explorationControl = new ExplorationControl( + options.screenLimit, + options.pauseRequested, + { + attemptLimit: options.attemptLimit, + sweepDeadlineMs: options.sweepDeadlineMs, + now: activeNow + } + ) + /** The one place the four pause reasons turn into a line a reader can act on. The three + * ceilings are not interchangeable and must not all print "screen limit reached": that + * message on a run that captured three of eight screens and ran out of attempts sends the + * reader looking for five missing captures instead of at the unreachable routes that ate + * the budget. */ + const pauseMessage = (reason: ExplorationPauseReason): string => { + if (reason === 'requested') return '[app-flow] pausing at the latest screen checkpoint' + if (reason === 'limit') + return `[app-flow] screen limit reached (${options.screenLimit}) — pausing` + if (reason === 'attempts') + return `[app-flow] attempt limit reached (${explorationControl.screensAttempted} attempts for ${explorationControl.screensExplored}/${options.screenLimit} screens) — pausing; the remaining routes are costing more to reach than they are returning` + return `[app-flow] time limit reached (${explorationControl.screensExplored}/${options.screenLimit} screens in ${explorationControl.screensAttempted} attempts) — pausing` + } + const pauseBeforeNextScreen = (): boolean => { + const reason = explorationControl.beforeNextScreen() + if (!reason) return false + sweepPaused = true + sweepStopped = true + console.log(pauseMessage(reason)) + return true + } + const finishExploredScreen = (captured: boolean): boolean => { + const reason = explorationControl.screenFinished(captured) + if (!reason) return false + sweepPaused = true + sweepStopped = true + console.log(pauseMessage(reason)) + return true + } + for (let node = sweepQueue.next(); node !== null; node = sweepQueue.next()) { + checkSignal(signal) + // Pause before touching the next screen. A request made while a screen is in flight reaches + // the matching check after that screen's durable checkpoint has been written below. + if (pauseBeforeNextScreen()) break + // Two distinct reasons to skip, and only these two. The checkpoint is a snapshot taken + // before this loop started, so it cannot see a node captured opportunistically while + // exploring toward an earlier one this same run — that write only ever lands in the live + // `captureMap`, and checking the checkpoint alone re-explored every opportunistic hit from + // scratch with a fresh five-minute budget, the very waste that feature exists to avoid. + // + // What must NOT skip is a node this run already failed: `recordUnreached` writes a + // `missing` entry into the same live `captureMap`, so a bare `captureMap[node.id]` test + // also swallows the `queue.requeueCurrent()` retry the blocked prompt schedules once the + // user (or the relaunch) clears the blocker — the node would be marked missing and never + // reattempted. + const settled = checkpoint.captures[node.id] ?? null + const opportunistic = captureMap[node.id]?.captureMethod === 'opportunistic' + if (settled || opportunistic) { + console.log( + opportunistic + ? `[app-flow] skip ${node.id} — captured opportunistically while exploring another screen` + : `[app-flow] skip ${node.id} — already captured in a previous run` + ) + emit(projectId, onEvent, { + type: 'step', + phase: 'capture', + message: opportunistic + ? `Captured ${node.urlPath ?? node.title} opportunistically` + : `Already captured ${node.urlPath ?? node.title}`, + completed: sweepQueue.completed + 1, + total, + captured: explorationControl.screensExplored, + captureLimit: options.screenLimit + }) + continue + } + // Fast gets a tighter per-node ceiling than the shared five minutes. Nothing in the Fast + // ladder is open-ended — a dispatch, a bounded settle poll and one verification — so a node + // that has not landed in 45s is not making slow progress, it is waiting on something that + // will not arrive, and the remaining budget is better spent on the next screen. It also + // tightens the arrival judge's own `timeoutMs` clamp on the one path that still reaches it. + // The modes that can spend minutes legitimately (a model-driven walk) keep the old number. + nodeStartedAt = Date.now() + const budget = new ActiveTimeBudget( + options.explorationMode === 'fast' ? 45_000 : 5 * 60_000, + activeNow(), + activeNow + ) + const label = node.urlPath ?? node.title + emit(projectId, onEvent, { + type: 'step', + phase: 'capture', + message: `Reaching ${label}`, + completed: sweepQueue.completed, + total, + captured: explorationControl.screensExplored, + captureLimit: options.screenLimit + }) + + // One resolution feeds both the values and the unresolved list, so the two cannot drift: the + // static call sites are consulted first and anything the app itself showed us earlier in the + // sweep (or in a previous one) fills in what they could not. + const resolution = resolveNodeParams(node, parsed.edges, paramStore) + // Only the params that actually have an observed value. These go to rungs that open a URL + // or dispatch a navigation, and a stand-in would make either of those land cleanly on some + // other record — see `buildUrlPath`, which now refuses a hole outright rather than filling + // it. `unresolvedParams` below is what stops a rung from getting that far. + const params = Object.fromEntries( + node.params + .filter((key) => resolution.values[key] !== undefined) + .map((key) => [key, resolution.values[key]]) + ) + // Params neither the route graph nor anything harvested could supply. A deep link built on a + // guessed id opens a record the app does not have, and the error screen that follows is + // indistinguishable from a route this build withholds — so the in-app path is the only + // honest way in. + const unresolvedParams = resolution.unresolved + if (unresolvedParams.length === 0 && resolution.source && resolution.source !== 'static') { + console.log( + `[app-flow] params — ${node.urlPath} resolved from ${resolution.source} seen on ${resolution.seenOn}: ${JSON.stringify(resolution.values)}` + ) + } + let captureMethod: AppFlowCapture['captureMethod'] = 'unreached' + let replayScript: string | null = null + // True whenever `replayScript` was itself re-confirmed by a fresh screenshot rather than + // merely recorded. Only `savedReplayStrategy` can set it now, replaying a script the + // *manual* recorder published; exploration records no script to verify. + let replayScriptVerified = false + /** + * The journey a walk actually took, as `agent-device …` lines — this node's stored + * `pathToReach` and the seed for anything that explores from here. Set by `exploreFrom` on + * arrival, null when no walk arrived or when the walk contained a step with no faithful + * CLI form (see `walkCommands`, which refuses to store a truncated path). + */ + let pathCommands: readonly string[] | null = null + let reachError: string | null = null + let acceptedArrival: ArrivalResult | null = null + // What was in hand at the instant the vision judge accepted this node's screen, kept because + // that instant is the only one in the whole reach that is *proven* to be looking at the + // target. Everything after it — publishing the recording, replaying it to check it, the + // auth-gate probe — can and does move the device somewhere else, and a replay that fails + // mid-walk leaves it stranded with no session at all. Delivery falls back to these two when + // the live device has moved on; see `chooseDeliverySources` (reach/delivery.ts). + // + // The frame lives in its own scratch slot rather than being read from `scratch/explore.png` + // later: that file is overwritten by the very next observation, and by the time delivery + // runs it is whatever the exploration of some *other* node last looked at. + let arrivalFrame: string | null = null + let arrivalPressables: AppFlowPressable[] = [] + /** + * The frame the winning strategy's judge accepted, when that strategy left the app standing + * on it with nothing further touching the device — `ReachEvidence.frame`. Distinct from + * `arrivalFrame` above, which is a *fallback* for a delivery capture that came back + * compromised; this one says the delivery capture need not be taken at all, because the + * screen has not changed since the frame that proved it. + */ + let verifiedFrame: string | null = null + /** The controls read at that same verified moment — `ReachEvidence.pressables`. */ + let verifiedPressables: readonly AppFlowPressable[] | null = null + let authStopped = false + // Set once a deep link proves the route resolves but this build withholds the screen. + // Exploration cannot get past that, so it is skipped rather than left to time out. + let unavailableReason: string | null = null + // The deep link resolved onto the app's 404 without proving the screen is gated — usually a + // sample param. The explorer is told, so it treats a missing entry point as confirmation + // rather than a reason to keep hunting. + let deepLinkNotFound = false + // `deepLinkAttempted` (whether the deep-link rung actually opened a URL for this node, as + // opposed to short-circuiting on the breaker, an unresolved param, or a missing urlPath) is + // now derived from the ladder's own `outcome.attempts` after it runs — see the `const + // deepLinkAttempted = outcome.attempts.some(...)` below — since that is exactly the + // `touchedDevice` field `deepLinkStrategy` (`reach/strategies/deep-link.ts`) already reports. + // Set only when a `CommandSeed`'s own stored path fails to run — a fact about the parent's + // journey, not about whether the target is reachable at all. The `explore` step below uses + // this to decide whether a bounded last hop that could not even start is worth retrying as + // a full, unseeded exploration (yes) versus one that started fine and simply ran out of its + // three decisions (no — see plan Step 3e). + let seedBaseFailed = false + // Set when the most recent `exploreFrom` reached its decision loop and ended without + // arriving, as opposed to failing before it (launch, base replay, auth gate) — reset at + // the top of every call. `shouldRetryFromEntry` reads it to tell a walk that ran out of + // ideas from one that never got to have any. + let seededWalkRan = false + // The *manual* recorder's published path, and the only `.ad` file the sweep still touches: + // `savedReplayStrategy` replays it when one exists. Exploration no longer writes a + // candidate or publishes anything here. + const savedScript = join(getFlowsDir(projectId), `${node.slug}.ad`) + // Bounded mode — plan Step 3e/3f's "last hop": the seed already proved everything up to one + // tap away, so a walk that has not arrived within three decisions is not a promising walk + // approached badly, it is a target that was never reachable this way at all. Tighter than + // `EXPLORATION_MAX_DECISIONS`'s general-purpose seven on purpose — see that constant's own + // doc comment for why a higher cap only buys longer flailing on a hopeless target, which is + // exactly what a proven seed should never need. + const exploreFrom = async ( + seed: ExplorationSeed | null, + exploreOptions: { bounded?: boolean } = {} + ): Promise => { + checkSignal(signal) + seededWalkRan = false + if (authStopped || budget.expired || isFallbackRoute(node) || unavailableReason) { + if (isFallbackRoute(node)) { + reachError = 'Fallback route has no in-app entry point.' + } + return false + } + const commandSeed = seed && 'commands' in seed ? seed : null + // The lines this walk's own stored path will open with, so the path it eventually stores + // is literally what the walk did. A command seed contributes the parent's whole journey; + // anything else contributes one baseline launch line carrying the URL the reset below + // actually opened. Built before the device is touched so the two cannot disagree. + const seedUrl = seed && 'url' in seed ? seed.url : null + const prefix = commandSeed + ? [...commandSeed.commands] + : baselineLaunchCommands(device, seedUrl) + // Collected as the walk goes, by `act` below. Null the moment a step turns out to have no + // faithful CLI form — see `walkCommands` for why a truncated path is worse than none. + let walkLines: string[] | null = [] + try { + try { + if (commandSeed) { + // Running the parent's stored `pathToReach` rather than replaying its `.ad`: the + // lines open with their own launch and then press their way to the parent's screen, + // which is the same journey the script encoded and the same one `path-run.ts` runs + // for the Replay button. What it no longer involves is a candidate file, an armed + // `--save-script` transaction, a divergence report or an in-place repair — the four + // things `replaySeedBase` existed to manage, all of which were consequences of the + // seed being a recording rather than a list. + // + // Repair has no analogue here and needs none. A recorded `.ad` carried per-step + // element *identities* that a fresh accessibility tree could contradict + // (`IDENTITY_MISMATCH`, the failure the strip existed to rescue); these lines carry + // only selectors, which are re-resolved against the live tree on every run. + await ports.device.runPathCommands(commandSeed.commands) + checkSignal(signal) + } else { + // A soft re-root, not a relaunch. The cold start this used to pay was the price of + // the recorded script's `open --relaunch` first line, not of the walk — see + // `startReplayRecording`'s own doc comment, which says so — and the walk only needs + // to start somewhere known. `seedUrl` carries a deep-link seed's URL so the walk + // starts where the stored baseline line says it does. + await ports.device.resetToLaunch( + seedUrl, + Math.max(1_000, Math.min(90_000, budget.remainingMs)) + ) + checkSignal(signal) + } + } catch (error) { + // Only a `commandSeed` failing here means the *parent's own proof* did not hold up — + // the caller (`runVerifiedReach`'s `explore` step) uses this to decide whether it is + // still worth paying for a full, unseeded exploration afterwards. A plain launch + // failing here is the pre-existing case and always did fall to the outer catch before + // this branch existed; recorded the same way now, just sooner and with a clearer note. + if (commandSeed) seedBaseFailed = true + reachError = `${commandSeed ? "Seed's stored path" : 'Launch'} failed: ${error instanceof Error ? error.message : String(error)}` + console.log(`[app-flow] ${node.id} ${reachError}`) + return false + } + const launchAuth = await ports.auth.checkGate(sweepQueue.remaining, budget) + if (launchAuth === 'stop') { + authStopped = true + return false + } + if (launchAuth === 'exhausted') { + reachError = 'Navigation budget exhausted after five minutes.' + return false + } + const graphPath = findNavigationPath(parsed.edges, node.id, entryNodeIds) + // Which control opens this screen, and from where — the parser reads it off the call + // site, so the planner is told what to tap instead of hunting for it screen by screen. + const incoming = parsed.edges + .filter((edge) => edge.to === node.id) + .map((edge) => + edge.trigger + ? `from ${edge.from}: tap ${edge.trigger.by}=${JSON.stringify(edge.trigger.value)}` + : `from ${edge.from}: control unknown (${edge.raw})` + ) + // `node.group` names only the first navigator a shared registration helper happened to + // be called from — true for id/display purposes, but stating it alone as WHERE the + // screen lives is actively misleading when it is really registered in several tabs (see + // `groups` on `AppFlowNode`). Naming all of them here is what stops the planner reading + // e.g. "registered in layout: HomeTab" for a screen that is just as reachable from + // Search, Notifications, MyProfile or Messages. + const layoutLine = + node.groups.length > 1 + ? `Target is registered via a shared helper in every one of: ${node.groups.join(', ')} — it is reachable from any of them, not only the first.` + : `Target is registered in layout: ${node.group || 'unknown'}` + // Bounds this one exploration attempt's opportunistic spend — see + // `OPPORTUNISTIC_VERIFY_LIMIT`. A fresh budget per `exploreFrom` call (seeded attempt, + // then a from-entry retry) rather than per node, since each is its own walk through the + // app and each can pass its own hub screens. + let opportunisticAttempts = 0 + // The action that produced whatever the next `observed` call sees, if any — set at the + // end of a successful `act` below. A chrome revealed by a tap (a hamburger button + // opening a drawer) needs this to record a non-empty `reveal`; a chrome that needed no + // reveal at all (an always-visible tab bar) simply never has one, and that is correct + // too — see the `chrome-observed` fact this feeds in `observed` below. + let lastAction: AppFlowStep | null = null + // A seeded walk starts on a screen this sweep already proved it can reach, so the first + // hop out of it is recordable. An unseeded walk starts from a fresh relaunch, where the + // landing screen is whatever the app restores — so it starts at null and the first + // `observed` confirmation sets the position without claiming an edge into it. + const cursor = createWalkCursor(seed?.id ?? null) + walkCursor = cursor + // One session for this whole exploration attempt (Step 0): the model keeps its own + // turn-by-turn history, so only the first step needs the full target briefing — + // every step after that sends just the fresh screenshot, the fresh control list, and + // anything that went wrong since the last step (`history`, which by this point only + // ever holds failures — see the doc comment on `decide` in exploration.ts). + // No tools. Every one of this role's decisions is made from the screenshot and the + // control list it is handed — the prompt never asks it to consult a file, and the + // source path in the briefing is context for naming, not an invitation to open it. + // Granting `read` let a walk that could not find its target start reading the repo + // instead: one ProfileKnownFollowers step ran 42s and 324k tokens, and that single + // unreachable screen accounted for 73% of an entire sweep's bill. `maxDecisions` + // cannot bound this, because it counts decisions and the reading happens inside one. + // + // History compaction: the session keeps its turns so a step does not resend what came + // before, but the provider still re-reads all of them every step. Measured on device: + // within one walk a step-1 planner call cost ~1,900 input tokens and step 3 cost + // ~3,900, and the sweep logged 137,600 cache-read tokens across 20 planner calls — + // more than four times its entire reasoning spend and the largest single line in the + // run. Older turns keep the model's own decision and lose their screenshot and control + // list; the briefing and the two most recent observations stay whole. See + // `pi/history-compaction.ts` for the exact rules. + const navigationSession = await runAgentSession({ + cwd: projectPath, + systemPrompt: NAVIGATION_SYSTEM_PROMPT, + tools: [], + // `low`, and deliberately not `off` like the judges. This is the one app-flow role + // that makes a genuine multi-step decision — where am I, what did my last tap do, + // which control moves me toward a screen I have not seen yet — so it is the role most + // likely to pay for zero reasoning in failed walks, and a failed walk costs far more + // than the reasoning it saved. `low` is still a large cut from what this role was + // actually getting: nothing was reaching the wire before, so every call ran at the + // backend's xhigh default (see `pi/provider.ts`). Worth retrying at `off` once a + // sweep has shown what `low` does to the arrival rate. + thinkingLevel: 'low', + compactHistory: { keepRecentUserTurns: 2 } + }) + let stepsSent = 0 + let exploration: ExplorationResult + try { + exploration = await runInteractiveExploration({ + budget, + // The five-minute budget alone is not a bound on how much a hopeless target costs: + // it bounds wall clock, not model calls, and a fast app burns decisions far quicker + // than it burns minutes. This is the bound on calls — see the constant's comment for + // why seven, and why an unreachable screen is exactly the case that used to run long. + // A bounded attempt gets a much tighter version of the same reasoning: the seed + // already proved everything up to one tap away, so three unproductive decisions (or + // even one that makes no visible difference, or one failed planner call) says the + // target is not one more tap from here after all. + maxDecisions: exploreOptions.bounded ? 3 : EXPLORATION_MAX_DECISIONS, + maxNoProgress: exploreOptions.bounded ? 1 : undefined, + maxPlannerFailures: exploreOptions.bounded ? 1 : undefined, + observe: async () => { + // No auth gate here: the app was relaunched and classified immediately before + // this loop started, and mid-exploration taps cannot reach a launch gate. The + // frame is the decider's vision input only, so every step overwrites one scratch + // file and `screens/` keeps just the delivered capture. + const screenshotPath = getScratchPath(projectId, 'explore') + await ports.device.captureScreenshot(screenshotPath) + return { + screenshotPath, + pressables: await ports.device + .capturePressables(Math.max(1_000, Math.min(30_000, budget.remainingMs))) + .catch(() => []) + } + }, + check: () => checkSignal(signal), + decide: async (observation, history) => { + const visible = summarizePressables(observation.pressables) + const recentFailures = history.length + ? `\nSince the last step: ${JSON.stringify(history)}` + : '' + const controlsLine = `Visible controls now: ${JSON.stringify(visible.shown)}${visible.omitted ? ` (+${visible.omitted} more controls on this screen, not listed)` : ''}${recentFailures}\nChoose one next decision from this fresh observation.` + // A bounded attempt is seeded one hop from the target by construction — the + // planner is told so directly instead of being handed the same "find your way + // from an entry screen" briefing a full exploration gets, and the parent's own + // outgoing edge is handed over as a strong hint (never a command: a recorded + // control can drift from what the live screen shows). + const seedLine = commandSeed + ? `\nYou replayed a proven journey to ${commandSeed.id} and are one tap away from the target.${commandSeed.trigger ? ` The last known control for this hop: tap ${commandSeed.trigger.by}=${JSON.stringify(commandSeed.trigger.value)} — use it if it is still on screen, otherwise find the equivalent control.` : ''}` + : seed && 'url' in seed + ? `\nThe app was opened directly at ${seed.urlPath} (${seed.id}), so you start there rather than at the entry screen.` + : '' + const boundedLine = exploreOptions.bounded + ? '\nYou have very few actions left to find this screen from here — act only on a control you are confident leads to the target, and return blocked as soon as none does.' + : '' + const userPrompt = + stepsSent === 0 + ? `${appEntryBrief}${seedLine}${boundedLine}\nTarget screen: ${node.id} (${node.title})${deepLinkNotFound ? "\nOpening this route by URL landed on the app's not-found screen, so this build may not offer it at all. Confirm quickly via the named entry control and return blocked if it is absent." : ''}${unresolvedParams.length ? `\nThis route needs ${unresolvedParams.map((key) => `:${key}`).join(', ')}, so it shows one concrete item. If the entry chrome has a tab or menu item named after this screen, tap that first; it goes straight there. Only when no such entry exists, open any one item from a list that already leads here — which one does not matter.` : ''}\n${layoutLine}\nSource: ${node.file ?? 'unknown'}\nJourney from an entry screen: ${graphPath ? describeJourney(parsed.edges, graphPath) : 'none in the route graph — find it by exploring from the entry chrome'}\nWays into this screen: ${incoming.length ? incoming.join(' | ') : 'none found statically'}\n${controlsLine}` + : controlsLine + stepsSent += 1 + const response = await navigationSession.prompt( + { + userPrompt, + imagePath: observation.screenshotPath, + // One planning step is a look at a screenshot and a control list, so a call + // still running after a minute is stuck. Capping it here leaves the + // five-minute budget for the steps that follow instead of spending it all + // on one of them. + timeoutMs: Math.max(1_000, Math.min(60_000, budget.remainingMs)) + }, + `navigate ${node.id} step ${stepsSent}` + ) + return parseNavigationDecision(parseSubAgentJson(response.text)) + }, + act: async (action) => { + console.log(`[app-flow] explore ${node.id} action ${JSON.stringify(action)}`) + try { + await ports.device.executeNavigationActions( + [action], + Math.max(1_000, budget.remainingMs) + ) + lastAction = action + noteWalkAction(cursor, action) + // Recorded here, after the action succeeded, so the stored path contains + // exactly the steps the device actually performed — the same point the old + // recording transaction captured them at. A step with no faithful CLI form + // poisons the whole list rather than being skipped; see `walkCommands`. + if (walkLines) { + const line = walkStepCommand(action) + if (line) walkLines.push(line) + else if (action.type !== 'wait') walkLines = null + } + } catch (error) { + // The loop turns this into a history note for the planner. Without a log line the + // run reads as an action that silently did nothing to the screen. + console.log( + `[app-flow] explore ${node.id} action failed — ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + }, + verifyArrival: async (observation) => { + const arrival = await verifyCurrentTarget( + node, + observation.screenshotPath, + budget + ) + if (arrival.verdict !== 'match') return false + acceptedArrival = arrival + // Only when a single tap separates us from the last confirmed screen — the same + // conservatism `noteWalkArrival` applies before minting an edge, and for the same + // reason: with two or more actions in between, which one produced the arrival is a + // guess. Read before `noteArrival`, which resets the cursor's pending actions. + if ( + cursor.actionsSinceConfirmation.length <= 1 && + lastAction?.locator?.by === 'id' + ) { + paramStore.learnFromArrival(lastAction.locator.value, node, node.id) + } + // The walk's own target, confirmed — the strongest hop the sweep ever observes, + // since this is the judge the whole ladder exists to satisfy. + noteArrival(node.id) + // Kept for the destination-guard fallback below and for delivery: these are the + // controls of the screen the judge just accepted, and the only place they are + // still in hand once the recording has been published. Every later observation + // overwrites the scratch frame, and the loop returns no observation of its own. + arrivalPressables = observation.pressables + try { + const frame = getScratchPath(projectId, 'arrival-frame') + await copyFile(observation.screenshotPath, frame) + arrivalFrame = frame + } catch (error) { + // Copying the frame aside is a delivery convenience, never a condition of the + // arrival — the judge has already spoken on this screenshot. A failed copy costs + // the fallback frame and leaves the arrival exactly as accepted. + console.log( + `[app-flow] ${node.id} could not keep the verified arrival frame — ${error instanceof Error ? error.message : String(error)}` + ) + } + return true + }, + // Side observations: a tap made while walking to `node` can land on, or reveal, + // something worth remembering that has nothing to do with this walk's own target — + // another still-pending screen (the original opportunistic-capture case), a + // navigator chrome (plan Step 3d), or a screen the parser never registered a node + // for at all (plan Step 4a). This purely observes: it never influences what `decide` + // returns, never injects a step, and never lets `node` itself resolve via this path — + // `verifyArrival` above is the only way this walk's own target is accepted. Each + // check below reports what it found as a `DiscoveredFact` rather than writing + // `captureMap`/`runtimeNodes`/`chromeInventory` directly, so this hook stays ignorant + // of the graph it feeds — see `DiscoveredFact`'s doc comment (`reach/types.ts`) and + // `applyDiscoveredFact` above, which is what actually folds one in. + observed: async (observation) => { + if (budget.remainingMs < OPPORTUNISTIC_MIN_REMAINING_MS) return + const observedTokens = fingerprint(observation.pressables) + + // 1. A tap made while walking to `node` landed on some other screen the sweep + // already has a parsed node for. Never the node this walk is already trying to + // reach, never a node the sweep has already settled (anything with a + // `captureMap` entry), and never a route with no in-app entry point at all. + // + // Only when the observation matches no known runtime node: a match there is a + // better answer than a title-overlap guess, and the later `matchRuntimeNode` + // call in this block will use it, so spending a model call on a sibling-prone + // guess first would only pay for an answer the sweep already has. + if ( + opportunisticAttempts < OPPORTUNISTIC_VERIFY_LIMIT && + !matchRuntimeNode(runtimeNodes, observedTokens) + ) { + const pending = nodesWithSlug.filter( + (other) => + other.id !== node.id && !captureMap[other.id] && !isFallbackRoute(other) + ) + const known = pending.length + ? pickOpportunisticCandidate(observedTokens, pending) + : null + if (known) { + opportunisticAttempts += 1 + const arrival = await verifyCurrentTarget( + known, + observation.screenshotPath, + budget + ) + if (arrival.verdict === 'match') { + await applyDiscoveredFact({ + kind: 'opportunistic-capture', + nodeId: known.id, + arrival, + screenshotPath: observation.screenshotPath, + pressables: observation.pressables + }) + return + } + console.log( + `[app-flow] opportunistic ${known.id} rejected while exploring ${node.id} — ${arrival.reason}` + ) + } + } + + // 2. A navigator chrome — pure token matching, no vision call, so it never + // competes with the two model-backed checks above/below for the walk's own + // opportunistic budget. + const chromeDetected = detectChromeInventory( + observation.pressables, + chromeCandidates() + ) + if (chromeDetected) { + // `node` is where this walk is *going*, not where it is standing — it has not + // been reached yet, or the walk would be over. Attributing the reveal to it said + // the drawer was opened from a screen the explorer had never seen, which is how a + // device run ended up recording the Settings screen's rows as a chrome revealed + // from `HomeTab/Home`. The cursor holds the last position an arrival judge + // actually confirmed; the target is only the fallback when nothing has been + // confirmed yet. + const revealedFrom = walkCursor?.confirmedNodeId ?? node.id + // Only a tap that reads as a drawer opener is a reveal. `lastAction` is just + // whatever the walk happened to tap before this frame, so passing it through + // unconditionally recorded an always-visible tab bar as needing a reveal — and + // `buildChromePathToReach` then published that tap as the first step of the + // journey to every screen behind it. On device that produced paths beginning + // `press 'id="feedItem-by-gregpak.net"'` — tapping a post in the feed — as the + // documented way to reach 40 different screens. + const reveal = + lastAction && inferChromeKind([lastAction]) === 'drawer' ? [lastAction] : [] + await applyDiscoveredFact({ + kind: 'chrome-observed', + entryNodeId: revealedFrom, + reveal, + items: chromeDetected.items + }) + return + } + + // 3. A screen the parser never registered a node for at all. Only worth spending a + // model call on once neither cheap check above accounted for this frame, and + // only within the walk's own opportunistic budget and the sweep-wide mint cap. + if (opportunisticAttempts >= OPPORTUNISTIC_VERIFY_LIMIT) return + if (runtimeNodes.length >= RUNTIME_NODE_SWEEP_CAP) return + // Similarity-only pass first, with no id yet: a repeat sighting of an + // already-minted runtime node is recognised for free, without spending the model + // call the judge check below costs. + const knownRuntime = matchRuntimeNode(runtimeNodes, observedTokens) + if (knownRuntime) { + await applyDiscoveredFact({ + kind: 'runtime-screen-sighted', + match: knownRuntime, + key: knownRuntime.id, + info: '', + observedTokens: [...observedTokens], + screenshotPath: observation.screenshotPath, + pressables: observation.pressables + }) + return + } + opportunisticAttempts += 1 + const distinctResponse = await runAgent( + { + cwd: projectPath, + systemPrompt: RUNTIME_SCREEN_SYSTEM_PROMPT, + userPrompt: 'Is this a distinct app screen not already accounted for?', + tools: [], + // A classification of the frame in front of it, like the arrival judge — real + // screen or transient overlay — so `off` for the same reason. This one is also + // purely opportunistic: it runs on frames a walk happened to pass through, and + // declining to mint a node is always a safe answer. + thinkingLevel: 'off', + imagePath: observation.screenshotPath, + timeoutMs: Math.max(1_000, Math.min(30_000, budget.remainingMs)) + }, + 'runtime-screen' + ).catch((error) => { + console.log( + `[app-flow] runtime-screen check failed while exploring ${node.id} — ${error instanceof Error ? error.message : String(error)}` + ) + return null + }) + const verdict = distinctResponse + ? parseSubAgentJson<{ distinct: boolean; key?: string; info?: string }>( + distinctResponse.text + ) + : null + if (!verdict?.distinct || !verdict.key) return + const candidateId = `runtime:${sanitizeScreenKey(verdict.key) || 'screen'}` + const exactMatch = matchRuntimeNode(runtimeNodes, observedTokens, candidateId) + await applyDiscoveredFact({ + kind: 'runtime-screen-sighted', + match: exactMatch, + key: verdict.key, + info: verdict.info ?? '', + observedTokens: [...observedTokens], + screenshotPath: observation.screenshotPath, + pressables: observation.pressables + }) + } + }) + } finally { + navigationSession.dispose() + // The walk is over, however it ended. Leaving the cursor in place would let the next + // confirmation from anywhere else in the sweep read the screen this walk stopped on as + // its own starting point and mint an edge across a relaunch that never happened. + walkCursor = null + } + if (!exploration.arrived) { + seededWalkRan = true + reachError = exploration.reason + console.log(`[app-flow] ${node.id} exploration stopped — ${exploration.reason}`) + return false + } + + // The whole publish-and-verify tail used to live here: pick a portable destination + // guard, `saveAndReplayRecording` the candidate, survive its failure, rename the + // candidate over the published `.ad`. Every line of it was in service of the recording, + // and on the measured sweep every single exploration-based capture failed somewhere in + // it — four walks, two distinct causes — so the arrival was kept and the script was + // lost anyway. What is stored instead is the walk itself. + // + // Nothing here can fail in a way that costs the arrival: `walkLines` was assembled as + // the walk went, and a walk whose steps had no faithful CLI form simply stores no path + // (`walkCommands`'s all-or-nothing rule) while keeping the screen reached, exactly as + // the missing-landmark case used to. + if (!walkLines) { + console.log( + `[app-flow] ${node.id} arrival preserved without a stored path — a walked step had no faithful agent-device form` + ) + return true + } + // Minimized before it is stored, never while it is being walked: `act` records what + // the device did, and the walk's own history is what the planner reasons over. The + // trim applies to this walk's lines only — `prefix` is either a baseline launch or a + // parent's already-stored journey, neither of which this walk is entitled to rewrite + // (see `minimizeWalkCommands`). + const minimized = minimizeWalkCommands(walkLines) + pathCommands = [...prefix, ...minimized.commands] + const contentSelectors = contentMatchedSelectors(pathCommands) + console.log( + `[app-flow] ${node.id} stored a ${pathCommands.length}-command path${ + commandSeed ? ` seeded from ${commandSeed.id}` : '' + }${ + minimized.removed.length + ? ` (minimized: dropped ${minimized.removed.length} — ${minimized.removed + .map((entry) => `${entry.rule}: ${entry.command}`) + .join('; ')})` + : '' + }${ + contentSelectors.length + ? ` — matches on rendered content, may not replay elsewhere: ${contentSelectors.join(', ')}` + : '' + }` + ) + return true + } catch (error) { + checkSignal(signal) + reachError = `Interactive exploration failed: ${error instanceof Error ? error.message : String(error)}` + // Every other exit from `exploreFrom` logs a line naming the node and the reason + // (the `!exploration.arrived` branch above, the explicit `reachError` assignments), + // so a candidate discarded here for an uncaught error was the one silent path — the + // device run this fix responds to jumped straight from the arrival judge's verdict to + // the next node with nothing in between to explain why the screen was lost. + console.log(`[app-flow] ${node.id} exploration failed — ${reachError}`) + return false + } + } + + // Effect folding — plan landing-order item 4. Reach strategies + // (`reach/strategies/`) are pure: they read `ports` and return a `ReachAttempt`, never + // reaching into this closure's state themselves. This is the one place their `effects` + // turn into the sweep-level state `trySavedReplay`/`tryDeepLink` used to mutate directly — + // see `reach/types.ts`'s doc comment on `ReachEffect` for why each kind exists. + const scratchPath = (key: string): string => getScratchPath(projectId, key) + const applyReachEffects = (effects: ReachAttempt['effects']): void => { + for (const effect of effects) { + switch (effect.kind) { + case 'deep-link-opened': + openedByNode[effect.nodeId] = effect.opened + // Every URL the app really opened is a complete, co-occurring param set for + // whichever route it matches — the cleanest record there is, and it may belong to a + // different route than the one being reached. + paramStore.ingestUrl(effect.opened.url, effect.nodeId, nodesWithSlug) + break + case 'deep-link-rejection-recorded': + deepLinkRejections[effect.nodeId] = effect.rejection + console.log( + `[app-flow] ${effect.nodeId} recording deep-link rejection so later sweeps skip ${effect.rejection.url}` + ) + break + case 'deep-link-rejection-cleared': + deepLinkRejections[effect.nodeId] = null + console.log( + `[app-flow] ${effect.nodeId} deep link succeeded — clearing stored rejection` + ) + break + case 'deep-link-memo-rejected': + rejectedDeepLinkUrls.reject(effect.url) + break + case 'guard-gate-closed': + closedGuardGates.close(effect.condition) + break + case 'deep-link-not-found': + deepLinkNotFound = true + break + case 'deep-link-breaker-success': + deepLinkBreaker = registerDeepLinkSuccess(deepLinkBreaker) + break + case 'deep-link-breaker-failure': + deepLinkBreaker = registerDeepLinkFailure( + deepLinkBreaker, + Boolean(probeRoute) + ).state + break + case 'deep-link-breaker-disabled': + deepLinkBreaker = { ...deepLinkBreaker, enabled: false } + break + case 'deep-link-partial-landed': + // Consumed synchronously by the deep-link rung below, in the same call that + // produced it — see that rung's own comment for why this cannot wait for a later + // strategy to read it back out of `effects`. + break + } + } + } + + /** + * Claims the reach the one-time deep-link probe already performed and verified, instead of + * performing the identical one again — see `probeCredit`. + * + * Reports itself as `'deep-link'` rather than inventing a strategy name, because that is + * honestly how the app got here: the probe opened this node's URL and a judge confirmed + * the frame. Every downstream consumer (`captureMethod`, `node.reach`, `decideDelivery`) + * is then looking at the same evidence it would have had, from the same transport, and + * none of them needs to learn a new case. The effects match the deep-link strategy's own + * success path for the same reason. + * + * `touchedDevice: false` is the one place it differs, and it is accurate: this rung makes + * no device call at all. That is the entire point — the device is already on the screen. + */ + const probeCreditRung: ReachStrategy = async () => { + const credit = claimProbeCredit(node.id) + if (!credit) { + return { + strategy: 'deep-link', + ok: false, + reason: 'No verified probe reach is available for this node.', + touchedDevice: false, + elapsedMs: 0, + effects: [] + } + } + const effects: ReachEffect[] = [{ kind: 'deep-link-breaker-success' }] + if (credit.opened) { + effects.push({ kind: 'deep-link-opened', nodeId: node.id, opened: credit.opened }) + } + applyReachEffects(effects) + console.log( + `[app-flow] reach ${node.id} — reusing the deep-link probe's verified arrival, no second navigation` + ) + return { + strategy: 'deep-link', + ok: true, + reason: credit.arrival.reason, + touchedDevice: false, + arrival: credit.arrival, + evidence: { + arrival: credit.arrival, + frame: credit.frame, + pressables: credit.pressables, + replayScript: null, + replayVerified: false + }, + elapsedMs: 0, + effects + } + } + + const savedReplayRung: ReachStrategy = (_input, p) => + savedReplayStrategy( + { + node, + scriptPath: savedScript, + budget, + remaining: sweepQueue.remaining, + scratchPath, + signal + }, + p + ) + + // `tryDeepLink`'s pure open-verify-record-rejection sequence, moved to + // `reach/strategies/deep-link.ts`. One entanglement stays here rather than in that module: + // a param-free prefix landing on a real screen (Step 3f) used to call `exploreFrom` for a + // bounded last hop *from inside* `tryDeepLink`, and a successful hop was recorded as method + // `'deep-link'` (so `node.reach` becomes `'deep-link'`, not `'navigation-only'`) — not as an + // exploration success. `exploreFrom` has not moved out of this file yet (landing-order item + // 5), so the pure strategy cannot call it without reaching back into this closure; instead + // it reports the landing as a `deep-link-partial-landed` effect and this wrapper — still + // "exploreFrom, wrapped as a strategy" per the plan — performs the same bounded hop the + // closure used to, preserving the exact method/reach classification. + /** The controls last read off this build's own not-found route, when anything captured it. */ + const capturedFallbackPressables = (): AppFlowPressable[] | null => { + for (const candidate of nodesWithSlug) { + if (!isFallbackRoute(candidate)) continue + const captured = captureMap[candidate.id]?.pressables + if (captured?.length) return captured + } + return null + } + + const deepLinkRung: ReachStrategy = async (_input, p) => { + const attempt = await deepLinkStrategy( + { + node, + unresolvedParams, + params, + budget, + scratchPath, + signal, + breaker: deepLinkBreaker, + storedRejection: deepLinkRejections[node.id], + memoRejected: (url) => rejectedDeepLinkUrls.wasRejected(url), + guardGateClosed: (condition) => closedGuardGates.wasClosed(condition), + nodesForAncestry: nodesWithSlug.map((candidate) => ({ + id: candidate.id, + urlPath: candidate.urlPath, + capturedStatus: captureMap[candidate.id]?.status ?? null + })), + reprobe: reprobeDeepLinkRoute, + arrivalJudge: deepLinkArrivalJudge, + // Only assembled when it will be read. The build's own fallback route is the one + // build-specific signature the judge-free check has; `isFallbackRoute` is the same + // predicate the sweep order and this loop's own fallback handling use, so the three + // cannot disagree about which route that is. Usually null on a first sweep (fallback + // routes sort last) and populated on a resumed one, where the capture is read back + // off the document — see `detectNotFoundScreen`'s rule 1. + notFoundEvidence: deepLinkArrivalJudge + ? undefined + : { fallbackPressables: capturedFallbackPressables() } + }, + p + ) + applyReachEffects(attempt.effects) + if (attempt.ok || attempt.failure) return attempt + + const partial = attempt.effects.find( + (effect) => effect.kind === 'deep-link-partial-landed' + ) + if (!partial || partial.kind !== 'deep-link-partial-landed') return attempt + console.log( + `[app-flow] ${node.id} prefix ${partial.seed.urlPath} landed on a real screen — attempting a bounded last hop for the remaining segment` + ) + if ( + await exploreFrom( + { id: partial.seed.id, urlPath: partial.seed.urlPath, url: partial.seed.url }, + { bounded: true } + ) + ) { + openedByNode[node.id] = { app: partial.seed.app, url: partial.seed.url } + paramStore.ingestUrl(partial.seed.url, node.id, nodesWithSlug) + deepLinkBreaker = registerDeepLinkSuccess(deepLinkBreaker) + if (deepLinkRejections[node.id]) { + deepLinkRejections[node.id] = null + console.log( + `[app-flow] ${node.id} bounded last hop succeeded — clearing stored rejection` + ) + } + return { + ...attempt, + ok: true, + reason: 'Bounded last hop from the partial prefix reached the target.', + arrival: acceptedArrival, + evidence: { + arrival: acceptedArrival, + pathCommands, + replayScript, + replayVerified: replayScriptVerified + } + } + } + if (authStopped) return { ...attempt, failure: 'auth-stopped' } + if (!budget.expired) { + console.log( + `[app-flow] ${node.id} bounded last hop from ${partial.seed.urlPath} did not reach — falling back to full exploration — ${reachError}` + ) + } + return attempt + } + + // `runVerifiedReach`'s old `explore` step, unchanged: `findSeedSource` (plan Step 3e) picks + // the best proof this sweep has that some other screen leads here, and `exploreFrom` itself + // has not moved out of this file yet (landing-order item 5) — this is exactly "exploreFrom, + // wrapped as a strategy" per the plan for this step. + const exploreRung: ReachStrategy = async () => { + const runExplore = async (): Promise => { + const seed = findSeedSource({ + urlPath: node.urlPath, + targetId: node.id, + nodes: nodesWithSlug, + edges: parsed.edges, + // Any journey this sweep has stored, not only one a *previous* sweep re-verified. + // The old test was `capture.replayScript`, and before that `replayVerified` — which + // could only ever be set by a previous sweep's saved replay, so seeding never fired + // within one run and every walk re-walked the hops its own parent had just walked. + // + // `pathCommands` is the honest test now, and strictly better than the script ever + // was: it is set by exactly the walks that arrived, it stands alone (its first line + // is its own launch), and running it needs no file on disk. A capture carried + // forward from a previous sweep brings its own along, so a resumed run seeds from it + // too. + storedPath: (id) => captureMap[id]?.pathCommands ?? null, + deepLinkedThisRun: (id) => openedByNode[id]?.url ?? null + }) + + if (seed?.kind === 'path') { + console.log( + `[app-flow] ${node.id} bounded last hop from ${seed.id}'s stored path${seed.trigger ? ` — tap ${seed.trigger.by}=${JSON.stringify(seed.trigger.value)}` : ''}` + ) + if ( + await exploreFrom( + { id: seed.id, commands: seed.commands, trigger: seed.trigger }, + { bounded: true } + ) + ) { + return true + } + if (authStopped || budget.expired) return false + if (!seedBaseFailed) { + // The parent's own proof held — its script really did keep an active session — and + // the bounded loop still ran out its three decisions without arriving. That is not + // "approached badly", it is the seed's own evidence that this target is not one tap + // from there, so a full unseeded walk is not worth its five-minute price here; the + // node records unreached with the bounded loop's own reason instead. + return false + } + console.log( + `[app-flow] ${node.id} seed's stored path failed — falling back to full exploration — ${reachError}` + ) + } else if (seed?.kind === 'deep-link') { + console.log(`[app-flow] ${node.id} explore seeded at ${seed.urlPath} — ${seed.url}`) + if (await exploreFrom({ id: seed.id, urlPath: seed.urlPath, url: seed.url })) + return true + if (authStopped || budget.expired || unavailableReason) return false + if (!shouldRetryFromEntry({ walkRan: seededWalkRan, reason: reachError })) { + console.log( + `[app-flow] ${node.id} seeded exploration ran its decisions without arriving — not retrying from the app entry, it would have nothing new to try — ${reachError}` + ) + return false + } + console.log( + `[app-flow] ${node.id} seeded exploration failed, retrying from the app entry — ${reachError}` + ) + } + return exploreFrom(null) + } + + const exploreStartedAt = Date.now() + const ok = await runExplore() + return { + strategy: 'entry-exploration', + ok, + failure: !ok && authStopped ? 'auth-stopped' : undefined, + reason: ok + ? (acceptedArrival?.reason ?? 'Exploration arrived.') + : (reachError ?? 'Exploration failed.'), + touchedDevice: true, + arrival: acceptedArrival, + evidence: ok + ? { + arrival: acceptedArrival, + // Always null since verification moved to first use: nothing on the exploration + // path takes a post-replay frame any more, so delivery takes its own frame and + // read — the fallback this field always had. + frame: null, + pressables: null, + pathCommands, + replayScript, + replayVerified: replayScriptVerified + } + : undefined, + // Measured rather than the 0 this used to report: the `reach + // ()` line reads this field, and the most expensive rung in the ladder logged as + // free. + elapsedMs: Date.now() - exploreStartedAt, + effects: [] + } + } + + /** + * Navigate through the app's own navigator, for screens a URL cannot address. + * + * It reuses `params`/`unresolvedParams` exactly as the deep-link rung does, so a route + * whose values the store could not fill is skipped here too rather than dispatched with a + * hole in it. Arrival stays the judge's call; see `reach/strategies/render.ts`. + */ + const renderRung: ReachStrategy = async (_input, p) => { + const attempt = await renderStrategy( + { + node, + params, + unresolvedParams, + budget, + scratchPath, + guardGateClosed: (condition) => closedGuardGates.wasClosed(condition), + // Only Fast may settle for the runtime's own readiness evidence in place of the + // vision judge; auto and thorough ask the judge on every arrival exactly as before. + // See `RenderInput.arrivalJudge`. + arrivalJudge: options.explorationMode === 'fast' ? 'fallback' : 'always' + }, + p + ) + applyReachEffects(attempt.effects) + return attempt + } + + const outcome = await runReachLadder({ + input: null, + ports, + budget, + signal, + // `probeCreditRung` first: it is the only rung that can succeed without touching the + // device at all, and when it has nothing to claim it falls through for free. Ahead of + // the saved replay because a replay would move the app off the very screen the credit + // is a statement about. + // `renderRung` between the deep link and exploration: a deep link is the more faithful + // journey and produces a reusable replay, so it keeps priority, but a navigator + // dispatch is far cheaper and far more predictable than asking a model to find the + // screen. See `reach/strategies/render.ts`. + // + // Which of the last two rungs are present is the user's choice — see `strategiesFor`. + strategies: strategiesFor(options.explorationMode, { + probeCredit: probeCreditRung, + savedReplay: savedReplayRung, + deepLink: deepLinkRung, + render: renderRung, + explore: exploreRung + }) + }) + + // Every rung the ladder tried, in order — the decline reasons that used to be dropped on + // the floor here. `touchedDevice` is the distinction that matters when reading a sweep + // back: `declined` short-circuited before any device call (an unresolved param, a tripped + // breaker, a cached rejection) and cost nothing, while `failed` actually ran and did not + // land. A rung that never fires and a rung that fires and misses need opposite fixes, and + // without this line the two are indistinguishable in the log — which is how a sweep came + // back showing 2 renders out of 42 screens with no record of why the other 40 skipped it. + // + // The winner is logged too, in the same `reach ()` shape, because a + // ladder that only narrates its misses leaves the rung that *worked* to be inferred from a + // separate `verify … via ` line — and the suffix names a diagnostic frame, not a + // rung. Its reason is left off: for a winner that is the arrival judge's description of the + // frame, which can quote whatever the screen shows (a handle, a record name), and the + // `verify` line already carries it. + for (const attempt of outcome.attempts) { + if (attempt.ok) { + console.log( + `[app-flow] ${node.id} reach ${attempt.strategy} succeeded (${attempt.elapsedMs}ms)` + ) + continue + } + const how = attempt.touchedDevice ? 'failed' : 'declined' + console.log( + `[app-flow] ${node.id} reach ${attempt.strategy} ${how} (${attempt.elapsedMs}ms) — ${attempt.reason}` + ) + } + + // What the ten mutable variables (`openedByNode`, `consecutiveDeepLinkFailures`, + // `deepLinkEnabled`, `authStopped`, `deepLinkNotFound`, `unavailableReason`, + // `deepLinkAttempted`, `reachError`, `acceptedArrival`, `replayScript`) used to be set to, + // directly, by whichever closure happened to succeed — now derived in one place from the + // ladder's own `outcome`. + if (outcome.status === 'reached') { + const winner = outcome.attempts[outcome.attempts.length - 1] + captureMethod = + winner.strategy === 'deep-link' + ? 'deep-link' + : winner.strategy === 'saved-replay' + ? 'saved-replay' + : winner.strategy === 'render' + ? 'rendered' + : 'agent-navigation' + if (winner.evidence) { + acceptedArrival = winner.evidence.arrival + pathCommands = winner.evidence.pathCommands ?? pathCommands + replayScript = winner.evidence.replayScript + replayScriptVerified = winner.evidence.replayVerified + verifiedFrame = winner.evidence.frame ?? null + verifiedPressables = winner.evidence.pressables ?? null + } else if (winner.arrival) { + acceptedArrival = winner.arrival + } + } else if (outcome.status === 'unavailable') { + unavailableReason = outcome.reason + } else if (outcome.status === 'stopped') { + if (outcome.reason === 'auth') authStopped = true + // A 'cancelled' stop only happens when `signal` was already aborted between rungs; every + // other cancellation path still throws directly (`checkSignal` inside a strategy). Doing + // the same here — rather than treating this as an ordinary "give up on this node" outcome + // — keeps a cancelled build behaving exactly as it did before this extraction: it unwinds + // out of `buildAppFlow` entirely instead of moving on to record this node as unreached. + else checkSignal(signal) + } else { + reachError = outcome.reason + } + const deepLinkAttempted = outcome.attempts.some( + (attempt) => attempt.strategy === 'deep-link' && attempt.touchedDevice + ) + + if (authStopped) { + sweepStopped = true + } + + if (sweepStopped) break + + // A harvested value can name a record the app no longer has, or one that is valid for the + // route that owns it and not for a sibling that borrowed it. This runs whatever the node + // ended up as: exploration frequently rescues a node whose deep link or render just failed + // on a bad value, and withdrawing the value only on an unreached verdict left every other + // borrower to pay for the same failure. + if (!unavailableReason) + demoteFailedHarvest(paramStore, node.id, resolution, outcome.attempts) + + if (captureMethod === 'unreached') { + if (unavailableReason) { + await recordUnreached(node, 'unavailable', unavailableReason) + emit(projectId, onEvent, { + type: 'step', + phase: 'capture', + message: `${label} is not available in this build`, + completed: sweepQueue.completed + 1, + total, + captured: explorationControl.screensExplored, + captureLimit: options.screenLimit + }) + if (finishExploredScreen(false)) break + // A screen this build withholds is a verdict, not a stuck app, so it must not feed the + // no-progress counter that decides the sweep is blocked and asks the user for help. + continue + } + await recordUnreached(node, 'missing', reachError) + if (finishExploredScreen(false)) break + // Every rung that ran was refused outright — the navigator does not register the route, + // so nothing was dispatched and the screen is exactly as it was found. Ahead of both the + // fingerprint read and the counter: the fingerprint would of course match the previous + // one (the app never moved), and three such refusals in a row tripped the blocked-run + // breaker and halted a measured run to ask the user to clear a blocker that did not + // exist. See `ReachAttempt.refused`. + if (outcome.status === 'unreached' && outcome.refused) continue + // The same reasoning, one step wider: no rung ever touched the device, so the app was + // never asked to go anywhere and could not possibly have moved. The fingerprint read + // below would match the previous failure's trivially, which is not evidence of a stuck + // app — it is evidence that nothing happened. Fast's ladder is render-only, so a run of + // nodes with unresolved route parameters (`renderStrategy`'s `skip`, 0ms, no dispatch) + // is three guaranteed matches in a row, and a 78-screen sweep interrupted the user with + // "the app keeps rendering this one screen" three times over screens it had just + // captured fine. Same for `budget.expired`, "No runtime transport" and a closed guard + // gate, which are equally device-free. + // + // What this gives up: if the app really is wedged *and* every node in the run happens to + // skip before dispatch, the breaker no longer notices. That is an acceptable trade — + // device-free skips carry no observation of the app at all, so the breaker was never + // reading the app's state in that case, only agreeing with itself. A genuinely stuck app + // is still caught the moment one node actually dispatches and the screen does not move, + // which is the case this counter is for and which stays fully intact. + if (!touchedDevice(outcome)) continue + // A planner that ran out of time left the app wherever the last action put it, which says + // nothing about the app being stuck — and sibling screens look so alike that counting + // these would stop the sweep to ask the user to clear a blocker that is not there. + if (isToolingFailure(reachError)) continue + const current = fingerprint(await capturePressables(ctx, device).catch(() => [])) + const overlap = failedReference ? similarity(current, failedReference) : 0 + if (failedReference && overlap >= ARRIVAL_SIMILARITY) blockedRun += 1 + else { + failedReference = current + blockedRun = 1 + } + if (blockedRun >= BLOCKED_RUN_LIMIT) { + const resolution = await resolveBlocked(sweepQueue.remaining - 1) + // The retry is for a *stuck app* — the blocker cleared, so the walk that failed against + // it deserves another run. It is not for a node the planner reasoned its way out of: + // `ModerationInboxReportDetails` concluded "no safe action reaches the target", the + // relaunch cleared the unrelated blocked-run counter, and the node was walked a second + // time to reach the same conclusion for a second full planner price. One retry per node + // keeps the recovery the breaker exists for and drops the repeat. + if (resolution === 'continue') { + if (requeuedIds.has(node.id)) { + console.log( + `[app-flow] ${node.id} — not retrying a second time; this run already reached the same verdict once` + ) + continue + } + requeuedIds.add(node.id) + sweepQueue.requeueCurrent() + continue + } + sweepStopped = true + break + } + continue + } + + // The static parse only ever guesses `reach` — this is where the device settles it. A + // deep link that actually matched proves the route routes; landing here instead via + // exploration or a saved replay, after a deep link was attempted and failed, proves the + // opposite. Anything not covered by either (no urlPath, deep link never attempted this + // node) keeps the parser's guess rather than being overwritten on no evidence. + if (captureMethod === 'deep-link') { + node.reach = 'deep-link' + } else if ( + (captureMethod === 'agent-navigation' || captureMethod === 'saved-replay') && + deepLinkAttempted + ) { + node.reach = 'navigation-only' + } + + emit(projectId, onEvent, { + type: 'step', + phase: 'capture', + message: `Capturing ${label}`, + completed: sweepQueue.completed + 1, + total, + captured: explorationControl.screensExplored, + captureLimit: options.screenLimit + }) + + // The screen is reached and verified by this point, so a failing device command here costs + // this one screen's detail — never the sweep. Losing the remaining screens to a transient + // runner fault (a watchdogged accessibility capture is the common one) would throw away + // every capture still ahead of it. See `decideDelivery` (reach/delivery.ts): a verified + // arrival or a published replay must survive a screenshot failure here, not be discarded. + const screenshotPath = getScreenshotPath(projectId, node.slug) + const deliveryStartedAt = Date.now() + console.log( + `[app-flow] capture ${sweepQueue.completed + 1}/${total} screenshot ${node.urlPath}` + ) + // Captured to scratch first, never straight to the node's slug: on the missing-session + // recovery branch the frame is the launch screen (`relaunched: true`), and writing that + // under this node's filename would silently corrupt an existing good screenshot even + // though the delivery record below correctly declines to treat it as reached evidence. + const deliveryShot = getScratchPath(projectId, 'delivery') + const relativePath = `screens/${node.slug}.png` + // Either the frame the judge already accepted, or the one taken just below. Both are this + // reach's own look at the screen, which is what `chooseDeliverySources` means by 'live'; + // the difference is only whether a second screenshot had to be spent to get one. + const liveFrame = verifiedFrame ?? deliveryShot + let liveScreenshot: DeliveryScreenshotOutcome + if (verifiedFrame) { + // A strategy that reports `evidence.frame` reached this screen and then stopped touching + // the device, so the frame the judge accepted is still what the screen shows — only the + // judge call itself sits between the two moments. Taking a second screenshot of it cost a + // measured ~0.6s per screen to produce a near-identical image, and the one delivered was + // the one *nothing* had verified. A `relaunched` frame cannot arrive here: it is turned + // into an automatic mismatch inside `verifyWithDiagnostic`, so no reach that reports a + // frame ever succeeded on one. + console.log(`[app-flow] ${node.id} delivering the frame the arrival judge verified`) + liveScreenshot = { ok: true, relativePath } + } else { + try { + const { relaunched } = await ports.device.captureScreenshot(deliveryShot) + liveScreenshot = relaunched + ? { ok: false, reason: 'session was lost; the app was relaunched at its launch URL' } + : { ok: true, relativePath } + } catch (error) { + checkSignal(signal) + const detail = error instanceof Error ? error.message : String(error) + console.log(`[app-flow] ${node.id} screenshot failed — ${detail}`) + liveScreenshot = { ok: false, reason: `Screenshot failed: ${detail}` } + } + } + + console.log( + `[app-flow] capture ${sweepQueue.completed + 1}/${total} pressables ${node.urlPath}` + ) + let livePressables: AppFlowCapture['pressables'] = [] + let livePressablesError: string | null = null + if (verifiedPressables) { + // Read during the arrival judge's own latency rather than after it — see + // `withPressables` in `verify-with-diagnostic.ts`. Still this reach's own read of this + // screen, and taken at the same instant as the frame delivered above, so the two halves + // of the record now describe one moment instead of two. + livePressables = [...verifiedPressables] + } else { + try { + livePressables = await ports.device.capturePressables() + } catch (error) { + checkSignal(signal) + livePressablesError = error instanceof Error ? error.message : String(error) + console.log(`[app-flow] ${node.id} pressables failed — ${livePressablesError}`) + } + } + console.log( + `[app-flow] capture ${sweepQueue.completed + 1}/${total} pressables found ${livePressables.length} ${node.urlPath}` + ) + + // Both halves of the record come from the same judgement about whether the live device is + // still on this screen — see `chooseDeliverySources`. Note that the control read above runs + // either way: it is the fresher source whenever it can be trusted, and finding that out + // costs one device call that the screenshot has already established is safe to spend. + const sources = chooseDeliverySources({ + liveScreenshot, + livePressables, + livePressablesError, + arrivalFramePath: arrivalFrame, + arrivalPressables + }) + // This is where a captured screen's testIDs become openable URLs for *other* routes: a + // `profileCard-alice.test-link` on this screen is the value `/profile/:name` could not get + // from source text. + paramStore.ingestPressables(node.id, sources.pressables.pressables, nodesWithSlug) + await harvestNavState(node) + await harvestLinks(node) + let screenshotOutcome: DeliveryScreenshotOutcome + if (sources.screenshot.source === 'none') { + screenshotOutcome = { ok: false, reason: sources.screenshot.reason } + } else { + const frame = + sources.screenshot.source === 'live' ? liveFrame : sources.screenshot.framePath + try { + await copyFile(frame, screenshotPath) + screenshotOutcome = { ok: true, relativePath } + if (sources.screenshot.source === 'arrival') { + console.log( + `[app-flow] ${node.id} delivered the verified arrival frame; the live device had moved on` + ) + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + console.log(`[app-flow] ${node.id} could not store the delivery frame — ${detail}`) + screenshotOutcome = { ok: false, reason: `Screenshot unavailable: ${detail}` } + } + } + if (sources.pressables.source === 'arrival') { + console.log( + `[app-flow] ${node.id} delivered the verified arrival's controls; the live read was taken after the device had moved on` + ) + } + + const finalArrival = acceptedArrival as ArrivalResult | null + // Intentionally empty for a screen Fast accepted on runtime evidence: a verdict reached + // without the vision judge carries no `key`/`info`, because those are the judge's own name + // and description of the frame and nothing else produces them. `inferredScreens` is the + // judge's naming of screens, so it is thin on a Fast run by construction — not a bug, and + // not something to fill in with the route name, which would make a parsed id look like an + // observation. See `RenderInput.arrivalJudge`. + if (finalArrival) { + inferredScreens[node.id] = { + key: finalArrival.key, + info: finalArrival.info + } + } + failedReference = null + blockedRun = 0 + + const evidence: ReachEvidence = { + arrival: finalArrival, + pathCommands, + replayScript, + replayVerified: replayScriptVerified + } + const delivery = decideDelivery({ + kind: 'attempt', + evidence, + screenshot: screenshotOutcome, + pressables: sources.pressables.pressables, + pressablesError: sources.pressables.error, + pressablesSource: sources.pressables.source, + captureMethod: captureMethod as NonNullable, + structureRevision + }) + // The device is still standing on this screen — it was reached and verified above and + // nothing has navigated since — which is the only moment its UI states can be opened + // without visiting the screen a second time. Only a screen that actually delivered a + // screenshot is worth presenting things on. The states are *returned* and attached to + // this node's capture here, so they ride the same `captureMap` / checkpoint publish as the + // rest of the capture instead of a second write path. Awaited to completion — including + // the rollback to level 0 — before the loop advances, because the next screen is reached + // from this very device. `capture` never throws: a failure here costs this node its + // states, never the sweep. + const nodeCapture = await attachSweepUiStates(uiStateCapturer, node, delivery) + captureMap[node.id] = nodeCapture + // Closes this node's run-log record. Counts and timings only — see `SweepLogPort` for + // what deliberately never goes in it. + const nodeLog = sweepLog?.node(node.id) + nodeLog?.delivery({ + screenshotMs: Date.now() - deliveryStartedAt, + pressables: sources.pressables.pressables.length + }) + nodeLog?.finish({ + status: delivery.capture.status, + totalMs: Date.now() - nodeStartedAt + }) + // A chrome edge is minted the moment the drawer or tab bar is *seen*, so it starts life + // unverified and scriptless — `chromeEdges` has nothing better to record at that point. + // Once this same destination is actually reached and a replay script published for it, the + // edge's own claim has been proven by a walk, so upgrade it. This is what makes + // `buildChromePathToReach`'s preferred `agent-device replay` form reachable at all: nothing + // else in the sweep ever sets `evidence.replayScript`, and without this the chrome path + // would forever fall back to replaying the reveal and item taps by selector. + if (delivery.capture.replayVerified && delivery.capture.replayScript) { + for (let at = 0; at < runtimeEdgesList.length; at++) { + const edge = runtimeEdgesList[at] + if (edge.to !== node.id || !edge.evidence) continue + runtimeEdgesList[at] = { + ...edge, + evidence: { + ...edge.evidence, + verified: true, + replayScript: delivery.capture.replayScript + } + } + } + } + await persistCaptureUpdate() + if ( + finishExploredScreen(delivery.kind === 'reached' && Boolean(delivery.capture.screenshot)) + ) + break + } + + if (options.pauseRequested?.()) { + sweepPaused = true + } + if (sweepStopped) { + // The checkpoint is the thing the user picks up from, so it goes out before the terminal + // writes below. + await persistCheckpoint({ force: true }) + manifest.sweepStatus = sweepPaused ? 'paused' : 'stopped' + // The capture map and the screens manifest are still published wholesale here — this path's + // `persistCaptureUpdate` writes only the checkpoint and the throttled document, and the + // flat maps are what a *later* tool read (not the renderer) consumes. The document itself + // is built by `currentFlowDocument`, shared with the live publisher and the finish path so + // the graph never changes shape between an intermediate publish and the terminal one. + await writeCaptureStatus(projectId, captureMap) + const stoppedScreens = currentAgentDeviceScreens() + await writeAgentDeviceScreens(projectId, stoppedScreens) + const stoppedResult = currentFlowDocument(stoppedScreens) + await finishManifest(manifest) + await writeAppFlow(projectId, stoppedResult) + // Persisted once per sweep rather than per screen: the store only matters to the *next* + // run, and `persistCheckpoint` already writes on every screen. + await writeParamStore(projectId, paramStore.toJSON()).catch(() => undefined) + emit(projectId, onEvent, { + type: 'complete', + phase: 'render', + message: sweepPaused + ? 'Screen sweep paused at a checkpoint; partial results were preserved.' + : 'Screen sweep stopped; partial results were preserved.' + }) + // A paused sweep is a *completed* run of a bounded sweep, not a degenerate one: hitting the + // screen limit is the ordinary outcome of `screenLimit`, and this is the exit it takes. It + // was nonetheless the one exit with no closing summary, so the runs whose cost you most + // want to read — the ones that stopped because they ran out of budget — were the runs that + // reported nothing, and a seven-minute sweep's breakdown had to be reconstructed by hand + // from the per-call lines. + logAgentTokenSummary(tallies, tokens, hedges) + logPhaseSummary(timing) + return { ok: true, projectId, result: stoppedResult } + } + + await writeCaptureStatus(projectId, captureMap) + + const agentDeviceScreens = buildAgentDeviceScreens( + [...nodesWithSlug, ...runtimeNodes], + parsed.edges, + runtimeEdgesList, + chromeInventory, + ctx, + device, + openedByNode, + inferredScreens, + captureMap + ) + await writeAgentDeviceScreens(projectId, agentDeviceScreens) + console.log( + `[app-flow] wrote agent-device screens — ${Object.keys(agentDeviceScreens).length} entries` + ) + + checkSignal(signal) + if (options.pauseRequested?.()) { + sweepPaused = true + sweepStopped = true + } + + // Forced before the terminal writes: `clearSweepProgress` below removes the checkpoint on a + // completed sweep, and this is what guarantees no deferred write is still in flight to + // recreate it. On a stopped-but-not-cleared sweep it is the last durable checkpoint. + await persistCheckpoint({ force: true }) + await writeCaptureStatus(projectId, captureMap) + const finalScreens = currentAgentDeviceScreens() + await writeAgentDeviceScreens(projectId, finalScreens) + + // Stamped from the same `currentFlowDocument` the live publisher used, so the terminal write + // differs from the last intermediate only in freshness. + const finalResult = currentFlowDocument(finalScreens) + + manifest.tokens = snapshotUsage(tokens) + manifest.sweepStatus = sweepPaused ? 'paused' : sweepStopped ? 'stopped' : 'completed' + await finishManifest(manifest) + // A finished sweep has nothing left to resume: the next build starts clean. A stopped one + // keeps its checkpoint so the user can pick up where they left off. + if (!sweepStopped) await clearSweepProgress(projectId) + + await writeAppFlow(projectId, finalResult) + await writeParamStore(projectId, paramStore.toJSON()).catch(() => undefined) + const harvest = paramStore.summary() + console.log( + `[app-flow] params — harvested ${harvest.records} record(s) from ${harvest.templates} testID shape(s), ${harvest.learned} demonstrated` + ) + + checkSignal(signal) + emit(projectId, onEvent, { + type: 'complete', + phase: 'render', + message: sweepPaused + ? 'Screen sweep paused at a checkpoint; partial results were preserved.' + : sweepStopped + ? 'Screen sweep stopped; partial results were preserved.' + : 'Done.' + }) + logAgentTokenSummary(tallies, tokens, hedges) + logPhaseSummary(timing) + console.log('[app-flow] done') + + return { ok: true, projectId, result: finalResult } + } finally { + // The catch-all durable path: a cancel, a thrown device fault or a stop all leave through + // here, and whatever the last screen wrote must be on disk before the sweep lets go. The + // revision throttle makes this a no-op when a terminal path already flushed — including + // after `clearSweepProgress`, which must not be undone. + await flushCheckpointOnExit?.().catch(() => undefined) + // The one place the run log is closed, so every exit — finished, stopped, cancelled, + // thrown — gets the same closing phase summary and the same final flush. + await sweepLogOnExit?.close(formatPhaseSummary(timing)) + await fastRuntime?.close().catch(() => undefined) + // A device the user launched stays up so the next sweep can adopt it again. + if (!reusedDevice) await closeDevice(device) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + let preserved: AppFlowResult | null = null + if (activeManifest) { + activeManifest.sweepStatus = message === 'Cancelled' ? 'stopped' : 'failed' + // The tokens a cancelled sweep spent are just as real as a finished one's, and this is the + // only place they are recorded: `manifest.tokens` is written on the success path alone, so a + // cancel used to hand the renderer a manifest still carrying the *previous* run's total — or + // none at all on a first build, which is why the usage chip vanished the moment you stopped a + // sweep. The by-role breakdown is logged here for the same reason: a cancelled run is exactly + // the one whose bill you want to read. + activeManifest.tokens = snapshotUsage(tokens) + logAgentTokenSummary(tallies, tokens, hedges) + logPhaseSummary(timing) + await finishManifest(activeManifest).catch(() => undefined) + const existing = await readAppFlow(projectId).catch(() => null) + if (existing) { + preserved = { ...existing, manifest: activeManifest } + await writeAppFlow(projectId, preserved).catch(() => undefined) + } + } + if (message === 'Cancelled' && preserved) { + emit(projectId, onEvent, { + type: 'complete', + phase: 'render', + message: 'Screen sweep cancelled; partial results were preserved.' + }) + return { ok: true, projectId, result: preserved } + } + console.error(`[app-flow] build failed: ${message}`) + emit(projectId, onEvent, { type: 'error', phase: 'render', message }) + return { ok: false, error: message } + } +} + +/** + * How long a prompt waits for the user before the build gives up on them and carries on marking + * what it could not reach. Long enough to sign in by hand; short enough that a build nobody is + * watching does not hold a simulator overnight. + */ +const PROMPT_TIMEOUT_MS = 15 * 60_000 + +export class AppFlowBuilder extends EventEmitter { + private controller = new AbortController() + private running = false + private pauseRequested = false + private pendingPrompt: { + id: string + settle: (resolution: AppFlowPromptResolution) => void + } | null = null + + /** Answers an open prompt. Returns false when nothing is waiting for one. */ + resolvePrompt(resolution: AppFlowPromptResolution, promptId?: string): boolean { + const pending = this.pendingPrompt + if (!pending || promptId !== pending.id) return false + pending.settle(resolution) + return true + } + + private requestUserAction = (prompt: AppFlowPrompt): Promise => { + // Supersede an older prompt if the orchestrator reaches a newer checkpoint. + this.pendingPrompt?.settle('stop') + return new Promise((resolve) => { + const signal = this.controller.signal + const settle = (resolution: AppFlowPromptResolution): void => { + if (this.pendingPrompt?.settle !== settle) return + this.pendingPrompt = null + clearTimeout(timer) + signal.removeEventListener('abort', onAbort) + resolve(resolution) + } + // Cancelling or walking away must not leave the sweep waiting on a click forever. + const onAbort = (): void => settle('stop') + const timer = setTimeout(() => { + console.log(`[app-flow] prompt (${prompt.kind}) timed out — stopping`) + settle('stop') + }, PROMPT_TIMEOUT_MS) + this.pendingPrompt = { id: prompt.id, settle } + if (signal.aborted) settle('stop') + else signal.addEventListener('abort', onAbort, { once: true }) + }) + } + + /** + * An options object rather than positional parameters: the list had reached seven, two of its + * callers passed `undefined` through the middle of it to reach the ones they cared about, and + * adding `explorationMode` would have made an eighth. + */ + async start(options: { + projectPath: string + mode: AppFlowBuildMode + decisionProvider?: AppFlowDecisionProvider + screenLimit?: number + explorationMode?: ExplorationMode + deepLinkJudgeMode?: DeepLinkJudgeMode + captureUiStates?: boolean + /** Fast only: ask the arrival judge about a screen the runtime snapshot could not vouch for. */ + judgeUnclearScreens?: boolean + launchedDevice?: CaptureDevice + }): Promise { + if (this.running) throw new Error('Build already in progress') + this.running = true + this.controller = new AbortController() + this.pauseRequested = false + + let result: AppFlowBuildResult | AppFlowBuildError + try { + result = await buildAppFlow({ + ...options, + pauseRequested: () => this.pauseRequested, + signal: this.controller.signal, + onEvent: (event) => this.emit('event', event), + requestUserAction: this.requestUserAction + }) + } catch (error) { + // buildAppFlow catches its own failures, but a listener throwing (or an unexpected bug) + // must not leave `running` stuck true with no terminal event. + result = { ok: false, error: error instanceof Error ? error.message : 'Unknown error' } + } finally { + this.running = false + this.pendingPrompt = null + } + + if (result.ok) { + this.emit('done', result) + } else { + this.emit('error', result) + } + } + + cancel(): void { + console.log('[app-flow] cancel requested') + this.controller.abort() + } + + pause(): void { + console.log('[app-flow] pause requested; waiting for the next screen checkpoint') + this.pauseRequested = true + // A user-action prompt is itself a durable boundary, so it need not remain open before the + // orchestrator can observe the cooperative pause request. + this.pendingPrompt?.settle('stop') + } + + isRunning(): boolean { + return this.running + } +} diff --git a/src/app-flow/param-store.ts b/src/app-flow/param-store.ts new file mode 100644 index 00000000..acfa69a7 --- /dev/null +++ b/src/app-flow/param-store.ts @@ -0,0 +1,964 @@ +/** + * Real values for route params, harvested from what the app itself showed us. + * + * `resolveRouteParams` (exploration.ts) reads values out of *source text* — the literals a + * `navigate('Profile', {name: 'alice'})` call site spells out. On an app that passes variables, + * which is nearly all of them, that finds nothing, and a parameterised route is then reachable only + * by walking to it: `/profile/:name` cost 29.2s of exploration in the reference sweep for a screen a + * resolved param opens in ~5s. The values were on the screen the whole time — Bluesky stamps handles + * into testIDs (`profileCard-alice.test-link`) and every navigation the harness performs reports the + * URL it landed on. + * + * Everything here obeys one rule: **a value is only ever observed, never invented.** A wrong id does + * not produce a weaker deep link, it produces a confidently wrong capture of some other record, and + * the 404 it lands on says nothing about the screen we were asked about. So where there is no + * evidence the answer is "no value", and the caller explores instead — exactly what it does today. + * + * The unit of storage is a **record**, not a param. A record is a complete param set taken from one + * single place: one pressable, one navigation observation, one real URL. Params are never spliced + * across sources, because `/profile/:name/lists/:rkey` built from a right `name` and some other + * record's `rkey` is precisely the wrong-capture failure above, and it is indistinguishable from + * success until a human looks at the screenshot. + */ + +/** Where a record's values came from, loosely ordered by how directly the app vouched for them. */ +export type ParamSource = 'static' | 'nav-state' | 'a11y-learned' | 'a11y-affinity' + +/** Highest first. Applied wholesale to whole records — see `best`. */ +const SOURCE_RANK: Record = { + static: 0, + 'nav-state': 1, + 'a11y-learned': 2, + 'a11y-affinity': 3 +} + +/** + * A complete param set for one route, from one observation. + * + * `values` covers **every** param the route declares or the record is not stored for it: a partial + * fill is the one thing that lets a right `name` carry a wrong `rkey` into a URL that opens cleanly. + */ +export interface ParamRecord { + /** The route these values are valid for. */ + nodeId: string + /** + * The route pattern `nodeId` carried when the observation was made, so the record can also answer + * for a route that shares these parameters — see `sharesRouteParams`. Absent on records carried + * over from a sweep that predates this field, which are then only ever used for their own node. + */ + urlPath?: string | null + /** Complete param set; every value has passed `PARAM_VALUE_RE` and any known shape check. */ + values: Record + source: ParamSource + /** Screen id the observation was made on, for provenance in the log. */ + seenOn: string + /** Monotonic within a run; higher is more recent. */ + seq: number + /** Carried over from a previous sweep, so the record may point at a record the app has deleted. */ + fromPriorRun?: boolean +} + +/** + * A testID shape that is known to carry a value for one param of one route — `profileCard-`…`-link` + * for `/profile/:name`'s `name`. + * + * Extraction strips the prefix and suffix rather than splitting on `-`, so a value that itself + * contains a dash (`alice-bob.bsky.social`) survives intact. + */ +export interface PressableTemplate { + prefix: string + suffix: string + nodeId: string + paramKey: string + /** + * `learned` — the app demonstrated this mapping: a tap on a control with this identifier landed on + * a URL that contained the token, so the binding is observed fact. + * + * `affinity` — nothing has been demonstrated yet and the identifier merely *reads* like it belongs + * to the route (`profileCard` ↔ `/profile/:name`). This is a bootstrap guess about which route a + * value belongs to; the value itself is still real. Worst case is one wasted open, and then the + * same exploration that would have happened anyway. + */ + evidence: 'learned' | 'affinity' + /** 1 for learned; the token-overlap fraction for affinity. */ + score: number +} + +/** + * A value that can stand in for a route param — the one rule every source here is held to. + * + * The static layer reads values out of *source text*, so what sits where a value should be is often + * the expression around it: a `navigate('ProfileList')` call site once yielded the deep link + * `/profile/navigate('ProfileList')/lists/navigate('ProfileList')`. The harvesting layers have the + * mirror-image problem — a testID segment or a navigation param can be anything the app felt like + * putting there. Anything that is not a plain URL segment is an artefact rather than a value, and + * this lives here rather than in `exploration.ts` so both layers are measured against one rule. + */ +export const PARAM_VALUE_RE = /^[\w.~@%-]{1,128}$/ + +/** + * Param values are persisted to disk and replayed across sweeps, so anything that looks like a + * credential, or that could poison an object when it is used as a key, must never enter the store. + * This is additive to `PARAM_VALUE_RE`, which constrains the *shape* of a value but says nothing + * about its key and lets a short opaque token or a dotted JWT-like string through. + */ +const SENSITIVE_KEY_RE = /token|password|secret|authorization|cookie/i +const PROTOTYPE_KEYS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']) +const MAX_PARAM_KEY_LENGTH = 64 +const MAX_PARAM_VALUE_LENGTH = 128 +const CREDENTIAL_VALUE_RE = /^(?:Bearer\s|eyJ[A-Za-z0-9_-]+\.)/ + +export type ParamRejectionReason = + 'sensitive-key' | 'prototype-key' | 'key-too-long' | 'value-too-long' | 'credential-shaped-value' + +/** Why one param may not be stored, or null when it may. Never includes the value itself. */ +export function unsafeParamReason(key: string, value: string): ParamRejectionReason | null { + if (PROTOTYPE_KEYS.has(key)) return 'prototype-key' + if (key.length > MAX_PARAM_KEY_LENGTH) return 'key-too-long' + if (SENSITIVE_KEY_RE.test(key)) return 'sensitive-key' + if (value.length > MAX_PARAM_VALUE_LENGTH) return 'value-too-long' + if (CREDENTIAL_VALUE_RE.test(value)) return 'credential-shaped-value' + return null +} + +/** One refused param set, for the sweep log. Carries the key and the reason, never the value. */ +export interface ParamRejection { + nodeId: string + key: string + reason: ParamRejectionReason + origin: ParamSource | 'disk' +} + +/** What this module needs to know about a route. Structural, so tests need no fixtures. */ +export interface ParamRouteNode { + id: string + title?: string + urlPath: string | null + params: readonly string[] +} + +/** What this module needs from a captured control. Matches `AppFlowPressable` structurally. */ +export interface ParamPressable { + identifier?: string + label?: string + text?: string + role?: string +} + +/** + * Whether two route patterns describe the same journey far enough in that one's observed values are + * literally the other's. + * + * A value is only ever observed, never invented — but an observation of `/profile/:name/post/:rkey` + * is an observation of a real `name` and a real `rkey` *in those positions*, and + * `/profile/:name/post/:rkey/liked-by` addresses the same post with the same two values. Holding + * records strictly per-route meant a sweep explored three descendants of a post whose ids it had + * just harvested, at around fifty seconds each, with the answer already in the store. + * + * The test is positional rather than by param name on purpose: two routes may both call a param + * `name` and mean different things, and nothing short of identical leading segments — literals and + * `:param` spellings alike — establishes that the shorter path is a prefix of the longer one's + * journey. Where they diverge, no value crosses. This is not splicing: a record still travels + * whole, from one observation, which is the rule that stops a right `name` carrying some other + * record's `rkey`. + */ +export function sharesRouteParams( + owner: string | null | undefined, + target: string | null +): boolean { + if (!owner || !target) return false + const ownerSegments = owner.split('/').filter(Boolean) + const targetSegments = target.split('/').filter(Boolean) + const shared = Math.min(ownerSegments.length, targetSegments.length) + if (!shared) return false + for (let at = 0; at < shared; at += 1) { + if (ownerSegments[at] !== targetSegments[at]) return false + } + return true +} + +/* ------------------------------------------------------------------ shapes */ + +/** + * The coarse family a value belongs to. Used only as a *negative* check, and only when the static + * layer already produced a literal for the same param: a handle-shaped route whose call sites say + * `alice.test` should not accept a bare `42` scraped off a list row. With no literal there is no + * baseline, so no check is applied — guessing a shape would be as inventive as guessing a value. + */ +export type ShapeClass = 'digits' | 'dotted' | 'token' | 'mixed' + +export function shapeClass(value: string): ShapeClass { + if (/^\d+$/.test(value)) return 'digits' + if (value.includes('.')) return 'dotted' + if (/^[a-z0-9]+$/.test(value)) return 'token' + return 'mixed' +} + +/* -------------------------------------------------------------- tokenizing */ + +/** + * `profileHeaderFollowersButton` / `profile-header-followers` / `ProfileFollowers` → the words a + * human reads in them. Identifiers are source text and carry no spaces, so without this an id's own + * words never line up with a route's. + */ +export function tokenizeIdentifier(text: string): string[] { + return text + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[^A-Za-z0-9]+/) + .map((word) => word.toLowerCase()) + .filter(Boolean) +} + +/** Words so generic that sharing them says nothing about which route a control belongs to. */ +const STOP_TOKENS = new Set([ + 'btn', + 'button', + 'link', + 'item', + 'card', + 'row', + 'cell', + 'view', + 'screen', + 'tab', + 'by', + 'to', + 'the', + 'a', + 'id' +]) + +/** The words a route is identified by: its literal path segments plus its component name. */ +export function nodeTokens(node: ParamRouteNode): Set { + const fromPath = (node.urlPath ?? '') + .split('/') + .filter((segment) => segment && !segment.startsWith(':') && !segment.startsWith('[')) + .flatMap((segment) => tokenizeIdentifier(segment)) + const fromTitle = tokenizeIdentifier(node.title ?? node.id.split('/').pop() ?? '') + return new Set([...fromPath, ...fromTitle].filter((token) => !STOP_TOKENS.has(token))) +} + +/** + * How much of a testID's wording is accounted for by a route's own wording, in [0, 1]. Scored + * against the prefix rather than the whole identifier so the value itself cannot contribute. + */ +export function affinityScore(prefix: string, node: ParamRouteNode): number { + const prefixTokens = tokenizeIdentifier(prefix).filter((token) => !STOP_TOKENS.has(token)) + if (!prefixTokens.length) return 0 + const routeTokens = nodeTokens(node) + const shared = prefixTokens.filter((token) => routeTokens.has(token)) + // A single short coincidence ("to", "id" are already dropped; "list" is not) is not evidence. + if (!shared.some((token) => token.length >= 4)) return 0 + return shared.length / prefixTokens.length +} + +/** Below this, the wording is a coincidence rather than a hint. */ +const AFFINITY_FLOOR = 0.5 + +/* --------------------------------------------------------------- templates */ + +/** + * The template a demonstrated mapping implies, found by locating the real value *inside* the + * identifier. Substring search rather than segment splitting, so values containing `-` or `.` need + * no special case and the recovered prefix/suffix are exactly what extraction will strip later. + */ +export function learnTemplate( + identifier: string, + value: string, + nodeId: string, + paramKey: string +): PressableTemplate | null { + if (!value || value.length < 2) return null + const at = identifier.indexOf(value) + if (at === -1) return null + // A value that is the entire identifier leaves nothing to key on and would match every control. + if (at === 0 && value.length === identifier.length) return null + return { + prefix: identifier.slice(0, at), + suffix: identifier.slice(at + value.length), + nodeId, + paramKey, + evidence: 'learned', + score: 1 + } +} + +/** + * Candidate value positions inside an identifier, for the bootstrap case where nothing has been + * demonstrated yet. A segment only counts as a possible value when it does not read like a word a + * developer typed: it carries a dot, a digit, an `@`, or is long enough to be an opaque key. That + * keeps `tab-home-link` from offering `home` as somebody's handle while still finding `alice.test`, + * `3kabc7xyz2p1q` and `did:plc:…`-style tokens. + */ +export function splitTestId( + identifier: string +): { prefix: string; token: string; suffix: string }[] { + const segments = identifier.split('-') + if (segments.length < 2) return [] + const out: { prefix: string; token: string; suffix: string }[] = [] + for (let at = 0; at < segments.length; at += 1) { + const token = segments[at] + if (!token || !PARAM_VALUE_RE.test(token) || !looksLikeValue(token)) continue + const prefix = segments.slice(0, at).join('-') + if (!prefix) continue + const suffix = segments.slice(at + 1).join('-') + out.push({ + prefix: `${prefix}-`, + token, + suffix: suffix ? `-${suffix}` : '' + }) + } + return out +} + +function looksLikeValue(token: string): boolean { + if (/[.@%~]/.test(token)) return true + if (/\d/.test(token) && !/^\d{1,2}$/.test(token)) return true + return token.length >= 13 +} + +/** The value `identifier` carries for `template`, or null when the shape does not apply. */ +export function applyTemplate(template: PressableTemplate, identifier: string): string | null { + if (!identifier.startsWith(template.prefix)) return null + if (template.suffix && !identifier.endsWith(template.suffix)) return null + const token = identifier.slice( + template.prefix.length, + template.suffix ? identifier.length - template.suffix.length : undefined + ) + if (!token || !PARAM_VALUE_RE.test(token)) return null + return token +} + +/* ------------------------------------------------------------- url parsing */ + +/** + * The route a real URL belongs to, and the whole param set it fills. + * + * One observed URL is the cleanest record obtainable: every param comes from the same place by + * construction, which is what makes nested routes like `/profile/:name/lists/:rkey` correct rather + * than lucky. Ranked by literal segment count so a concrete route wins over a more parameterised one + * that happens to have the same arity. + */ +export function matchFlowLink( + url: string, + nodes: readonly ParamRouteNode[] +): { nodeId: string; values: Record } | null { + const path = normalizePath(url) + if (path === null) return null + const actual = routeSegments(path) + let best: { nodeId: string; values: Record; literals: number } | null = null + for (const node of nodes) { + if (!node.urlPath || node.urlPath === '/') continue + const pattern = routeSegments(node.urlPath) + if (pattern.length !== actual.length) continue + // A catch-all swallows the rest of the path, so the value it would yield is a path rather than + // a segment and cannot be put back into a URL through `PARAM_VALUE_RE`. Nothing honest to do. + if (pattern.some((segment) => segment.startsWith('[...'))) continue + let literals = 0 + const values: Record = {} + let ok = true + for (let at = 0; at < pattern.length; at += 1) { + const key = patternParamKey(pattern[at]) + if (key === null) { + if (pattern[at].toLowerCase() !== actual[at].toLowerCase()) { + ok = false + break + } + literals += 1 + continue + } + const value = decodeSegment(actual[at]) + if (!value || !PARAM_VALUE_RE.test(value)) { + ok = false + break + } + values[key] = value + } + if (!ok) continue + if (!best || literals > best.literals) best = { nodeId: node.id, values, literals } + } + return best ? { nodeId: best.nodeId, values: best.values } : null +} + +/** The path part of a URL or a bare path, or null when there is nothing routable in it. */ +function normalizePath(url: string): string | null { + const scheme = url.match(/^([a-zA-Z][\w+.-]*):\/\//)?.[1]?.toLowerCase() + let path = (scheme ? url.slice(url.indexOf('://') + 3) : url).split(/[?#]/)[0] + // Only `http(s)` puts an authority where a path segment would otherwise sit. An app's own scheme + // has no host — `bluesky://profile/alice.test` means `/profile/alice.test` — so dropping a + // "hostname" there eats the first real segment and the route never matches. + if (scheme === 'http' || scheme === 'https') path = path.split('/').slice(1).join('/') + const trimmed = `/${path}`.replace(/\/{2,}/g, '/').replace(/\/+$/, '') + return trimmed || null +} + +/** + * The routable segments of a path or template. + * + * `index` and Expo Router group segments (`(tabs)`) are spelled in a route's *file* path but never + * appear in the URL the app navigates to, so a template carrying them would never match a real + * link on length alone. Dropped from both sides, as `codex-mobile-dev-plugin`'s `matchFlowLink` + * does, so `(tabs)/profile/[name]/index` and `/profile/alice.test` line up. + */ +function routeSegments(path: string): string[] { + return path + .split(/[?#]/)[0] + .split('/') + .filter((segment) => segment && segment !== 'index' && !/^\(.+\)$/.test(segment)) +} + +/** The param a template segment declares — `:name`, `:name?` and `[name]` alike. */ +function patternParamKey(segment: string): string | null { + if (segment.startsWith(':')) return segment.slice(1).replace(/\?$/, '') + if (segment.startsWith('[') && segment.endsWith(']')) { + return segment.slice(1, -1).replace(/^\.\.\./, '') + } + return null +} + +function decodeSegment(segment: string): string | null { + try { + return decodeURIComponent(segment) + } catch { + return segment + } +} + +/* ------------------------------------------------------------------- store */ + +/** How a nav-state read has turned out this sweep. Latched on first failure; see `nav-state.ts`. */ +export type NavStateStatus = 'unknown' | 'ready' | 'unavailable' + +export interface ParamStoreJson { + records: ParamRecord[] + templates: PressableTemplate[] + /** `${nodeId}\u0000${paramKey}` → the shape a static literal established for that param. */ + shapes: Record +} + +/** One accepted record, with the provenance the sweep log prints. */ +export interface ParamResolution { + values: Record + source: ParamSource + seenOn: string + /** + * The route whose record this is, which is not always the route being resolved — see + * `sharesRouteParams`. `demote` is keyed on it, so a value that turns out to name something the + * app no longer has is forgotten where it actually lives rather than left for the next + * descendant to borrow. + */ + ownedBy: string +} + +export class ParamStore { + /** + * Indexed by route and by (route, param) rather than held as flat lists. + * + * `ingestPressables` runs once per captured screen over every control on it against every + * parameterised route — on Bluesky that is ~45 x 22 per screen, 76 times a sweep — and the + * bootstrap layer can mint a template per (testID shape, route) pair. Flat arrays turn each of + * those into a scan, which is a lot of work to spend inside a change whose entire purpose is to + * make the sweep faster. The buckets are small and kept sorted on insert, so selection is a map + * lookup and a short walk. + */ + private recordsByNode = new Map() + private templatesByParam = new Map() + private shapes = new Map() + private seq = 0 + /** Every param set refused as unsafe, in order. The same entries are logged as they happen. */ + readonly rejections: ParamRejection[] = [] + /** Set by the orchestrator so one failed nav-state read is not retried all sweep. */ + navState: NavStateStatus = 'unknown' + + /* ---------------------------------------------------------- static layer */ + + /** + * Register what `resolveRouteParams` found in source text. A complete set becomes the + * highest-precedence record; every literal, complete or not, establishes the shape later + * harvested values for that param have to match. + */ + noteStatic(node: ParamRouteNode, resolved: Record): void { + // Static values never pass through `addObserved`, so the guard is applied here as well. One + // unsafe literal poisons the whole set, and no shape is learned from it either. + const unsafe = this.firstUnsafe(node, resolved, 'static') + if (unsafe) return + for (const [key, value] of Object.entries(resolved)) { + if (PARAM_VALUE_RE.test(value)) this.shapes.set(shapeKey(node.id, key), shapeClass(value)) + } + if (node.params.length && node.params.every((key) => resolved[key])) { + this.add({ + nodeId: node.id, + values: Object.fromEntries(node.params.map((key) => [key, resolved[key]])), + source: 'static', + seenOn: 'call-site', + seq: (this.seq += 1) + }) + } + } + + /* ------------------------------------------------------- url observation */ + + /** + * A URL the app actually navigated to. Classified as a navigation observation because that is what + * it is: the app produced the string, so every param in it is simultaneously real and + * co-occurring. + */ + ingestUrl(url: string, seenOn: string, nodes: readonly ParamRouteNode[]): ParamRecord | null { + const match = matchFlowLink(url, nodes) + if (!match) return null + const node = nodes.find((candidate) => candidate.id === match.nodeId) + if (!node) return null + return this.addObserved(node, match.values, 'nav-state', seenOn) + } + + /** + * One route out of a flattened navigation state. Non-string params are dropped rather than + * coerced past numbers: an object or array in a param slot cannot be put in a URL, and stringifying + * it produces `[object Object]`, which is an invented value. + */ + ingestNavRoute( + node: ParamRouteNode, + params: Record, + seenOn: string + ): ParamRecord | null { + const values: Record = {} + for (const key of node.params) { + const raw = params[key] + if (typeof raw === 'string') values[key] = raw + else if (typeof raw === 'number' && Number.isFinite(raw)) values[key] = String(raw) + else return null + } + return this.addObserved(node, values, 'nav-state', seenOn) + } + + /* ------------------------------------------------------- a11y harvesting */ + + /** + * Teach the store a testID shape from a navigation the harness just performed: the control it + * tapped, and the URL the app reported afterwards. This is the only evidence class that ties a + * value to a *route* rather than merely reading like it might belong to one, so it also + * back-fills a record for the node it just demonstrated. + */ + learnFromNavigation( + identifier: string | undefined, + landedUrl: string, + seenOn: string, + nodes: readonly ParamRouteNode[] + ): void { + const match = matchFlowLink(landedUrl, nodes) + if (!match) return + const node = nodes.find((candidate) => candidate.id === match.nodeId) + if (node) this.addObserved(node, match.values, 'nav-state', seenOn) + if (!identifier) return + for (const [key, value] of Object.entries(match.values)) { + const template = learnTemplate(identifier, value, match.nodeId, key) + if (template) this.addTemplate(template) + } + } + + /** + * A walked arrival: the harness tapped a control and the arrival judge confirmed the app landed on + * `node`. No URL is involved — an in-app tap produces none — so the value has to come out of the + * identifier itself. The binding is still *demonstrated* rather than guessed, because the app is + * what performed the navigation; this is the only way a walk pays for itself on later routes. + * + * Restricted to a single-param route whose identifier offers exactly one candidate position. Two + * candidates are two readings of the same control with nothing to choose between them, and a + * multi-param route would need every value out of this one identifier with no way to tell which + * is which. Both stay unresolved, and the next route explores exactly as it does today. + */ + learnFromArrival( + identifier: string | undefined, + node: ParamRouteNode, + seenOn: string + ): ParamRecord | null { + if (!identifier || node.params.length !== 1) return null + const candidates = splitTestId(identifier) + if (candidates.length !== 1) return null + const [candidate] = candidates + const paramKey = node.params[0] + this.addTemplate({ + prefix: candidate.prefix, + suffix: candidate.suffix, + nodeId: node.id, + paramKey, + evidence: 'learned', + score: 1 + }) + return this.addObserved(node, { [paramKey]: candidate.token }, 'a11y-learned', seenOn) + } + + /** + * Read every value the controls on a captured screen carry. + * + * A record is only built from **one** pressable: all of a node's params must come out of the same + * identifier, via one template each. That is what stops a `name` from one row pairing with an + * `rkey` from another. Nodes whose params cannot all be filled from a single control stay + * unresolved, and the caller explores — the intended outcome, not a failure. + */ + ingestPressables( + seenOn: string, + pressables: readonly ParamPressable[], + nodes: readonly ParamRouteNode[] + ): ParamRecord[] { + const wanted = nodes.filter((node) => node.params.length) + if (!wanted.length) return [] + this.proposeAffinityTemplates(pressables, wanted) + const added: ParamRecord[] = [] + for (const pressable of pressables) { + const identifier = pressable.identifier + if (!identifier) continue + for (const node of wanted) { + const values: Record = {} + let evidence: 'learned' | 'affinity' = 'learned' + for (const key of node.params) { + const hit = this.templatesFor(node.id, key) + .map((template) => ({ template, value: applyTemplate(template, identifier) })) + .find((candidate) => candidate.value !== null) + if (!hit?.value) break + values[key] = hit.value + if (hit.template.evidence === 'affinity') evidence = 'affinity' + } + if (Object.keys(values).length !== node.params.length) continue + const record = this.addObserved( + node, + values, + evidence === 'learned' ? 'a11y-learned' : 'a11y-affinity', + seenOn + ) + if (record) added.push(record) + } + } + return added + } + + /** + * Bootstrap templates from wording alone, for routes nothing has demonstrated yet. Only ever + * produces `affinity` evidence, and only for a (node, param) pair that has no learned template — + * once the app has shown us the real shape there is no reason to keep guessing at it. + */ + private proposeAffinityTemplates( + pressables: readonly ParamPressable[], + nodes: readonly ParamRouteNode[] + ): void { + // One identifier carrying one value can only speak for a single-param route; a multi-param + // route needs every value from the same control, which affinity scoring has no way to + // establish. And once the app has demonstrated the real shape for a param there is no reason + // to keep guessing at it. + const bootstrap = nodes.filter( + (node) => node.params.length === 1 && !this.hasLearnedTemplate(node.id, node.params[0]) + ) + if (!bootstrap.length) return + // A feed shows the same row shape forty times over. Scoring is per distinct shape, not per + // control, so a long list costs what a short one does. + const shapes = new Map() + for (const pressable of pressables) { + if (!pressable.identifier) continue + for (const candidate of splitTestId(pressable.identifier)) { + shapes.set(`${candidate.prefix}\u0000${candidate.suffix}`, candidate) + } + } + for (const shape of shapes.values()) { + for (const node of bootstrap) { + const score = affinityScore(shape.prefix, node) + if (score < AFFINITY_FLOOR) continue + this.addTemplate({ + prefix: shape.prefix, + suffix: shape.suffix, + nodeId: node.id, + paramKey: node.params[0], + evidence: 'affinity', + score + }) + } + } + } + + /** Candidate shapes for one param, demonstrated ones first. Kept sorted by `addTemplate`. */ + private templatesFor(nodeId: string, paramKey: string): readonly PressableTemplate[] { + return this.templatesByParam.get(shapeKey(nodeId, paramKey)) ?? [] + } + + private hasLearnedTemplate(nodeId: string, paramKey: string): boolean { + return this.templatesFor(nodeId, paramKey).some((template) => template.evidence === 'learned') + } + + /* ------------------------------------------------------------- selection */ + + /** + * The best complete param set for `node`, or null when nothing observed covers it. + * + * Precedence is applied to whole records and never merged: if the static layer filled `name` but + * not `rkey`, a harvested record supplying both wins outright rather than contributing its `rkey` + * to the static `name`. + */ + best(node: ParamRouteNode): ParamResolution | null { + if (!node.params.length) return null + const covering = (records: readonly ParamRecord[]): ParamRecord[] => + records + .filter((record) => node.params.every((key) => record.values[key] !== undefined)) + .sort(recordOrder) + // The node's own observations first, whatever their source: a record made for this route is a + // statement about this route, and one offered by a neighbour is an inference from it — sound, + // but second in line. Precedence inside each group is unchanged. + const own = covering(this.recordsByNode.get(node.id) ?? []) + const chosen = + own[0] ?? + covering( + this.allRecords.filter( + (record) => record.nodeId !== node.id && sharesRouteParams(record.urlPath, node.urlPath) + ) + )[0] + if (!chosen) return null + return { + values: Object.fromEntries(node.params.map((key) => [key, chosen.values[key]])), + source: chosen.source, + seenOn: chosen.seenOn, + ownedBy: chosen.nodeId + } + } + + /** + * Forget a record that turned out to point at something the app does not have — a handle deleted + * since the previous sweep, an affinity template that bound the wrong route. Called when a deep + * link built from harvested values is rejected on arrival, so the next attempt falls through to + * the next-best record and ultimately to exploration. + * + * `nodeId` is the route that *owns* the record (`ParamResolution.ownedBy`), which is not always + * the route that just failed on it: a record borrowed by a descendant has to be forgotten where + * it lives, or every other descendant goes on borrowing the same dead value. + */ + demote(nodeId: string, values: Record): void { + const stamp = valueStamp(values) + const bucket = this.recordsByNode.get(nodeId) + if (!bucket) return + this.recordsByNode.set( + nodeId, + bucket.filter((record) => valueStamp(record.values) !== stamp) + ) + } + + /* ------------------------------------------------------------ bookkeeping */ + + private addObserved( + node: ParamRouteNode, + values: Record, + source: ParamSource, + seenOn: string + ): ParamRecord | null { + if (!node.params.length) return null + // The whole record is refused, not just the offending param: a record with a hole in it would + // be "resolved" and then replayed incomplete. + if (this.firstUnsafe(node, values, source)) return null + const complete: Record = {} + for (const key of node.params) { + const value = values[key] + if (!value || !PARAM_VALUE_RE.test(value)) return null + const expected = this.shapes.get(shapeKey(node.id, key)) + if (expected && shapeClass(value) !== expected) return null + complete[key] = value + } + return this.add({ + nodeId: node.id, + urlPath: node.urlPath, + values: complete, + source, + seenOn, + seq: (this.seq += 1) + }) + } + + /** + * The first unsafe param of a set, recorded and logged (key and reason only), or null when all are + * safe. Checks the keys the route declares as well as any extra keys supplied. + */ + private firstUnsafe( + node: ParamRouteNode, + values: Record, + origin: ParamRejection['origin'] + ): ParamRejection | null { + const keys = new Set([...node.params, ...Object.keys(values)]) + for (const key of keys) { + const raw = Object.hasOwn(values, key) ? values[key] : undefined + const reason = unsafeParamReason(key, typeof raw === 'string' ? raw : '') + if (!reason) continue + return this.reject({ nodeId: node.id, key, reason, origin }) + } + return null + } + + private reject(rejection: ParamRejection): ParamRejection { + this.rejections.push(rejection) + console.warn( + `[app-flow] param-store refused record for ${rejection.nodeId}: param "${rejection.key.slice(0, MAX_PARAM_KEY_LENGTH)}" ${rejection.reason} (${rejection.origin})` + ) + return rejection + } + + private add(record: ParamRecord): ParamRecord { + const stamp = valueStamp(record.values) + const bucket = this.recordsByNode.get(record.nodeId) ?? [] + const existing = bucket.find((candidate) => valueStamp(candidate.values) === stamp) + if (existing) { + // Re-seeing the same values from a better source is a promotion, not a duplicate. + if (SOURCE_RANK[record.source] < SOURCE_RANK[existing.source]) existing.source = record.source + // A record read back from a sweep older than `urlPath` learns its pattern the first time the + // same values are seen again, rather than staying unusable by its own descendants forever. + if (!existing.urlPath && record.urlPath) existing.urlPath = record.urlPath + existing.seq = record.seq + existing.fromPriorRun = false + return existing + } + bucket.push(record) + this.recordsByNode.set(record.nodeId, bucket) + return record + } + + private addTemplate(template: PressableTemplate): void { + const key = shapeKey(template.nodeId, template.paramKey) + const bucket = this.templatesByParam.get(key) ?? [] + const existing = bucket.find( + (candidate) => candidate.prefix === template.prefix && candidate.suffix === template.suffix + ) + if (!existing) bucket.push(template) + else if (existing.evidence === 'affinity' && template.evidence === 'learned') { + existing.evidence = 'learned' + existing.score = 1 + } else if (template.score > existing.score) existing.score = template.score + bucket.sort(templateOrder) + this.templatesByParam.set(key, bucket) + } + + private get allRecords(): ParamRecord[] { + return [...this.recordsByNode.values()].flat() + } + + private get allTemplates(): PressableTemplate[] { + return [...this.templatesByParam.values()].flat() + } + + /** Provenance for the sweep log: how much was learned and from where. */ + summary(): { records: number; templates: number; learned: number } { + const templates = this.allTemplates + return { + records: this.allRecords.length, + templates: templates.length, + learned: templates.filter((template) => template.evidence === 'learned').length + } + } + + toJSON(): ParamStoreJson { + return { + records: this.allRecords.map((record) => ({ ...record })), + templates: this.allTemplates.map((template) => ({ ...template })), + shapes: Object.fromEntries(this.shapes) + } + } + + /** + * Rehydrate from a previous sweep. Carried records are marked `fromPriorRun` and sort below + * anything seen live, because the record they name may have been deleted in the meantime; a 404 on + * one demotes it through `demote` exactly as a bad live record would be. + * + * Hostile or merely stale input is dropped rather than trusted: this file is on disk between runs. + */ + static fromJSON(raw: unknown, { fromPriorRun = true } = {}): ParamStore { + const store = new ParamStore() + if (!raw || typeof raw !== 'object') return store + const data = raw as Partial + for (const record of Array.isArray(data.records) ? data.records : []) { + if (!isPlainRecord(record)) continue + // A file written before the guard existed may hold an unsafe record; it is dropped whole. + const unsafe = Object.entries(record.values).find(([key, value]) => + unsafeParamReason(key, value) + ) + if (unsafe) { + store.reject({ + nodeId: record.nodeId, + key: unsafe[0], + reason: unsafeParamReason(unsafe[0], unsafe[1])!, + origin: 'disk' + }) + continue + } + store.add({ ...record, fromPriorRun: fromPriorRun || record.fromPriorRun }) + store.seq = Math.max(store.seq, record.seq ?? 0) + } + for (const template of Array.isArray(data.templates) ? data.templates : []) { + if (isPlainTemplate(template)) store.addTemplate(template) + } + for (const [key, value] of Object.entries(data.shapes ?? {})) { + if (value === 'digits' || value === 'dotted' || value === 'token' || value === 'mixed') { + store.shapes.set(key, value) + } + } + return store + } +} + +function shapeKey(nodeId: string, paramKey: string): string { + return `${nodeId}\u0000${paramKey}` +} + +/** Identity of a param set, so the same values seen twice do not become two records. */ +function valueStamp(values: Record): string { + return Object.keys(values) + .sort() + .map((key) => `${key}=${values[key]}`) + .join('&') +} + +/** + * Seen-this-run first, then precedence, then most recent. + * + * Freshness outranks source, which looks backwards until you notice that only the *observed* + * sources can be carried over at all: `static` is re-derived from the project's source text on + * every run and so is never `fromPriorRun`. Among observations, a handle read off the screen a + * moment ago is far more likely to still exist than one a sweep from last week recorded, and the + * record that no longer exists is the one that costs a wasted open and a 404. Within a single run + * this comparison is inert and precedence decides everything, which is the common case. + */ +function recordOrder(left: ParamRecord, right: ParamRecord): number { + const byRun = Number(left.fromPriorRun ?? false) - Number(right.fromPriorRun ?? false) + if (byRun !== 0) return byRun + const bySource = SOURCE_RANK[left.source] - SOURCE_RANK[right.source] + if (bySource !== 0) return bySource + return right.seq - left.seq +} + +/** Demonstrated shapes before guessed ones, then the better-scoring guess. */ +function templateOrder(left: PressableTemplate, right: PressableTemplate): number { + if (left.evidence !== right.evidence) return left.evidence === 'learned' ? -1 : 1 + return right.score - left.score +} + +function isPlainRecord(value: unknown): value is ParamRecord { + if (!value || typeof value !== 'object') return false + const record = value as Partial + if (typeof record.nodeId !== 'string' || !record.nodeId) return false + if (!record.values || typeof record.values !== 'object') return false + if (record.urlPath !== undefined && record.urlPath !== null && typeof record.urlPath !== 'string') + return false + const entries = Object.entries(record.values) + if (!entries.length) return false + if ( + !entries.every(([key, item]) => key && typeof item === 'string' && PARAM_VALUE_RE.test(item)) + ) { + return false + } + return record.source !== undefined && record.source in SOURCE_RANK +} + +function isPlainTemplate(value: unknown): value is PressableTemplate { + if (!value || typeof value !== 'object') return false + const template = value as Partial + return ( + typeof template.prefix === 'string' && + typeof template.suffix === 'string' && + typeof template.nodeId === 'string' && + typeof template.paramKey === 'string' && + (template.evidence === 'learned' || template.evidence === 'affinity') && + typeof template.score === 'number' + ) +} diff --git a/src/app-flow/parser.ts b/src/app-flow/parser.ts new file mode 100644 index 00000000..fc84404f --- /dev/null +++ b/src/app-flow/parser.ts @@ -0,0 +1,1622 @@ +import { readdir, readFile, stat } from 'node:fs/promises' +import { dirname, extname, join, posix, relative, resolve } from 'node:path' +import ts from 'typescript' +import type { + AppFlowEdge, + AppFlowGuard, + AppFlowLayout, + AppFlowNode, + AppFlowTrigger +} from '../shared/app-flow.ts' + +// Re-exported so existing imports of `AppFlowLayout` from this module keep compiling — the type +// itself moved to `shared/app-flow.ts` because `AppFlowResult.navigation` persists it verbatim into +// `app-flow.json`, which the renderer also reads, and `parser.ts` is main-process-only. +export type { AppFlowLayout } + +function generateSlug(routeId: string): string { + return routeId + .replace(/[^a-zA-Z0-9]/g, '_') + .replace(/_+/g, '_') + .toLowerCase() +} + +/** + * How much to trust `scheme` for building a working deep link: + * - `verified-prefix`: the linking config's own `prefixes` array names this scheme, so React + * Navigation will actually route a URL opened with it. + * - `scheme-mismatch`: a `prefixes` array was found, but this scheme is not in it. iOS will still + * open the app (the scheme is registered in Info.plist independently of `prefixes`), but React + * Navigation ignores a URL whose prefix it does not recognise, so the app just sits on whatever + * it already had open — indistinguishable at runtime from landing on the wrong screen. + * - `assumed`: no `prefixes` array was found at all (or it held only universal `https://` links), + * so there is nothing to check `expo.scheme` against; it is used on faith, as before this fix. + */ +export type DeepLinkConfidence = 'verified-prefix' | 'scheme-mismatch' | 'assumed' + +export interface ParsedAppGraph { + mode: 'expo-router' | 'react-navigation' + appDir: string | null + routesFile: string | null + scheme: string | null + /** Confidence that `scheme` is one React Navigation's `linking.prefixes` will actually route. */ + deepLinkConfidence: DeepLinkConfidence + /** Raw `prefixes` entries read off the linking config, custom-scheme and universal alike. */ + linkingPrefixes: string[] + appName: string | null + bundleId: string | null + slug: string | null + nodes: AppFlowNode[] + edges: AppFlowEdge[] + /** + * The navigator tree. Without it nothing in the graph says the app opens on a tab bar, so an + * explorer has no idea where a journey starts and picks whichever screen an over-approximated + * edge chain happens to dead-end at. + */ + layouts: AppFlowLayout[] + /** Kind of the root navigator, e.g. `Tabs`. */ + rootNavigator: string | null + /** Layouts mounted directly on the root — the tabs a journey can start from. */ + entryLayouts: string[] + /** + * Node ids the app's entry chrome opens DIRECTLY — each entry layout's own declared + * `initialRouteName` (or, absent that, its first registered screen, matching React Navigation's + * own default). This is deliberately a different concept from `entryLayouts`/`group`: `group` is + * ATTRIBUTION (which navigator registers a screen — a helper called from five tabs belongs to + * all five), while this is REACHABILITY (which single screen in each of those tabs a person + * lands on without navigating anywhere first). Conflating the two previously meant that every + * screen sharing a `group` with an entry layout — 78 of 83 in social-app, most of them behind a + * shared registration helper — was treated as one tap from launch, which both fabricated a + * one-hop journey for screens nested many pushes deep and suppressed the "explore from the entry + * chrome" instruction the planner needs for a genuine orphan. + */ + entryScreenIds: string[] +} + +/** Navigator kind for a `create*Navigator` factory, including project-specific wrappers. */ +function classifyNavigatorFactory(factory: string): string { + if (/MaterialTopTab|TopTab/.test(factory)) return 'TopTabs' + if (/BottomTab|\bTabs?Navigator/.test(factory)) return 'Tabs' + if (/Drawer/.test(factory)) return 'Drawer' + return 'Stack' +} + +interface RawRouteEntry { + abs: string + noExt: string + base: string + dir: string +} + +interface LayoutEntry { + file: string + dir: string + navigator: string | null + src: string +} + +interface PathAlias { + prefix: string + target: string +} + +interface ScreenTitleHit { + name: string | null + href: string | null + title: string +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Both parsers build their edge list by scanning every screen's source for call sites, which is +// inherently noisy in two ways this cleans up once at the end rather than guarding every producer +// above: (1) a screen that calls `navigate()` on its own route name — observed from a NotFound +// fallback resolving `navigate('HomeTab')` back to the tab it is already inside — draws a self-loop +// that says nothing; (2) the same call site can be matched by more than one heuristic (e.g. a +// `navigate()` call inside an anchor's `onPress`, also caught by the `href` scan), producing an +// exact duplicate edge. Both are noise, never signal, so they are dropped after building rather +// than reasoned about while building. +function finalizeEdges(edges: AppFlowEdge[]): AppFlowEdge[] { + const seenKeys = new Set() + const out: AppFlowEdge[] = [] + for (const edge of edges) { + if (edge.from === edge.to) continue + const key = `${edge.from}\0${edge.to}\0${edge.kind ?? ''}` + if (seenKeys.has(key)) continue + seenKeys.add(key) + out.push(edge) + } + return out +} + +function routeMatcher(urlPath: string): RegExp { + const re = urlPath + .split('/') + .map((seg) => + seg.startsWith('[...') + ? '.*' + : seg.startsWith('[') || seg.startsWith(':') + ? '[^/]+' + : escapeRe(seg) + ) + .join('/') + return new RegExp('^' + re + '/?$') +} + +/** + * Components a screen renders *instead of itself* when a gate is closed. Anything outside this list + * is ordinary conditional content, not a whole-screen swap, so it must not read as a guard. + */ +const GUARD_FALLBACK_RE = /^(?:NotFound|Unavailable|Forbidden|NoAccess|AccessDenied)/ + +/** Reads a parenthesised span starting at `open`, honouring nesting. */ +function readBalancedParens(src: string, open: number): { text: string; end: number } | null { + let depth = 0 + for (let index = open; index < src.length; index++) { + const char = src[index] + if (char === '(') depth += 1 + else if (char === ')') { + depth -= 1 + if (depth === 0) return { text: src.slice(open + 1, index), end: index } + } + } + return null +} + +/** + * Finds a gate that replaces the whole screen with a not-found-style fallback. + * + * The route is registered either way, so a deep link to a gated screen resolves and then renders a + * 404 — indistinguishable at runtime from a URL that was simply wrong. Reading the gate off the + * component tells the sweep which of the two it is, and saves it exploring for an entry point that + * this build does not render. + */ +/** + * Inlines a condition that is just a local flag (`!isEnabled`) with the expression it was assigned, + * so the recorded reason names the gate itself rather than the variable holding it. + */ +function resolveGuardCondition(src: string, condition: string): string { + const bare = /^(!?)([A-Za-z_$][\w$]*)$/.exec(condition) + if (!bare) return condition + const assigned = new RegExp( + `\\b(?:const|let|var)\\s+${escapeRe(bare[2])}\\s*(?::[^=\n]*)?=\\s*([^\n;]+)` + ).exec(src)?.[1] + return assigned ? `${bare[1]}${assigned.trim().replace(/\s+/g, ' ')}` : condition +} + +export function extractGuard(src: string): AppFlowGuard | null { + for (const match of src.matchAll(/\bif\s*\(/g)) { + const open = match.index + match[0].length - 1 + const condition = readBalancedParens(src, open) + if (!condition) continue + // Only an immediate return counts: a fallback further down the block is reached by other paths + // too, so it says nothing about how this screen renders when the gate is closed. + const returned = /^\s*\{?\s*return\s*\(?\s*<\s*([A-Za-z_$][\w$.]*)/.exec( + src.slice(condition.end + 1, condition.end + 200) + ) + if (!returned || !GUARD_FALLBACK_RE.test(returned[1])) continue + const text = condition.text.replace(/\s+/g, ' ').trim() + if (text) return { condition: resolveGuardCondition(src, text), fallback: returned[1] } + } + return null +} + +async function walk(dir: string): Promise { + const out: string[] = [] + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const p = join(dir, entry.name) + if (entry.isDirectory()) { + out.push(...(await walk(p))) + } else { + out.push(p) + } + } + return out +} + +export interface AppConfigInfo { + scheme: string | null + name: string | null + slug: string | null + bundleId: string | null +} + +function firstString(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) return value.trim() + if (Array.isArray(value) && typeof value[0] === 'string' && value[0].trim()) { + return value[0].trim() + } + return null +} + +export async function readAppConfig(projectRoot: string): Promise { + const empty: AppConfigInfo = { scheme: null, name: null, slug: null, bundleId: null } + try { + const cfg = JSON.parse(await readFile(join(projectRoot, 'app.json'), 'utf-8')) as { + expo?: { + scheme?: string | string[] + name?: string + slug?: string + ios?: { bundleIdentifier?: string } + } + scheme?: string | string[] + name?: string + } + const expo = cfg.expo ?? {} + return { + scheme: firstString(expo.scheme ?? cfg.scheme), + name: firstString(expo.name ?? cfg.name), + slug: firstString(expo.slug), + bundleId: firstString(expo.ios?.bundleIdentifier) + } + } catch { + // fall through to app.config.* + } + for (const f of ['app.config.js', 'app.config.ts']) { + try { + const src = await readFile(join(projectRoot, f), 'utf-8') + return { + scheme: src.match(/\bscheme\s*:\s*["']([A-Za-z0-9._+-]+)["']/)?.[1] ?? null, + name: src.match(/\bname\s*:\s*["']([^"']+)["']/)?.[1] ?? null, + slug: src.match(/\bslug\s*:\s*["']([A-Za-z0-9._+-]+)["']/)?.[1] ?? null, + bundleId: src.match(/\bbundleIdentifier\s*:\s*["']([A-Za-z0-9.]+)["']/)?.[1] ?? null + } + } catch { + // ignore + } + } + return empty +} + +function toUrlPath(noExt: string): string { + const segments = noExt.split('/') + if (segments[segments.length - 1] === 'index') segments.pop() + const urlSegments = segments.filter((s) => !(s.startsWith('(') && s.endsWith(')'))) + return '/' + urlSegments.join('/') +} + +function extractParams(segments: string[]): string[] { + return segments + .filter((s) => s.startsWith('[') && s.endsWith(']')) + .map((s) => s.slice(1, -1).replace(/^\.\.\./, '')) +} + +function toSlug(noExt: string): string { + let s = noExt === 'index' ? 'index' : noExt.replace(/\/index$/, '') + s = s + .replace(/[()[\]]/g, '') + .replace(/\.\.\./g, '') + .replace(/\//g, '_') + return generateSlug(s || 'index') +} + +function fallbackTitle(noExt: string): string { + const last = posix.basename(noExt) + if (last === 'index') return 'index' + const cleaned = last + .replace(/^\[(?:\.\.\.)?/, '') + .replace(/\]$/, '') + .replace(/[-_]+/g, ' ') + return cleaned.replace(/\b[a-z]/g, (c) => c.toUpperCase()) || 'Screen' +} + +function isUpwardHref(raw: string): boolean { + return raw.split(/[?#]/)[0].split('/').includes('..') +} + +const SOURCE_EXTS = [ + '.ios.tsx', + '.native.tsx', + '.ios.ts', + '.native.ts', + '.tsx', + '.ts', + '.ios.jsx', + '.native.jsx', + '.jsx', + '.js' +] +const IMPORT_RE = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g + +async function loadPathAliases(projectRoot: string): Promise { + const defaults: PathAlias[] = [ + { prefix: '@/', target: 'src/' }, + { prefix: '@/', target: '' } + ] + try { + const configPath = ts.findConfigFile(projectRoot, ts.sys.fileExists, 'tsconfig.json') + if (!configPath) return defaults + const parsed = ts.getParsedCommandLineOfConfigFile( + configPath, + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: () => undefined + } + ) + const paths = parsed?.options.paths + if (!paths) return defaults + const baseUrl = parsed.options.baseUrl ?? projectRoot + const aliases = Object.entries(paths).map(([key, targets]) => ({ + prefix: key.replace(/\*$/, ''), + target: resolve(baseUrl, (targets[0] ?? '').replace(/\*$/, '')) + })) + aliases.sort((a, b) => b.prefix.length - a.prefix.length) + return aliases.length > 0 ? aliases : defaults + } catch { + return defaults + } +} + +async function resolveWithExt(base: string): Promise { + for (const ext of SOURCE_EXTS) { + try { + const candidate = base + ext + if ((await stat(candidate)).isFile()) return candidate + } catch { + // keep looking + } + } + for (const ext of SOURCE_EXTS) { + try { + const candidate = join(base, 'index' + ext) + if ((await stat(candidate)).isFile()) return candidate + } catch { + // keep looking + } + } + try { + if ((await stat(base)).isFile()) return base + } catch { + // not a file + } + return null +} + +async function resolveImport( + fromFile: string, + spec: string, + aliases: PathAlias[] +): Promise { + if (spec.startsWith('.')) { + return resolveWithExt(resolve(dirname(fromFile), spec)) + } + for (const alias of aliases) { + if (spec === alias.prefix.replace(/\/$/, '') || spec.startsWith(alias.prefix)) { + const rest = spec.startsWith(alias.prefix) ? spec.slice(alias.prefix.length) : '' + const resolved = await resolveWithExt(resolve(alias.target, rest)) + if (resolved) return resolved + } + } + return null +} + +async function loadLayoutSource(layoutFile: string, aliases: PathAlias[]): Promise { + const src = await readFile(layoutFile, 'utf-8') + const chunks = [src] + for (const match of src.matchAll(IMPORT_RE)) { + const resolved = await resolveImport(layoutFile, match[1], aliases) + if (!resolved) continue + chunks.push(await readFile(resolved, 'utf-8')) + } + return chunks.join('\n') +} + +function extractScreenTitles(src: string): ScreenTitleHit[] { + const hits: ScreenTitleHit[] = [] + + for (const match of src.matchAll( + /]*)>([\s\S]*?)<\/NativeTabs\.Trigger>/g + )) { + const name = match[1].match(/\bname\s*=\s*["']([^"']+)["']/)?.[1] ?? null + const href = match[1].match(/\bhref\s*=\s*["']([^"']+)["']/)?.[1] ?? null + const title = + match[2] + .match(/([^<]+)<\/NativeTabs\.Trigger\.Label>/)?.[1] + ?.trim() ?? + match[2].match(/