mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 16:17:51 +00:00
7d4aede5d359eb16dfcff2a64503b95d6cc56ef7
63
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ebeea319b2 |
Support modern Qoder commands and verify authenticated resume
Start and resume Qoder through the existing execution-host selector when only the documented qoder command exists. Preserve legacy qodercli preference, explicit commands, quoting and session identity; disconnected SSH execution refuses without local fallback. Mobile history uses command-at-create only with the optional owned-create capability and an existing stable mutation identity. Reconcile authoritative execution-host inventory before retrying creation, adopt the same surviving operation, preserve WSL/incarnation metadata and restore only missing original launch metadata. Changed retry settings cannot replace original capture. Bounded evidence expires after 15 minutes; unavailable inventory or original evidence refuses recovery and leaves surviving execution untouched. Authoritative inventory proving absence preserves the existing recreation behavior; this is not a durable exactly-once ledger for completed one-shot side effects. Older hosts retain the acknowledged create-then-send path. Scope mobile launch authority to the current committed host/client generation, and recheck operation ownership after asynchronous preparation before later sends. Retire the mutation once the host acknowledges the resume; later ownership changes stop navigation without reporting a completed resume as failed or reusing a cached legacy pane. Preserve genuinely interrupted mutation identity. Preserve current-main OpenCode validation and merged Pi/Cursor behavior. Correct unchanged-main editor test fixtures to their production insertion-range and store contracts while retaining original assertions and production behavior. Credit: Neil Parker (@nwparker); Soperf and jyang for Qoder integration/history groundwork in #24614; actual Pullfrog and CodeRabbit reviews and the independent Source reviewer for the mobile retry, launch capture, evidence lifetime and connection ownership findings. Conservative positional process recognition and existing folder-history matching-worktree limitations remain documented. |
||
|
|
e1b046a1bd |
Skip new legacy file inventories after mobile search cleanup (#24792)
Reuse the existing mobile mounted-ref pattern only at legacy fallback entry after completed passive cleanup; preserve admitted work and all live search/authority/cache paths. Correct only the strict fully-unmounted inventory scenario and its sole golden. |
||
|
|
3793c58abd |
fix(mobile): release notification and account streams from the transport (#23032)
* fix(mobile): release notification and account streams from the transport * test(mobile): drop the deleted notification-unsubscribe matrix from the bridged-parity tally * test(mobile): count the bridged-parity corpus at 786 goldens * chore(mobile): state why the notification stream frame cast is safe * test(mobile): re-record goldens after the transport took over notification release Repins the recording baseline to |
||
|
|
ca7c14db08 |
fix(mobile): start + menu, quick command and diff-note agents through agent.launch (#22954)
* fix(mobile): start + menu, quick command and diff-note agents through agent.launch
The session screen's + menu, agent quick commands and diff notes' New agent
session now ask the host to start the agent with agent.launchReplay, so the
host picks chat or terminal from the desktop's default and delivers any
prompt. Hosts without the launch capabilities keep today's paths.
The phone's pending tab choice is one value (a tab, a terminal by handle, or a
launched surface) instead of two refs, and a launched chat is found by its
session id in the next snapshot rather than a predicted tab id. A launched
surface waits a bounded number of snapshots for its tab.
* test(mobile): add the + menu and diff-note launch scenarios to the recording corpus
* test(mobile): repin bridged-parity tallies for the four launch goldens; drop test casts
The corpus grows from 790 to 794 goldens; all four new ones replay identically.
* fix(mobile): show a refused agent launch as a toast beside open tabs
The inline create error renders only in an empty session, so a host refusal
(for example a disabled agent) from the + menu in a session with tabs showed
nothing. Always toast the failure: the caller's own copy when it gave one,
otherwise the host's reason.
* test(mobile): type the launch reply helper with the shared launch outcome types
* fix(mobile): record a launched agent's tab as this device's pick on the host
A launch carries no navigation, so the phone selected the new tab only
locally while the host kept this device on the tab it had before. Leaving
the session and coming back, or a reconnect that reset the screen, reopened
that old tab. The "+" terminal path this replaced asked the host to select
the tab for the caller.
When a launched surface's tab lands in a snapshot, activate it for the
caller exactly as a tap does. The resolver now names the landed tab in
place of the unused `missed` flag. Route parity re-pinned for the new
activation body, identity payload and strings.
* fix(mobile): land on a launched agent's tab without a 500 ms wait or a blank pane
The host publishes a launched tab before it replies, so the tab list the
phone already holds usually has it by the time the reply arrives. The
launch paths still waited for a refetch 500 ms later, leaving the phone on
the old tab for that long after every launch. Read the tab list at once.
On hosts without agent.launch, the chat path also unsubscribed the open
terminal and cleared its handle before the chat's tab landed, while the old
terminal tab stayed selected: a blank pane until the next tab list. Leave the
open tab live until the chat lands, as the launch path does; applying that
tab list tears the old terminal down.
Route parity re-pinned for the two bodies.
* fix(mobile): keep a tab the user picked while a prompted launch was still replying
A quick command or review-notes launch now waits for the host to deliver the prompt, which can take up to a minute. The launched tab shows up in the tab row well before that, so a user who tapped another tab meanwhile was pulled back onto the launched one when the reply arrived, and that pick was recorded on the host.
The launch now remembers which tab the phone was on when it started and only takes focus if the phone is still there when the reply lands. Any move made in between, by a tap or by the computer navigating this phone, wins. Session route parity re-pinned for the handleCreateTerminal body only.
* fix(mobile): name a launched agent's tab before asking, and land on it when it is listed
A "+" menu, quick-command or review-notes launch now reserves its tab before it asks the host: a fresh pane key (tab and leaf UUIDs) and, for an agent the host may start as a chat, a session id. Both are minted once per launch and sent unchanged on every replay, since the host's replay fingerprint covers them.
The phone arms its pending selection with that reservation before sending, so it lands on the terminal (matched by pane halves) or chat (matched by session id) as soon as the tab is listed. For an agent whose prompt is pasted after start, that is long before the reply, which waits for delivery. Landing also frees the "+" lock; the lock holds the create's id, so an older launch's reply cannot free a newer one's. The reply now only adds its own handle or session id (an older host ignores the reservation), starts the fallback countdown, and reports prompt delivery.
A tab the user picks mid-launch replaces the pending selection, so the launch-start tab check is gone. A reservation the host refuses as already taken reads "Couldn't start the agent. Try again." on the first send, and as unconfirmed after a replay. The mobile UUID fallback now yields a v4 UUID, because a pane key's leaf must be one. The host launch path moved to new-tab-agent-host-launch.ts; session route parity re-pinned for that move and the landing's lock release.
* test(mobile): expect the launch reservation in the four launch scenarios
The four launch scenarios now expect the pane key and session id the phone sends (the scripted ids come first, so the operation id moves from ...001 to ...004).
* fix(mobile): don't say an agent may not have started while the user is looking at it
When a launch's reply was lost after its tab had already landed, the phone said "Couldn't confirm the agent started", although the listed tab proves it did. Now a listed tab narrows the doubt to the prompt or notes ("The agent started, but couldn't confirm the notes were sent."), the notes stay unsent, and a bare launch says nothing. Only the nested-function parity pin moves, for handleCreateTerminal passing the tab list to the launch.
* test(mobile): read the launch's sent reservation through the host's params schema
The anti-slop audit rejects Reflect.get; parsing with AgentLaunchReplay also
asserts the host accepts the params the phone sent.
* test(mobile): check the launch reservation against the host without importing its schema
Mobile code may import the params contract only as types. The phone's tests
now read the sent reservation by narrowing, a chat reservation is checked
through the real host dispatcher, and the older-host drop is pinned host-side.
* fix(mobile): don't send the same review notes to a second new agent
The "+" lock is now freed when the launched tab lands, but review notes are
only cleared when the launch's reply confirms delivery, which for a prompted
launch can take up to a minute. In that window "Send review notes to AI" still
offered the same notes, and choosing a new agent session started a second
agent with them.
The notes a new agent session is being started with are now held from the tap
until that launch settles: the Send button no longer counts them, the sheet no
longer offers them, and a stale tap on the old sheet starts nothing. Notes the
host did not deliver become sendable again once the reply arrives.
* test(mobile): record the + menu and diff-note launch goldens
4 added (+ as a terminal, + as a chat, notes delivered, notes not delivered). 10 existing create-terminal goldens move only because the recorded state now shows one pending selection instead of two refs; their requests are unchanged.
|
||
|
|
e594cb06af |
test(mobile): record RPC goldens without a pinned commit, and check recorded requests against the desktop's params rules (#23732)
* test(mobile): add rpc:diff to decode what a recording change moved
The RPC recording goldens are content-addressed JSON, so their raw git diff is
pool hashes. `pnpm --dir mobile rpc:diff [<base>]` decodes both sides and prints,
per golden, the checkpoint, field and JSON path that moved with both values,
grouped across checkpoints, plus added and removed goldens. `--summary <file>`
appends a Markdown report capped for GitHub's step-summary limit.
It reads any pooled format, so it can prove the next commit's format change
moves no recorded value. Checkpoints are matched by occurrence because an id can
repeat within one golden.
This commit adds files under the recorder directory, which moves the header
digest every golden pins; the next commit removes that header.
* test(mobile): record RPC goldens without a pinned commit or input digests
Every golden carried a pinned `baseline` commit plus digests of the recorder,
its mount adapter and its scenario, and the record script refused to run unless
the product tree matched the pin. So every behaviour change repinned to its own
branch commit and rewrote all ~790 files, the squash made that commit
unreachable, and main's pin job stayed red until a hand-made repin pull request
landed (22 of them in 12 days). The digests could only fail when an input moved
and the recording did not, which is exactly the change that carries no
information; every run already re-derives each golden from the current tree and
compares it.
Format 6 keeps the format version, operation, family, named deltas, the value
pool and the recording. Removed: the pin and fence, the three digest modules and
their test, the pin guard and its CI job, and the dead scenario `version` field
(the manifest reader now refuses `baseline` and `version` with a message).
- `pnpm --dir mobile rpc:record [<golden-id>...] [--prune]` records all or some
goldens; orphans are listed, and deleted only with `--prune`. Every derived
test title now starts with its golden id so an id selects it.
- `compareGolden` reports every difference in one failure (identity fields by
name, the checkpoint list, each checkpoint/field/path grouped), keeps the
final byte compare, and ends with the command to re-record that golden.
- `unhandled-recording.test.ts` now drives a detached rejection through
`runRecording` into a checkpoint and the cleanup checkpoint; no golden carries
one, and disconnecting the capture passed every suite before.
- Seam rules that existed only to keep a digest honest are gone; the
mutant-reachability, register-completeness and one-exposure rules stay.
- CI: `Mobile tests on main` runs the whole mobile suite on every merge that
touches mobile/, src/shared/, the root lockfile or the host RPC paths, since
`verify` never runs on main. A new `Mobile RPC Recording Replay` workflow
replays the recordings on pull requests that touch src/shared/ or the root
lockfile without touching mobile/. `verify` writes the `rpc:diff` report to
the job summary.
Proof: `rpc:diff` against the parent reports no recorded behaviour moved; each
golden only loses its ten header lines.
* test(mobile): check every recorded request against the host's params contract
The goldens script the host's replies, so a scenario could record a success
for a request the real host would refuse, and a desktop change that tightens a
params schema moved no golden at all.
`recorded-request-params.test.ts` parses every distinct request the corpus puts
on the wire with the host dispatcher's own `parseRpcRequestParams` and the
schema `rpc-params-catalog.generated.ts` binds to that method. It fails on a
method the host lacks, params it refuses, params sent to a method that takes
none (the dispatcher never reads them), and keys the schema silently strips
unless an inventory entry gives the reason; a stale entry fails too. Each rule
is also shown firing on a made-up request, since the corpus has no instance of
three of them. It imports the desktop dispatcher, so it sits beside the other
Node-side tests outside the RN test program, and the params-contract boundary
now exempts test files, which are never bundled.
It found twelve requests the host would refuse, all from invented fixture
values, not product code, fixed at their source:
- git.branchDiff sent `base-oid`/`head-oid`/`merge-base` where the host needs
full object ids (diff-review and source-control adapters, and the branch
compare replies in the manifest that feed them);
- an iOS push registration without `apnsEnvironment`, which a real iOS token
always carries (`push-token.ts`); the adapter now defaults to `production`;
- `settings.update` given Linear's `assigned` filter as a GitHub preset, which
the product type forbids; the scenario now picks `my-issues`;
- GitLab `projectRef` as a string where the host and the product type take
`{ host, path }` (7 methods, 5 adapters and the manifest).
46 goldens move, and a decoded comparison of every one of them shows no change
other than those substitutions; `rpc:diff` lists them.
* ci(mobile): detect a mobile change without a SIGPIPE-prone grep pipe
Under the runner's pipefail, grep -q exiting on its first match SIGPIPEs git
diff on a long file list, so a large pull request touching mobile/ read as
uncovered and replayed the recordings a second time.
* test(mobile): drop comments that still describe the golden header and digests
Eleven adapters justified an import rule by the header a golden no longer
carries, and that rule's test is gone. The census failure now names the
rpc:record and --prune commands.
* test(mobile): refuse a golden that keeps a key no recording writes
Decoding dropped unknown top-level keys, so an old header left behind by a
hand-resolved merge conflict passed every compare unseen.
* ci(mobile): summarize RPC recording changes after a failed test step too
* test(mobile): stream rpc:record output instead of capturing it
A captured run stayed silent for its whole duration and clipped its tail,
where the failure summary sits, past 8 MB.
* test(ci): let the Ruby-gate contract skip the always-run RPC summary step
|
||
|
|
fed1eca486 |
test: stop restating internal tuning constants, keep the ones that are contracts (#23950)
Removes ~74 assertions of the form `expect(SOME_CONSTANT).toBe(<literal>)` where the literal is an internal tuning value — a timeout, retry count, debounce interval, cache TTL, circuit-breaker window, Tailwind class string. Those cannot fail for any reason a user would notice: they fail only when someone deliberately changes the number, and then the test is simply updated. They are copies of the declaration. The same pattern is NOT junk when the exact value is observable outside this process, so those were deliberately kept: - terminal byte contracts: `\r`, `\x03` ETX, Kitty escapes, `\x1b[?1;2c`; - wire and capability values: `agent.launch.v2`, protocol 3 / min-compatible 2, daemon per-feature boundary versions (a daemon survives app updates, so those pin what an old field daemon may be trusted with), relay header tokens; - security invariants: the `127.0.0.1` bind default, an empty iframe `sandbox`; - values external processes read: exit code 78 (EX_CONFIG) and exit code 3 (systemd `RestartPreventExitStatus`), `ORCA_AGENT_SESSION_SPAWN_TOKEN`, `npx skills …` commands users paste, on-disk journal schema versions, the `orca_<hash>` filename prefix the fish sweeper matches; - third-party names: expo-router's `unstable_settings` / `ErrorBoundary`, iOS Safari's 16px zoom threshold. Where a case asserted a relation rather than a literal — `A < B`, a sum of parts, a cap compared against a sibling budget — the relation stays and only the literal went. Test-only changes: no production file is touched and no test file is deleted. |
||
|
|
2a001b43e5 |
fix(mobile): stop a closing phone stream from ending the terminal or chat feed that replaced it (#22939)
* fix(mobile): don't unsubscribe a terminal stream that already ended * fix(mobile): keep the stream registry under the line cap and expect no error after a streamed end The streamed `end` now closes a direct stream, so a later reply for that id is unrouted instead of reaching the listener as an error; the subscription recording test expected the old error. Inline the error wrapper so the registry fits the 300-line cap, and shorten the comments. * fix(mobile): reopen a native chat stream the host ended while the screen still shows it * fix(mobile): only the focused screen takes back a host-ended chat feed, and a reopen keeps loaded history * fix(mobile): give each native chat subscription its own token, and reopen with the first page * test(mobile): pin the reopened chat subscribe params without a cast * fix(mobile): show a host-ended chat feed as an error instead of reopening it With a token per subscription, the desktop ends a phone chat feed only when the phone closes it or the socket drops: no client sends the connection-wide chat unsubscribe, a socket close delivers no end, and every desktop with mobile chat keys feeds by the client's token. The reopen-with-backoff layer and the history-keeping reopen merge guarded a trigger that no longer exists, so they are removed. An end that still reaches the screen settles the feed as an error, so a dead feed is never shown as live; leaving and re-entering the chat subscribes again with a fresh token. * test(mobile): re-record the RPC goldens for the per-subscription chat token Each nativeChat.subscribe now sends its own token, so the native chat scenarios expect `claude:session-1:<id>` in the subscribe params, and the four native chat recordings (native-chat-page-earlier and the session.native-chat-page matrix) record that token in their subscribe and unsubscribe payloads. Every other golden moved only its `baseline` header, repinned to the commit recorded from. * test(mobile): repin RPC recordings to the merge with main and re-record Merging main moved the product tree the recordings are pinned to, so the baseline is repinned to the merge commit and the whole corpus re-recorded. Only the four native chat recordings change content, because each nativeChat.subscribe now carries a per-subscription token; every other recording moves only its baseline header. * test(mobile): repin RPC recordings to the merge with main and re-record Merging main brought #22762's recordings, #23757's repin and #23720's recorder change, so the corpus is repinned to the merge commit, the last commit to touch a recorded path, and re-recorded whole. Against main, 786 recordings move only their baseline header and 4 native chat recordings change content: their nativeChat.subscribe and nativeChat.unsubscribe carry the per-subscription token instead of claude:session-1, which also moves their scenarioSha256. |
||
|
|
6e1b7e7fa3 |
test: remove junk tests that assert source text instead of behavior (#23815)
Deletes 101 test files and trims 112 more, all matching documented junk patterns: exact source/import/string greps, copied inventories and export lists, duplicate invocations of a contract another test already owns, typeof-shape checks TypeScript already enforces, and self-comparisons. The largest group read a production `.ts` file and asserted on its text — for example a TaskPage test that required the source to contain `selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null`. Any behavior-preserving rename broke it; no behavior change ever did. Production-side follow-through: exports that only these tests imported are de-exported or deleted, stale comments pointing at removed censuses are dropped, and the reliability-gate registry, `cloud/package.json` test lists, and orphaned source-reading helpers are updated so nothing references a deleted file. Two files kept their real coverage and lost only the census scaffolding: `agent-status-producer-census.test.ts` now drives all five producers end to end instead of grepping the source tree, and `config-toml-trust-stale-writes` replaces an export-list parity check. |
||
|
|
c5fc0c6f26 |
fix(ci): keep a squash-merged RPC recording pin reachable through its pull request (#23720)
* fix(ci): keep a squash-merged RPC recording pin reachable through its pull request
Main's "RPC recording pin" check has been red since #22762: that branch pinned
the recording corpus to its own commit
|
||
|
|
29c7d5d983 |
fix(mobile): start AI-button agents through agent.launch, never a bare shell (#22762)
* fix(mobile): start AI-button agents through agent.launch, never a bare shell "Fix checks with AI", "Resolve conflicts with AI", commit-failure recovery and diff review's "New Agent Session" created a terminal with no agent and typed the multi-line prompt into the shell, so each line ran as a shell command. They now call agent.launchReplay into the existing workspace with the prompt; the host picks chat or terminal from the user's default and delivers the prompt. Hosts without the launch capabilities get the buttons disabled with update copy. The agent comes from the desktop's own resolution (moved to src/shared). The replay loop and capability read are shared with the workspace-create launch. * test(mobile): repin bridged-parity tallies for the AI-button launch goldens The corpus goes from 787 to 790 goldens: five shell-path goldens are removed and eight agent.launch ones added; one lands in identical and two in result-absent-settlement. * test(mobile): re-record goldens for AI-button launches through agent.launch Repinned baseline to |
||
|
|
2077956254 |
fix(mobile): size a terminal's first subscribe from the document's reported cell box (#23080)
* fix(mobile): size a terminal's first subscribe from the document's reported cell box #22960 sent phone dims on a terminal's first subscribe by opening a throwaway empty terminal (init 80x24 ""), awaiting its ready and measuring, behind a per-document first-subscribe mark whose lifetime was tied to web-ready. That cost a second xterm/WebGL instance and ~150 ms per open, plus lifecycle state. The document now measures the cell box without a terminal (xterm 6's CharSizeService strategy, rounded as the renderer rounds it) for every text-size preset and reports it with its viewport in web-ready; a table, because the text scale only reaches the document after that notify. Each init's ready reports the box xterm actually laid out, which replaces the probe's entry. The controller answers fitDimensions/measureFitDimensions from that table and the view's layout with no message; without a table it asks the document as before. The session seeds an unmeasured viewport synchronously in subscribeToTerminal, so the first subscribe carries dims by construction. Deleted: the empty init, its awaitReady gate, deferFirstSubscribeUntilViewportMeasured and the subscribedDocuments mark. The fit pass is unchanged and still covers a document that reports no cell box. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read the reported cell box through in-narrowing, not Reflect.get Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): correct the probe's cell-box guess from the box xterm lays out The web-ready probe is a guess: building the WebGL addon creates no context, so a context that fails on load lands on the DOM renderer, whose width is not snapped and depends on the column count. Before, a ready box that differed was only logged; the first subscribe had carried the wrong column count, the host echoed it, the fit pass saw the viewport equal to the host's dims, and the grid stayed slightly shrunk. The store also kept the WebGL width after a context loss. The document now reports the box xterm laid out whenever it changes (from onRender, which covers a renderer swap and a DPR change that onDimensionsChange does not fire for, and at ready). The store replaces the guess; when that changes the current text size's entry, the view calls onCellBoxChange with xterm's grid and the session re-fits, running the bounded fit pass if the dims moved (one resubscribe). Equal boxes do nothing. The RN layout box now survives a document reload; the document's own viewport only stands in until the view reports a layout (on the page, web-ready arrives first). The mismatch console.log is gone, and the probe's rounding names the xterm version it copies. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): correct each cell-box guess at most once, so a DOM renderer cannot loop On the DOM renderer the cell width is the rounded canvas width divided by the column count, so every re-init at new cols reported a new box. Each one counted as a correction, a floor over floats could flip the fit between two sizes, and each flip landed converged, which reset the resubscribe budget: an unbounded series of full-snapshot resubscribes. Only the first laid-out box for a guessed text size may be a correction; later reports still update the store, so fits stay truthful, but never resubscribe on their own. The fit's floor gains a 1e-6 epsilon so floating-point error at an exact boundary cannot flip a column or row. New document tests pin the render report after a renderer swap and the report at ready for a paused renderer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make xterm the only terminal cell measurer The document builds its real terminal before web-ready, at the app's text scale, and reports the box xterm laid out; the first init reuses that terminal. The page-side prediction, the per-scale guess table and the once-per-document correction are gone. The app remembers the box per text scale for its lifetime, so a later open at a known scale subscribes with phone dims at once. A box that changes at the same grid (renderer swap, pixel ratio) refits the open terminal in place. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep commands queued before the terminal WebView first loads A subscribe sized from the stored cell box can queue init before the native WebView reports its first load start, which cleared the queue and left the terminal blank. Only a reload now drops queued commands. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): re-init a document that lacks the subscription's init, and fit one frame width - Web-ready now says whether the document holds the terminal's latest init (a reload before the first ready drops a queued one); the session resubscribes any initialized terminal whose document lacks it. - One grid fit, shared by the app and the document, fed the unrounded frame width React Native laid out; it keeps exact fits whole at fractional pixel ratios. The document's viewport-width fits are gone. - The page builds every document at the scale the view mounted with, as the native WebView does. - A new document's first cell box is compared against the grid the subscribe fitted from the stored box. - The terminal built before ready stays hidden until its first init. - The cell-box census matches glyph-measurement techniques, not names; the store's unused clear() is gone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): build the terminal before ready only for the view shown at mount A session mounts one terminal view per tab, and each built xterm and a WebGL context before ready: 20 tabs made 20 contexts at load, past the ~16 a page (or Android's shared WebView renderer) holds, and native logged 32 context losses. Only the view shown when it mounts builds early now; the rest build at their first init as before. Deferring the WebGL addon instead would change the reported box: the DOM renderer lays out 7.8x15 where WebGL lays out 7.667x15 at the same font. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write a WebView document's start values into its page, not an injected script Android ran the pre-content injected script after the document's own in 1 of 22 documents on the emulator; that document started with no text scale or shown flag and built a terminal it should not have. The values now sit in the page ahead of the document script, one source object per start pair so a render never reloads the WebView. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the pre-ready terminal measures and reports while hidden Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): measure only the laid-out frame, and refit on a new grid, not a new width - A measure needs both of the frame's dimensions from React Native; the document's viewport-height fallback is gone, and before the first layout the handle answers no fit without asking the document. - A frame width change that still fits the PTY's grid from the stored box is a no-op, so sub-pixel layout jitter no longer re-measures. The width ref is written in that effect rather than during render (react-doctor). - One "last grid" ref: the last reported grid, or the one a subscribe fitted from the stored box. - The page render rig measures through the frame it laid out, as the session does, and lets the replay's fit settle before its resize-refit witness. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let only the current terminal document's ready flush A reload kept the WebView and its onMessage, so the old document's late web-ready flushed the queue into the reloading view and the new document got a second init. Each document now gets its own view (keyed on a generation the controller owns), every notify carries the generation of the view that received it, and a web-ready from a replaced document flushes nothing and stamps nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop every notify from a replaced terminal document One rule at the receive boundary: a notify from any generation but the current one is dropped, whatever its type, not only web-ready. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make fitDimensions a pure question; name each generation counter - fitDimensions no longer records the grid. A width change to a new grid asked it first, so the DOM renderer's report of that grid's box read as "same grid, new box" and refit again. Only the first-subscribe seed (seedFitDimensions) records the grid the document's first report is checked against. - viewGeneration counts the views, readyGeneration counts web-readies. - replaceDocument no longer resets the load flag; the load-start reset stays as the guard for a view that reloads itself. - The name-based lifecycle census is replaced by a behavioural test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): typecheck the handle mocks, drop the unused cell-box get - The two handle mocks carry both fitDimensions and seedFitDimensions, and the fake-timer acts return nothing, so the three test files check under tsconfig.test.json again. - terminalCellBoxes.get had no product caller; the store's tests assert through fit. - The load-start comment says what the controller does now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the grid the document has, ignore a replaced view's load start, dispose a failed pre-ready terminal - The document reports a new grid even with an unchanged box, so an in-place reflow on WebGL is held before a later renderer swap at that grid; the swap then refits. The app's apply paths do not hold the grid themselves: the DOM renderer's box follows cols, and a grid held on apply would read its own box as a renderer change and loop. One writer (holdGrid) holds the seeded or reported grid. - A load start from a view a replacement unmounted is ignored, as its notifies already are. - A terminal whose open throws before ready is disposed, not only unreferenced. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): ignore every native event from a replaced terminal view One wrapper binds each WebView lifecycle event (load start, error, HTTP error, render process gone, content process terminated) to the view's generation, so a replaced view's late event cannot reset, replace or put an error over the current document. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): a DOM seed refits once on its first report, not on the refit's own Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): subscribe a terminal only after its document is ready The document still builds its terminal before ready and reports the cell box xterm laid out in web-ready; the app now subscribes after that ready and fits from that box, so nothing is sent to a document before it is ready. Everything that made a pre-ready subscribe safe goes: the app-lifetime box store, the seed fit, the per-document view generations and their event filtering, the init tracker and the hasInit resubscribe. The native view reloads in place again and web-ready keeps main's reload rule. Boxes are kept per view; the grid a document last reported still guards the in-place refit against the DOM renderer's cols-dependent box. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): hold one reported cell box and the grid the subscribe fitted The controller keeps only the box the current document last reported, not a per-text-size store: the document re-reports on a scale change. The subscribe after ready fits from that box and holds the grid it fitted, so the DOM renderer's first report at that grid (a new box) refits once in place and converges; refit and apply paths hold nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fit only a ready box at the app's scale; forget a reloaded document's box and grid A reload keeps the document's mount scale, so a ready after a text-size change reports a box at the old scale; that box no longer sizes the first subscribe, which then takes the no-box path. A readiness reset drops the old document's box and held grid, so the new document's first DOM report at the same grid does not refit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the terminal document its frame at init, and fit text scale over it only A subscribe sized from the ready box sends no measure, so the document had no frame when the text size changed and reported the pre-refit row pitch. The app's init now carries the frame it laid out, in the fields a measure uses; the router takes it from either. The text-scale fit reads only that frame, with no viewport fallback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why a frameless text-scale change skips the resize Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): one cell box per terminal notify, not an array web-ready and cell-metrics carry `cellBox: {fontScale, cellWidth, cellHeight} | null`; the document's `laidOutCellBox` returns one or null and the parser validates one object. The text-scale match moves from web-ready into `handle.fitDimensions`, the one place a box is fitted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): fit terminals in the app from the reported box; drop the measure round trip The app already holds the box the document reported, so the refit and the fit pass await the init's ready and call `handle.fitDimensions` instead of posting `measure` and waiting on `measure-result`. The document's measure, its retries, and the measure promise and timeout go. The document still resizes locally on a text-size change, so every grid the app sends (init, resize, reflow) carries the laid-out frame it was fitted to. `holdSubscribedGrid` replaces `subscribeFitDimensions`, so the only fits are `fitDimensionsFromCell` and `handle.fitDimensions`. The render rig reads its fit from the ready box. The recorder adapter mounts the new handle with the same recorded effects; the goldens it mounts move on their adapterSha256 header only. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep the terminal frame in one ref, and notify a new width imperatively The session held the frame in a height ref, a width ref, a width state and the refit's own width ref. It now holds one `terminalFrameRef` ({width, height} | null until the first layout; a hidden 0x0 layout keeps the last box). onLayout notifies a new width imperatively, as it does height, and the refit's notify skips a width whose fit is the grid the PTY has. `terminal-frame-width-refit.ts`, the width state and its effect go. The subscribe's layout gate reads "no frame yet" directly. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): subscribe a held-back terminal on the frame's first layout only `handleTerminalFrameLayout` ran on every onLayout; it now runs once, when the frame first has a size. Later layouts only notify a new width. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): size the first subscribe inline in subscribeToTerminal `sizeTerminalViewportFromCellBox` wrapped five lines in a 37-line module; the subscribe now fits the ready box against the frame, holds that grid and records the diagnostic itself. The helper's tests fold into the subscription tests, which move to the subscription's name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the unreachable font-size guard on the reported cell box xterm 6.1.0-beta.303 updates the render service's cell box in the same task that sets `options.fontSize`: CharSizeService.measure fires onCharSizeChange, and RenderService.handleCharSizeChanged runs the renderer's `_updateDimensions` (DomRenderer.ts:359, WebglRenderer.ts:229). `term.onRender` fires from RenderService._renderRows after the rows are drawn (RenderService.ts:213, CoreBrowserTerminal.ts:538), and the document writes its text scale and the font size in one task (text-scaling.ts applyTextScale, terminal-init.ts init). So no report can read a box between the font and the scale; the guard and its test go. A new test pins the real order: no report when the font is set, the new box at the new scale on the next render. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): one start seam, no source cache, the reported box as an object - `useState` already pins each view's WebView source at mount (a new test re-renders at another text scale and gets the same object), so the module-level `webViewSources` Map goes. - `initialTextScale` and `buildsTerminalBeforeReady` become one `start(): { textScale, shown }` seam. - `reportedCellBox` holds the last reported box and grid, not a string key. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the RPC recordings to this branch and re-record The terminal refit now fits in the app from the reported box and reads one frame ref, so the recorder's terminal adapter mounts the new handle (`awaitReady` + `fitDimensions`) and options (`terminalFrameRef`), keeping its recorded effects. `baseline` is repinned to |
||
|
|
78771646af |
fix(mobile): show one review sheet at a time so the review screen never freezes (#22951)
* fix(mobile): close the review sheet before opening Send Notes On iPhone, Review Actions > Send Unsent Notes and Review Complete > Send Notes opened the Send Notes sheet while their own sheet was still on screen. iOS cannot present a second native sheet until the first has unmounted, so Send Notes never appeared and every later tap on the review screen was swallowed until the app restarted. Send Unsent Notes now uses the action sheet's closeBeforePress, and the Review Complete drawer opens Send Notes from its onAfterClose, the same sequencing the action sheet already uses. * test(mobile): drive Send Unsent Notes through the real action sheet The overflow test only checked the closeBeforePress flag, and nothing tested that the action sheet actually defers such an action until it has closed. Press the real row and assert Send Notes opens only from the sheet's after-close callback. * fix(mobile): ignore a close request on a drawer that is already hiding Android Back during a drawer's close animation restarted the hide, which cancelled it, so the drawer never unmounted and its invisible Modal kept swallowing every tap. Send Notes, which now opens after the review sheet closes, never appeared either. * refactor(mobile): give the review screen one sheet state so sheets cannot stack The review screen kept five independent sheet flags that any caller could set at any time. iOS cannot present a sheet while another is still on screen (even mid-close), so any overlap froze every tap. Two openers could still produce one: Review Complete appearing after the mark-reviewed save landed on a sheet opened meanwhile, and the Send Notes list reopening a sheet the user had already dismissed. The five flags become one reducer that mounts at most one sheet. Switching sheets closes the current one and shows the next only after its drawer reports it has finished closing; Review Complete waits for the user's sheet instead of closing it; a late send list only fills a Send Notes that is still shown or queued. The two per-call-site sequencers this branch added are removed in favour of it. * test(mobile): repin the RPC goldens for the review sheet state The review-actions recording adapter now drives the screen's sheet reducer instead of the removed Send Notes setter, which moves adapterSha256 on the 14 goldens recorded through it. baseline is repinned to the refactor commit so the recorder's product fence matches; no recorded body changed (only the baseline and adapterSha256 header fields move). * fix(mobile): settle a review sheet that closed before it was ever shown A drawer mounts only once a commit shows it, so a sheet closed or displaced in the same batch it opened in (e.g. Review Complete landing in the same frame as a tap that opens another sheet) never sends onAfterClose. The sheet state then waited on it forever and every later sheet on the screen stayed queued. Track which sheet's drawer a commit actually showed and settle a closing sheet that never reached the screen instead of waiting for a close it cannot send. * fix(mobile): keep a queued Send Notes when a late Review Complete lands A second Mark Reviewed save resolving while Review Complete was closing toward Send Notes reopened Review Complete and dropped the Send Notes the user had just asked for. A background opener now yields to any queued user sheet, including behind a closing sheet of its own kind. * refactor(mobile): present review sheets through one keyed drawer iOS cannot present a native Modal while another is still presented, even during its close animation. The review screen now renders all five sheets through one KeyedBottomDrawer that alone decides what is presented: a request for a different sheet hides the current one, and the next is mounted only after its hide finished and a commit without any Modal has landed. A request replaced before it was shown is never mounted. The screen's sheet state now records only what the user asked for (`requested`, plus a background Review Complete in `deferred`), so the presented-sheet bookkeeping, the per-drawer close callbacks and the screen-side mounted-sheet inference are gone. BottomDrawer becomes a constant-key adapter over the same drawer, so the app has one mount/close lifecycle. A hide that finished just before a reopen is now ignored instead of latching, which used to swallow the next close and leave an invisible Modal eating taps. * test(mobile): repin the RPC goldens for the keyed review drawer The review action adapter now drives the requested-sheet state, which moves that family's adapterSha256 on its 14 goldens; the repin to the keyed-drawer commit moves `baseline` on all 787. No golden body moved. |
||
|
|
9f5a8a5b8a |
Reuse mobile recording compilation and refresh desktop CI timings (#23343)
* ci: reuse recording compilation, split families, and refresh shard timings * Keep recording suite intact after hosted performance comparison --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
02a5594434 |
fix(mobile): give each host field one writer so relay routing can't revert edits (#22956)
* fix(mobile): give each host field one writer so relay routing can't revert edits Relay learners (director re-resolution, credential rotation, direct-to-relay upgrade) saved the whole HostProfile snapshot their connection opened with, so a late relay write reverted an Edit Host endpoint and rewrote the device token. The relay overlay also stored a copy of the paired address, which the direct probe kept dialling after an edit. Pairing is now the only full-profile writer (savePairedHost, fenced by a census). Learners call setRelayRouting(hostId, relay), which never touches the row or keychain, refuses a removed host, and skips unchanged routing. The overlay is relay-only in memory; one serializer writes the v2 shape older builds parse, without the direct entry. Saving a new endpoint rebuilds even a Relay-active client from the saved row. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai> * test(mobile): re-record RPC goldens for relay-only host routing Baseline moves to the product commit. adapterSha256 moves for the pairing and relay-credential adapters, which now read relay.relayHostId and inject saveRelayRouting. Only the four direct-upgrade bodies change: the settled host no longer carries the overlay's endpoints copy (direct-primary, relay-primary) or the duplicate relayHostId; relay and every effect are unchanged. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai> * refactor(mobile): relay learners own only relay routing Follow-up to the field-ownership split. The supervisor keeps its host read-only and holds the relay in a small owner that persists moves through setRelayRouting; the direct upgrade returns { relay, bundle } and the lifecycle composes the profile once. With one direct endpoint left, the probe takes a bound openDirect and the lifecycle computes the direct path once. One name per writer: setRelayRouting and savePairedHost are also the dependency keys, and the removed-host error is RelayRoutingHostRemovedError. The census now counts real value imports of savePairedHost, not mentions. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai> * test(mobile): re-record RPC goldens for the { relay, bundle } upgrade result Baseline moves to the refactor commit, and adapterSha256 moves for the pairing and relay-credential adapters (dependency keys renamed to savePairedHost and setRelayRouting). Only the four direct-upgrade bodies change: the settled upgrade result is now { relay, bundle } instead of { host, bundle }, with identical relay, bundle, state and effects. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai> * refactor(mobile): refresh edited hosts and let the establisher own relay Edit Host now reuses refreshHostClient, which already rebuilds an owned client (relay-active included) from the saved row after re-pairing, instead of a savedAddressChanged flag on forceReconnect. The session establisher, the only relay dialer, holds the relay and adopts learned routing through setRelayRouting; the supervisor takes a host id and a required relay, so the no-relay guards and the synthetic upgraded profile go. enqueueHostListMutation returns its operation's value, and the overlay store names its API after routing. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai> --------- Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai> |
||
|
|
a8786c040d |
fix(mobile): the page never removes a host, and stops bundling push (#22283)
* fix(mobile): the page never removes a host The page holds one host profile from `init.host` and no credential, so `removeHost` on web resolved without doing anything and the screen reported success for a host that was still paired. Its `.web` sibling refuses with a typed error instead, and the auth-failed banner's Remove — the one surface that opens the confirm — is absent on the page, because a control that can only refuse should not be there. Refusing is also the fence that keeps `push-registration.ts` out of the page bundle: the native lifecycle file's import was that subsystem's only path into a page route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the push families the page closure no longer reaches `host-removal-lifecycle.web.ts` was `src/notifications`'s only path into a page route, so the whole directory left the page bundle and the capability probe left the C1 layout closure with it. The derived family set shrank by two; the pin tables and their counts now match what the closure reaches. The expo-notifications fence grows a second claim and loses a precondition that had become false: `push-token.web.ts` and `desktop-notification-channel.web.ts` are no longer in the bundle either, so the fence is stated as the absence of the directory. C1 20 families / 94 goldens, C2 68 / 257, C3 26 / 116, C5 25 / 125. Session route closure 4211 -> 4207 modules. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page's auth-failed banner offers no control it can honour The banner is reachable on the page — the shell forwards the native client's state verbatim (`bridge-host.ts:381`) and `auth-failed` is in the wire enum (`bridge/bridge-envelope.ts:43`) — and the page can honour none of its three actions. `forceReconnect` is `() => Promise.resolve()` there (`client-context.web.tsx:55`, read through `host-client-hooks.ts:87`), `/pair-scan` sits outside the page's route root of `app/h` (`mobile-web-app-route-manifest.mjs:6`), and removal refuses. The previous commit hid only Remove and claimed the other two still worked; they do not. The whole action row moves into `AuthFailedBannerActions`, whose `.web` sibling renders no control and one line naming the app. The sentence above it is unchanged: re-pairing from the desktop is still what to do. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
86b93e02a7 |
feat(mobile): the microphone owns the wake lock, and the stop reply carries the tail (OTA phase C, ruling 36) (#22072)
* feat(mobile): give the microphone its own screen lock (OTA phase C, ruling 36) An open microphone holds the screen; a closed one gives it back. The lock lives in the device-side capture on both hosts — the shell's `native.audio.start|stop` handler and the native seam — so the page never decides anything about the screen. One tag per capture, minted by the module that owns the mic. Both captures give it back on every close path: a stop, a page session ending with the capture open, a device that would not begin, and an engine that throws after the capture is open, which now ends the capture rather than leaving it live. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): carry the capture's tail on the stop reply (OTA phase C, ruling 36) `native.audio.stop` drains what the ring still holds into its own reply, so the page's `end()` is one verb: stop, hand the bytes on, done. The drain, await and read-once-more ordering goes with it, and so do `ending`, `reading` and `released` — three variables that existed only to order a last read against the stop and to stop a refused read re-entering `end`. The tail fields default rather than being required: the page updates over the air and the shell does not, so a page this new can meet a shell that answers `stopped` alone. That dictation loses its tail where a required field would have lost it the stop. The heap case from PR D's bot round cannot recur: `end` issues no read, and a stop reply carries no interruption, so the lane that re-entered is gone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the dictation finish id, which ordered nothing `finishingIdRef` tracked the dictation a stop was finishing, and every state it could name was already named: `cancel`, a disable, an unmount and a newer start each bump the generation or clear the active id, so the finish guard answered the same either way. Its one distinguishing arm released pending audio bytes for a dictation whose budget `closeDictationAudio` had just reset, and could subtract those bytes from a newer dictation's reserve. `acceptingChunksRef` stays: it is what stops a late microphone event being sent after the capture handed over its tail and before the finish goes out. `pendingChunksRef` stays: `stop` awaits it so the finish cannot overtake the last chunk send. The finish guard is pinned by a case that cancels while the finish is in flight; neutered, it reds. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): delete the page's wake-lock seam (OTA phase C, ruling 36) The page never names the screen now. `native.wakelock.set` is gone with its schemas, its shell server, its grant rows and its harness entry; so are the page's keep-awake owner, the Android foreground re-acquire, and the `DictationKeepAwakeDevice` the capture contract carried. One module holds the screen — the device calls the microphone's capture makes — and both device-side captures share its one tag, because there is one microphone. Deleted: native-wakelock.ts (120), native-wakelock.test.ts (140), mobile-dictation-keep-awake.ts (248), mobile-dictation-keep-awake.test.ts (440), mobile-dictation-foreground-keep-awake.ts (78). With the tag pools gone, the desktop-start flow has one stale check instead of two, no startup budget to wait out and nothing to release. A source-scanning census pins it: no module under mobile/src or mobile/app but the one owner imports expo-keep-awake, and nothing anywhere names the retired verb. It reports the file and line, and checks the owner does import the package so the absence is the rule holding and not the match missing. KNOWN RED, reported and not recorded over: 25 golden cases in the speech.* families fail. The recorder adapter had to drop its keep-awake owner, which moves `adapterSha256` for every golden that mounts it, and the deleted owner's id minting shifts the deterministic random sequence, so the recorded `dictationId` values move too. No speech.dictation.* param, reply or operation changed. Awaiting the lead's call on a scoped re-record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the corpus after the wake-lock deletion Baseline bumped to |
||
|
|
d9954000b3 |
refactor(mobile): the rich editor's document becomes scope-threaded modules and a bundled factory (OTA phase C, C7.10 C1) (#21969)
* refactor(mobile): split the rich editor document's stylesheet and markup apart The body constant carried the tail of a `:root` block, every CSS rule and the editable surface's markup in one string, which only the HTML builder could splice. A page mounting the document needs the stylesheet and the markup separately, so they become a function over the theme and a constant. Byte-for-byte inert: `mobile-rich-markdown-editor-document.test.ts`'s digest of the shipped document is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the keyboard-inset normaliser its own module It is the host's half of the inset, read by the controller, and it sat in the module holding the document's in-page script. The script is about to become ordinary TypeScript under `rich-markdown/`, where a native-side normaliser does not belong. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the rich editor's document becomes scope-threaded modules and a factory The editor's ~600-line program lived in seven string constants a concatenator glued into one `<script>`: unreadable, untypeable, and unreachable from a page, which is where the OTA shell has to run it (ruling 26). It is now ordinary TypeScript under `src/components/rich-markdown/`. Every function that touches editor state takes `scope: RichMarkdownEditorScope` first, `createRichMarkdownEditorDocument(host)` builds the scope, runs the start sequence and returns `{ send, stop }`, and the six window reads the script did are host seams with those reads as their defaults: `postToHost`, `promptForUrl`, `keyboardInsetSource`, `clearTimer`, `getSelection`, `getDocument`. `runCommand` is async because a host that answers the URL prompt with a modal cannot answer synchronously; the thirteen commands that never wait stay one synchronous act. No module holds a `let` and none does work at parse time (rulings 20, 21), so a second mount starts from its own state and `stop` takes back both the surface's four listeners and the viewport's two. The native document is an esbuild IIFE bundle of `native-document-entry.ts`, written beside the terminal document's artifact by a fifth postinstall generator. Nothing ships it yet: the HTML builder still splices the old strings, which the next commit changes. Red-first: `rich-markdown-document-parse-time.test.ts` and `rich-markdown-host-seams.test.ts`. Their readers are the terminal census's, extracted to `src/test-support/webview-document-census.ts` and pointed at both documents rather than copied. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): ship the bundled document and retire the editor's script strings `buildMobileRichMarkdownEditorHtml` splices the esbuild bundle of `src/components/rich-markdown/`, and the seven string constants and their concatenator go. `escapeInjectedJavaScriptString` stays: it is the escape for `injectJavaScript`, which is still how the native host reaches the document. Equivalence, since a byte golden over the script cannot survive a bundler: - `rich-markdown/native-document-bundle.test.ts` evaluates the shipped artifact exactly as the WebView does — its markup, its bridge, its `execCommand`, its `prompt`, its `visualViewport` — and drives it through the injected handle: `keyboardInset` then `ready`, all five members, a markdown round trip through the real escape, an edit under the host's generation, every toolbar command's engine verb, the `javascript:` refusal, a tapped link, and the module list. - `mobile-rich-markdown-editor-document.test.ts` keeps a byte pin, now over the page around the document. Measured on main's own document with its script region removed and on this one: 5,621 bytes, both `5054e1d5c87e4ce1805d4856ddc8bf36804e697675e6013d84da453d3e81af25`. The whole-document digest it replaces was `1ef29c88…`, 29,852 bytes. Every assertion `mobile-rich-markdown-editor-html.test.ts` made by extracting functions out of the emitted text is kept, aimed at the modules: - nested/ordered/task list rendering and serialization, entities, explicit numbering, the parent-start fallback, read-only checkboxes → `markdown-round-trip.test.ts`, over real elements rather than shaped objects. - the emitChange/setEditable guards and the generation carried through a replacement → `editor-content.test.ts`, behaviourally. - dismissKeyboard, the tapped caret, the label tap, the restored caret, the end-of-document fallback, the detached caret → `editor-selection.test.ts`, with a blur that drops the ranges the way WebKit does. - parseable script and the injection escape stay in the HTML test. New with the factory: `document-lifecycle.test.ts` — stop takes the four surface listeners and the viewport observer off, a second mount is its own document, two documents do not share `editable`, and a start that throws unwinds. `use-mobile-rich-markdown-editor-controller`, `MobileRichMarkdownEditor` and the web fallback tests are untouched and green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the document's mutable bindings from the tree, not the line start The census matched `/^(let|var) /gm`, so `export let`, a declaration indented inside a top-level block and a `for (let …)` head were all invisible — three shapes of the one binding two documents would share — and its single precondition proved only the shape it could already see. `moduleLevelMutableBindings` walks the program instead and stops at every function body, because a binding one call owns is not module state. Its preconditions are one per shape, with the kind each reports, and a negative case over a `const` and a function-local `let`/`var` so the empty list is a measurement rather than a reader that refuses everything. Red-first: `export let pendingReport = 0` planted in `keyboard-inset.ts` reds it with `keyboard-inset: let pendingReport`, which the old matcher passed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin both WebView document bundles to the mobile root esbuild writes each module's path into a bundle as a comment relative to the working directory, and neither generator set `absWorkingDir`. So the artifact's bytes followed the cwd of whatever postinstall run wrote it: measured from the repo root, `mobile/`, and `mobile/src`, three digests — and from outside the repo the comments carried `/Users/<name>/…`, a machine path in the one file every bundle test compares against a build it makes itself. Both generators now pin the mobile root, so the four cwds measured agree, and both bundle tests carry the pin: a digest built in a child process from the OS temp directory equals the committed artifact's, and no comment in either artifact is an absolute path or climbs out with `../`. `build-terminal-document-script.mjs` had the defect verbatim on main; C1 copied its shape, so both are fixed here rather than leaving the original to be found again. Neither artifact's bytes move: both were generated from `mobile/`, which is what `absWorkingDir` now names. Red-first: deleting the `absWorkingDir` line from either generator reds that generator's case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover the getSelection seam's override, not just its default Five of the six seams had both halves and this one had only its window default, which is the half that cannot fail on the page: there the caret has to come from the object the host hands over, because a document mounted inside a screen shares `window` with every other field on it. The case gives the document a selection of its own, blurs the surface the way WebKit does — dropping the ranges, which is the whole reason a caret is saved — and reads the restored caret back out of the host's object. The window's own selection stays empty throughout, which is what says the default was never consulted. Red-first: `rememberSelection` reading `window.getSelection()` instead of the field reds it; every other case in the file stays green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the editor document's stop cancel its pending timer `stop` took the surface's four listeners and the viewport's observer off and left the input timer, while the scope kept the handle and the `clearTimer` seam kept the means to cancel it. A listener comes off with the element it was on; a scheduled callback holds the scope and fires into a document the host has already unmounted, posting a change under the generation of content it has replaced. `stopEditorContent` cancels it through the seam and clears the field, and the sequence runs it last — after the listeners that could have scheduled another one are gone. Nothing schedules the handle today. The cancel is here because the seam and the field exist for the day something does, and that is not the moment to discover `stop` never reached it. The case plants the pending change rather than waiting for a debounce, and carries its own control: the same timer posts while the document is running, and posts nothing once it is stopped. Red-first: dropping `stopEditorContent` from the sequence reds both that case and the parse-time census's start/stop set comparison. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the postinstall generator count in both censuses Two comments said four generators and six generated files. There are five generators writing six files, and the six are not the six either comment described: `census-source-files.ts` still named the page's copy of the terminal document, which ruling 25 retired and #21962 stopped ignoring, while C7.10 C1 added the rich Markdown editor's. Both now name the lists of record — `mobile/package.json`'s postinstall for the generators, `mobile/.gitignore` for the files — and say the count is a reading that grows rather than a fence, which is what made the old numbers wrong twice over. Verified against both lists: 5 and 6. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say which digest is the document and which is the page around it The docstring put main's whole-document digest and byte count in the sentence introducing the shell pin, so it read as if `1ef29c88…` and 29,852 bytes were what the constant below asserts. They are not: that digest is of main's whole document, script included, and nothing in the file reproduces it. The constant is of the document with its `<script>` region emptied, taken on main's document and on this one. Both are now named and separated, with what each covers and why the shell one was read twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the parse-time fixture by its role Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an editor command whose dialog answered after the host moved on C7.10 C1 made `runCommand` async so a host can answer the URL prompt with a modal. Inside the WebView that changes nothing — `window.prompt` resolves within a microtask, and the host reaches the document through `injectJavaScript`, which is a later task — but on the page the modal is a real task boundary, and while it is open the host can replace the content, make the editor read-only or unmount it entirely. The continuation ran anyway: `createLink` against markdown nobody chose, and a change posted under the new generation carrying an edit made against the old one. `acceptsCommands` is the question both halves ask: not stopped, still editable, still the same generation, still contenteditable. `insertUrl` asks it before `execCommand` and `runCommand` asks it again before emitting, each against the generation read before its own wait. The scope gains `stopped`, which `stopRichMarkdownEditorDocument` sets. Inert on native, where no state can change across a microtask, so the answer to both questions is the one the old code assumed. Red-first: with either check removed, the new case reports `[ 'createLink', 'createLink' ]` against `[ 'createLink' ]`. The case carries its own control — an answer that arrives while nothing has moved is still applied and still reported. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the editor's block reader always consume a line `markdownToHtml` looped forever on `# `, `- ` and `1. `. `isBlockStart` admits a marker followed by a space, and the list test admits the same, but the heading reader requires text after the hashes and `parseListLine` requires text after the marker — so on those lines the list branch consumed nothing and returned the index it was given, and the paragraph loop gathered nothing and pushed an empty paragraph without advancing. A one-line file the host handed to `setMarkdown` froze the WebView. Two guards, both by the same rule: a branch may only commit if it moved the index. The list branch falls through when its run is empty, and the paragraph falls back to the line itself when it gathered none. Present on main verbatim, so this is inherited rather than introduced — but the fix is observationally inert, because the only inputs it changes are the ones that previously never returned. Every input that produced output produces the same output. Evidence, from a probe that bounds the loop from the inside rather than waiting on it: before, `# ` and `- ` both UNBOUNDED; after, twenty marker and fence shapes all return. The pinned cases carry their own control, `# ok` and `- ok`, so the fallback is not swallowing the readers it falls back from. A red-first case is not possible here: without the fix the case does not fail, it hangs the worker. The probe above is the measurement. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): see every declaration that runs as a document module is evaluated The parse-time reader inspected only variable declarations, while `DECLARATION_KINDS` admits classes and default exports. So `class A { static value = install() }`, a static block, and `export default install()` all passed a census whose whole job is to refuse exactly that — and a static field reading `document` passed too, which is the remount defect the rule exists for, wearing a different shape. Three shapes now, each reported by what it does rather than what it looks like: a variable initialiser, a class's static members, and a default export that is an expression. `DECLARES_WITHOUT_RUNNING` keeps the last one from walking into the body of `export default function () {}`, whose calls run when something calls it. The preconditions are one per shape, with a negative case beside them: an instance field runs per `new` and nothing in a document is ever constructed, and a default-exported function declares a body rather than running one. Inherited from the terminal's census, which had the same reader; both use this one, and both are green. Red-first: removing the class branch reds the new precondition case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read lifecycle exports from the tree, not from one exact spelling The reader was a regular expression needing `export function`, one line, the scope parameter and no return type. `export async function startX(`, a return type, or a parameter list the formatter wrapped made a real lifecycle export vanish — and the comparison it feeds is a set against the names the sequence calls, so a function missing from *both* lists makes them agree. A start nobody runs would have read as a start nobody needs. It now qualifies a function by what it is: exported, named for its lifecycle, and taking the document's scope as its only parameter. That last clause is ruling 20's own wording — a start takes nothing the scope does not already carry — and the regex was enforcing it by accident, through the single parameter its pattern happened to allow. Surfaced by the change: the terminal's `startEdgeScroll(scope, dir)`, which the regex never matched and the sequence never calls. It takes a direction, so it is the overlay's act for a drag rather than a module's lifecycle, and the one- parameter rule refuses it for the stated reason instead of by accident. Both censuses are green. Red-first: restoring the regex reds the new precondition case, which covers `async`, a return type and wrapped parameters, with refusals beside them for a two-parameter start, another document's scope type, and an unexported function. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
4a3a32206d |
refactor(mobile): the terminal document is a function of its host (OTA phase C, C7.5b) (#21859)
* test(mobile): pin the terminal WebView document byte for byte The document is already pinned as a digest, which says whether the emitted bytes moved and nothing about where. C7.1 moves the hand-written script inside it into modules the web page can import and rebuilds the document from them, and the claim that has to hold through every one of those commits is that the native screen kept the document it had. A digest cannot be the instrument for that: it fails as two hexadecimal strings. So the document is also committed as itself. The fixture is generated by `scripts/build-terminal-document-fixture.mjs`, never pasted, and the test rebuilds the comparison through that script's own substitution rather than restating it, so a fixture written by one rule and read by another cannot agree with itself. The generated xterm engine is stored as two placeholders. It is already covered by the digest test, postinstall regenerates it from whatever xterm the lockfile holds, and inlining it would put 612 KiB of vendored bytes into the file whose job is to isolate hand-written changes. Two further cases keep that from becoming a hole: the placeholders must each appear exactly once and the engine must not appear at all, and the restored document must equal the real one. Regenerating the fixture is a review event. It is only correct when the emitted document was meant to change, and the diff in that commit is the evidence. Red-first: flipping one character inside a comment in `write-queue.ts` fails both identity cases with a one-line diff naming the comment, where the digest test reports a hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): compare two terminal documents as programs, not as bytes The C7.1 flip commit moves the document's 57 reassigned variables onto a scope object, because a variable assigned across ES modules is a syntax error, and every read and write of them gains a qualifier. The ruling asks that the review of that commit be a test rather than a 515-line read. This is that test's instrument. It cannot be a byte comparison. Once the script's source is modules, `oxfmt` owns its style, and the repository's style has no semicolons where the hand-written document has one on nearly every line. A byte diff would therefore be dominated by changes that are not the refactor, which is the opposite of what the reviewer needs. So the comparison is over tokens: semicolons are excluded for the same reason they moved, comments never reach the stream, and one difference is allowed — `name` becoming `<qualifier>.name`, three tokens for one — which it counts and reports. It is stricter than "it still runs": a reordered statement, a changed literal, a dropped operator, a renamed local and a qualifier under the wrong object name all diverge, each reported with the token index and both sides. Acorn carries `value` on its tokens but does not declare it, so the field is read through a narrowing check rather than asserted onto the declared type. Red-first, by mutation: dropping the qualifier-name check fails the case that names it; removing the leftover-token check fails the dropped- and added-statement cases; treating semicolons as significant fails the three cases that depend on ignoring them. The acceptance case runs on the real 2,758-line script rather than on a fixture, so the instrument is known to survive everything the document actually contains. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count each normalisation the move makes, separately Measured while extracting the first group: the document's ES5 style is not a style this repository's own rules permit. `curly` braces 279 brace-less if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446 `var` declarators become `const`, `let` or a scope field. Those rewrites land before the qualifier is considered at all, so "the qualifier and nothing else" was never reachable once the source is a linted module. The comparison now allows exactly four classes and counts each on its own: a reference that gained the qualifier, a declaration that moved onto the scope object, a `var` that only changed keyword, a body that gained braces, and a catch clause that lost its binding. Separate counters rather than a total, because the flip commit pins each number and a total would let one class absorb another — which is the drift the pin exists to catch. The two `var` classes partition the 446, and the qualifier's 641 sites partition into references that kept their declaration and declarations that moved. Two ordering facts the cases pin. The catch rule is tried before the brace rule, or the inserted-brace rule eats the `{` that follows `catch` and the streams never resynchronise. A body braced at the very end leaves its closing brace after the baseline has run out, so trailing closes are absorbed after the walk rather than reported as a length difference. Everything outside the four classes still refuses with the token index and both sides: a changed literal, a dropped operator, a reordered pair, a renamed local, a qualifier under another object's name, a brace opened and never closed, and a brace closed where none was opened. Red-first, by mutation: disabling the catch rule, disabling the trailing-brace absorption, folding scope-field declarations into plain references, and not counting brace insertions each fail exactly the case that covers them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): make the mouse-report cell a module the page can import The first of the twelve groups the document already names. `*-injected.ts` has been splicing JS strings into the document for a while, and tests evaluate those strings, so the one-source-two-consumers shape is already there; what is missing is that a string cannot be imported by the web page, typechecked, or linted. This turns one of them into a module and adds the generator that puts it back into the document. The generator is a transform, not a bundle: a bundler orders its output by the dependency graph, and the document's order is part of what the equivalence test holds fixed. Imports are dropped rather than resolved, because inside the document every name is already in scope — that is what the single IIFE means — and `document-externals.ts` declares the names whose groups have not moved yet and emits nothing at all. esbuild prints an ESM module's exports as a trailing block, so that block is dropped whole rather than by its keyword; leaving the keyword behind would put a bare block statement in the document. Both sides of the comparison now go through that same printer before being read. Otherwise every choice the printer makes — semicolons, property shorthand, quote style — reads as a difference in the program when it is a difference in who typed it, and each would need its own rule. A script that does not parse is reported as a refusal naming its side, not thrown. `let` is contextual outside strict mode, so acorn reports it as a name and not as a keyword; without that the var-to-let rewrite the linter performs would be refused on every reassigned local. The group's counts are pinned exactly: nine references gained the qualifier (`term` seven times, `panX` and `panY` once each), nine locals became `const` or `let`, thirteen one-statement `if` bodies gained braces, no declaration moved onto the scope object and no catch clause lost a binding. The document is untouched, so the byte pin from |
||
|
|
e9b180685b |
feat(mobile): render Mermaid diagrams on the page from one deferred engine artifact (OTA phase C, C7.10 B) (#21871)
* test(mobile): measure mermaid rendered in the page Red-first for C7.10 item B. The check mounts the real web sibling in chromium and webkit under the shipped shell CSP and asks four things of it: that a diagram renders with zero policy violations and zero eval / new Function calls, that the SVG is the native buildHtml's own output once the diagram id and xmlns:xlink are normalised away, that a hostile diagram lands inert, and that a source change, an unmount and a remount leave exactly one SVG and no listener of the first mount. The equality oracle is buildHtml itself, bundled for Node behind a Proxy stub for its native imports and served as its own document in the same browser, so neither side of the comparison is retyped. All eight cases fail on this commit: the sibling is still the labelled source box. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fence the session download rather than its module list Ruling 28. mobileWebAppRouteClosure reads metafile.inputs, which holds dynamically imported modules under splitting: true exactly as it does under splitting: false, so it cannot say "on demand" about anything: an on-demand mermaid moves the session route's module list 4320 -> 6362 while its download does not move at all. So the fence moves to entryStaticClosure. The new helper walks the emitted chunks from the output the route's own module landed in and follows import-statement edges only, and hands back both halves, because mermaid's absence from the download is only a measurement while its 66 files are present in the deferred half. The module list's new total is recorded in the docstring with its reason and asserted beside the engine's own file count, which moves only when the pinned mermaid version does. Red on this commit: no mermaid in the closure yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): render mermaid in the page The web sibling stops being a source box. mermaid is a browser library, so the page imports it inside the render effect and draws the diagram in this document: no WebView, no 3.7 MB engine string, and nothing of the engine downloaded by a session with no diagram on it. What replaces the sandbox is mermaid's own securityLevel: 'strict', which runs its serialized SVG through DOMPurify. The native path's </script> escaping has no analogue here and needs none, because the source is a JS string argument rather than text spliced into an inline script. Measured in both engines: a script in a label, a </script>, an onerror and a javascript: click all land inert. The configuration is now one object both hosts read, so the theme cannot drift between the page and the phone; buildHtml serializes it instead of holding a second copy. It gains suppressErrorRendering, because mermaid otherwise draws its own error diagram into a temporary element and leaves that element behind when it rethrows -- an orphan SVG on the page, and on native a diagram the component is about to replace with the source box anyway. The dispose clears the host on unmount and on a source change; the id is a useId, because mermaid writes it into the stylesheet inside the SVG and it has to be a CSS identifier. Also re-records the closure total the previous commit pinned: with the real component the session route's module list is 6376, not the design probe's 6362, and the reason is in that file's docstring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): budget the deferred engine's chunks apart from the routes Putting mermaid on the page took the app bundle from 69 emitted scripts to 172, and the asset budget failed: 215 assets against a ceiling of 115. The cause is not a page split running away, which is what that ceiling is for -- it is that mermaid lazily imports each of its own diagram types, so one import() lands 103 scripts no route count predicts. So the ceiling gains a second term, named and measured (172 scripts with mermaid against 69 with it aliased to a stub, at 11.17.2), rather than the route term being raised to cover it. A page split running away still fails on the route term, and the failure still says which of the two grew. The consequence is worth reading twice: the derived ceiling has to stay inside the 256 assets the shell will load, and with 42 images it now crosses that at 24 routes instead of 50. The bundle is at 215 today with 14 routes, so there is room for about ten more routes before a green build produces a manifest no phone will open. Measured by the config/scripts suite failing on this head, not predicted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): pre-bundle the page's mermaid into one artifact import('mermaid') from inside the app bundle emitted 103 scripts, not one: mermaid lazily imports each of its own diagram types and esbuild splits along those boundaries. Every one of those scripts sits inside the OTA generation the phone has already downloaded, so the split moved no bytes over the wire and spent 103 of the 256 manifest assets the shell will load -- which is the scarce resource here, and the reason the previous commit had to invent a second ceiling term. So a sibling generator bundles the package into one ESM module beside the WebView engine it already builds, emitted by the same postinstall run, gitignored and lint-ignored with the others. The page imports that artifact on demand instead, through a loader whose return type names the two calls the component makes -- checked against the artifact's own inferred export rather than cast to it. Measured, at 14 routes: emitted scripts 172 -> 69 (68 with no deferred engine at all) manifest assets 215 -> 112 (111 with none) session modules 6376 -> 4323 (+3 over main: config, loader, artifact) chunks fetched for one graph TD 27 -> 1 bytes fetched 837,530 -> 3,482,965 The static-closure fence is unchanged in meaning and now reads on the artifact: absent from every chunk the route reaches by an import statement, present in the deferred half. The rendered SVG is byte-for- byte what it was, so the equality against the native document still holds on both engines. Also adds the diagram to the webview-consumers list, which is what that list means: its native component imports the package and its sibling is what the builder resolves instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * revert(mobile): drop the deferred-engine ceiling term, keep the control With the engine pre-bundled into one artifact the bundle emits 69 scripts at 14 routes against the route term's 72, so the second term this series added has nothing left to do and the route count is the only term again. mobileWebAppBundleMaxChunks and the asset ceiling derived from it are back to what main has; the shell's 256 assets are crossed at 50 routes again rather than at 24. What stays is why. A ceiling raised to admit 172 scripts would have admitted any split at all, so the budget test gains the control that holds the line: the single-artifact count passes the ceiling and the lazily-chunked count fails it, both measured at 14 routes, with mermaid named as what produced the second. Red before the term came out: the control failed asserting 172 > 175. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep build output out of the raw-request-port census The census walks mobile/src for AST reaches into the unvalidated request port, and the pre-bundled mermaid artifact is the first generated file under src that is executable code rather than a string literal. Two of its own vendored dependencies contain the token `sendRequest`, so the walk read minified third-party code as a new call site and asked for an inventory line nobody can ever migrate. So `*.generated.ts` joins node_modules and test files in that file's stated list of what it does not scan, with the reason. The scripts that emit those artifacts are ordinary source and are still scanned, which is where a real reach would be. Two halves to the new control, because a filter that skipped everything would satisfy either alone: nothing generated is left in the scan, and the matcher still finds the port when handed one line of code. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): escape the shared config into the native inline script buildHtml spliced JSON.stringify(MERMAID_DIAGRAM_CONFIG) straight into the inline <script>, twenty lines below the function that exists because JSON.stringify leaves `<`, `>`, `&` and the U+2028/9 separators raw. Inert at today's five hex colours, and not inert for a themeCSS or a font stack, which is free text going into the same script element. So the escaping splits from the stringify and both callers use it: the source keeps its own wrapper, the config gets one. Those characters only ever appear inside JSON string literals, so escaping them is valid for an object serialization exactly as it is for a string. Red first: a config carrying `</script><script>` put four raw closers in the document where a benign build has two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin the page mermaid type against the package's own The loader returned the artifact's default as PageMermaid, which checked that two names exist and nothing about their shapes: the artifact is minified vendor output and both members infer as `any` there -- a probe assigning engine.render to a number compiles -- and `any` satisfies every signature there is. So the shapes are asserted against the package's `Mermaid`, which is precise. A PageMermaid member whose signature the engine does not really have now fails at this line rather than at a call the page makes. In the product module, not a test: mobile/tsconfig.json excludes test files, so a type-only assertion in one is never compiled. Underscored because it is a compile-time statement with no runtime reader, which is the form the linter asks for. Control, verified both ways: changing render to (id: number) => Promise<{ svg: number }> reds tsc naming both parameter and return, and the real signatures compile. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): re-measure the chunk series and say what it does not show The four-point series was stale and read as a slope it is not. Measured again on this head, by copying the route tree and dropping routes from the end of the sorted key list -- both siblings of each, because deleting a .web.tsx alone leaves the native file for the builder to resolve and measures an entirely different closure, which is how the first attempt at this produced 77 scripts for 14 routes: 8 routes -> 32 scripts 10 routes -> 43 12 routes -> 61 14 routes -> 69 (the real tree) Between four and nine more per route depending on which route, so 4r + 16 is a bound and not a fit, and the justification now says that instead of claiming three per route. It also says the part that matters more: at 14 routes the tree measures 69 against 72, and the last two routes cost the 8 the ceiling grants for two. The fence is at break-even, and the new assertion states that slope from the function rather than from a comment. Also records what the generation weighs, since every chunk ships in it whether or not a phone fetches one: 8,016,714 bytes across 112 assets against the 9 MiB ceiling, 84.9%, 1,420,470 left. It was 4,539,090 before item B, and the engine is the difference. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the native fallback under suppressErrorRendering The shared config reaches the phone too, and it gained a key the native path did not have. So the native document is now loaded for a diagram that throws, in both engines, with window.ReactNativeWebView standing in for the host: mermaid's run still rethrows, the document's own catch still posts `error`, and that is the message the component turns into the source box. Measured both ways, so the case says which half the key owns. Whether the fallback fires does not depend on it -- `error` is posted with the key and without it. What depends on it is that nothing is drawn behind the fallback: removing the key leaves mermaid's own error diagram in the document and reds this case at 1 SVG against 0, on chromium and webkit alike. The control is the same document for a diagram that parses: a height, not `error`, and one SVG. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): one walk for every source census, without build output Nine censuses under mobile/src each held a copy of the same recursive walk, and each decided for itself what a source file is: seven had no opinion about generated files, one excluded them in its own regex, and one had the exclusion I added last round. So all nine read 7.9 MB of emitted vendor code -- 3.7 MB of mermaid for the WebView, 3.5 MB of it for the page -- and the two largest censuses TypeScript-parsed all of it, looking for call sites nobody wrote and nobody can move. That is what took rpc-params-contract-type-only-boundary over its 5 s timeout in CI once the fifth artifact arrived. Measured here, median of 3, import plus tests: main, 4 artifacts, no exclusion 1004 ms (slowest case 831 ms) with the 5th, no exclusion 1513 ms (slowest case 1358 ms) with the 5th, this commit 947 ms (slowest case 788 ms) So it lands below where main has it, not merely below where I left it. Across the nine, four more halve: rpc-operation-cast-fence 769 -> 441, rpc-subscription-boundary 946 -> 468, unchecked-rpc-reader-boundary 1042 -> 538, lifecycle-owner 747 -> 433, reanimated-web-mapper-deps 1028 -> 516. The two that already excluded generated files do not move. What each census counts as interesting -- extensions, whether test files are in -- stays its own, because they genuinely disagree. What counts as a source file at all is now said once. The control is the file that started it: a *.generated.ts whose text holds exactly the import a census is hunting, planted beside an ordinary file carrying the same text. The generated one is not returned and the ordinary one is, so the absence is a measurement. A second control reads mobile/.gitignore and holds the predicate to every artifact the tree generates, and a third fences the walk itself to one spelling, so a tenth census cannot paste the cost back in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct why the type pin sits in the product module The comment said a type-only pin in a test file "is never compiled". That is false: mobile/tsconfig.json excludes *.test.ts, but tsconfig.test.json is a second program that does check them, run by check:tests-typecheck and held by the tests-typecheck ratchet. The conclusion is unchanged and the reason is now the true one. The app's own typecheck is the unconditional gate and would not cover a pin written in a test; the test program is real but carries a grandfathered baseline and a few files held outside it on purpose. And the assertion is about this module's own type either way, so it belongs beside it. Comment only; tsc, the ratchet and both lints re-run on the file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e6aa90ff36 |
test(mobile): certify the browser pane's golden families and render it in a page (OTA phase C, C6.5) (#21777)
* test(mobile): pin the browser pane's golden families The half pin for C6: 4 families, 15 goldens, every verdict the one C2's rule predicts. Measured per family with vitest `-t` over the full 787-golden corpus, with C1's 103 reproduced golden-for-golden as the control: 6 byte-identical, 9 result-absent-settlement. No composed `c6-page-closure.ts`: a composed table is pinned against a route and the browser is a pane, so C7's route is what composes this with C1's. The derivation census does not wait for that route. `mobileWebAppRoute- Closure` becomes one case of `mobileWebAppModuleClosure`, which takes any entries, so the pane's own closure can be read from the module. Two cases: the pane alone reaches exactly the pinned four, and the pane beside `app/h/_layout` adds exactly those four and no other, with the layout reproducing C1's 22 as the control for the difference. Closure at this base: 48 local modules alone, 34 beyond the layout, 30 under `src/browser` and four through the web siblings. The design said 23, all under `src/browser`; it was measured before C6.2 and C6.3 added those siblings, so the pin carries the re-measured number. `browser.screencast` has no golden at all, so this certifies the input path and says nothing about the frame path. Red first: with `browser.wheel` dropped from the table, both census cases fail naming the missing family; restored, the file's 10 cases pass and the parity suite reports "15 goldens in 4 families, 6 byte-identical". Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the frame budget against the shell's real frame Ruling 2's pin. `binaryEventEnvelopeBytes()` sizes the mobile view's device scale from a skeleton it builds itself, and until now its only check was another skeleton of the same shape in the same file: two copies of one assumption agreeing with each other. This measures the real thing. A frame with CDP's nine metadata fields and a real `Page.screencastFrame` timestamp, encoded by C6.1's `encodeBridgeScreencastFrame` and serialized by the real `BridgeHostSubscriptions`, posted through the host harness: 303 bytes besides the image, against a bound of 516. Held above is not enough on its own — 213 bytes of slack is room for the shell to grow the envelope by a field the page never hears about — so the bound is reconstructed exactly instead. Every byte of that slack is a number this frame prints narrower than a double can; adding those back gives 516 on the nose. The budget cases run a generated noise image at the budgeted scale, not a committed fixture: the worst case is the image JPEG compresses least, and a photograph sits a tenth of the way to it. 901,161 px at 0.545 bytes per pixel is 491,132 bytes, which the shell posts at 654,857 of the 655,360-byte cap. One envelope more and the shell drops it, which is ruling 1 read from the budget's side. Red first, two ways. Drop the metadata widening from the bound and three cases fail, the sharpest being the real shell answering the frame the page thought it could send with zero posts. Add a field to the shell's own envelope and the reconstruction fails at 516 against 548, where the existing suite stays green on all 14 — which is the drift this file exists for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): record the measured frame bytes, correcting |
||
|
|
6a0200f416 |
test(mobile): certify the files page closure, 28 families and 125 goldens (OTA phase C, C3.2) (#21724)
* test(mobile): certify the files page closure, 28 families and 125 goldens
C3.2. The closure is re-derived at this base from the entries esbuild compiles —
the two `.web.tsx` files — and matches the design exactly: 28 families, 125
goldens, C1's 22 a strict subset, 6 families and 22 goldens added. Explorer
3441 modules / 304 local / 10 under `src/files`, preview 3666 / 330 / 19, union
342 local. No `mobileWeb.*` family appears, which is the tell that the native
switch was not measured.
Two tables at the route seam, as C2.6 splits its own: the explorer's one family
and the preview's five are separate evidence for two routes with separate
grants, and `c3-page-closure.ts` only spreads them over C1's.
C1's 22 families are inherited verbatim, not re-derived. Measured here, C2's
rule disagrees with 13 of those 103 pins — all 7 in `tasks.smart-source-search`,
all 5 in `host-worktree-refresh`, and `worktree-catalog-snapshot`. C5's
docstring says 10; corrected in this commit, comment-only. Every family C3
shares with C2 and with C5 is asserted equal object for object, and the three
committed pin files disagree on nothing.
Class totals: identical 66, result-absent-settlement 47, params-undefined 7,
result-absent-stream-release 3, write-ordinal 2. Pinned beside the per-id walk
because a table built wrong in a self-consistent way passes the walk.
Red-first, both halves: changing one verdict fails the totals and the
inheritance check by name; dropping a golden fails drift, totals and the census
count together.
What 125 certified does not say: `host-worktree-refresh`, inherited from C1, has
no byte-identical golden at all, so its 5 hold a class and not bytes. All six
families C3 adds have at least one. No scenario replays a save twice, so
`files.writeTerminalArtifact` is certified for one round trip and not for
idempotency; and no golden here subscribes, because the domain opens no stream.
The `config/scripts` precondition now derives both files routes and compares
their union against the C1 + C3 tables, with a second case proving each route
reaches a strict part of it — without which the union would pass with one route
contributing nothing.
Also folds pullfrog's open nit: the render check's comment claimed
`toContain('readme.md')` proved the encoded round trip, which a truncated path
would also satisfy; the url assertion beside it is what proves it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): share the pin-source reader and finish C5's 10-to-13 correction
Two pullfrog findings on #21724.
`c5-page-closure.test.ts` still said the rule disagrees on 10 of the 103; only
`c5-page-closure.ts` moved in the last commit, so C5's two files disagreed with
each other and with C2's and C3's. The sentence now states 13 with the same
breakdown the other three carry. No assertion depended on the number. While
there, the comment records why C5 compares against the imported object rather
than the committed text and is still sound: it inlines its families instead of
spreading C1's, so there is no spread for an edited entry to launder through.
`pinsFromSource` was byte-for-byte identical in C2's and C3's suites — checked,
not assumed — and is now one module both import. It sits beside
`page-closure.ts` rather than inside it, named for what it reads: the module it
would have joined holds pure table arithmetic and this one reaches the file
system. The C2 suite's cases and assertions are unchanged; its diff is the
deletion of the copy and one import.
The shared reader keeps its teeth: making the wrapped-entry capture unmatchable
reds the inheritance check in both composed suites, which is the defect the
comment describes — three `result-absent-stream-release` pins once went missing
that way with an empty mismatch list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name which leg of the path round trip each assertion proves
Round-1 nits 3 and 4. A second commit rather than a fold into `dc1de6e728`:
that one is already committed, and amending is off the table.
The render check's comment claimed the url assertion was the proof of the round
trip. It is the outbound leg only — what the page encoded into its own history —
and a screen that mis-decoded the middle of the path satisfies it and the title
assertion both. The comment says that now, and points at where the inbound leg
is proved instead: `mobile-file-path-route-encoding.test.ts`, which reads each
hazard shape back out of the href, and `mobile-file-preview-route.test.ts`,
which drives the normalizer the screen reads its params through. Both files
checked to exist, and read, before being cited.
The 10-to-13 edit left a 127-character line in a file that wraps at 100;
reflowed. It was the only over-length line the C3.2 commits introduced — the
others in both files predate this branch, and `oxfmt` accepts them because it
does not reflow comments.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the inheritance case is blind to, and finish the reflow
Round-1 item 3, plus a correction to my own claim.
The "inherits C1's families whole" case reads C1's committed text, which makes
it independent of the spread but not of C1's file: edit a verdict there and both
sides of the comparison move together. The docstring now says that, says what it
does catch — a C3 half redeclaring an inherited family, which the spread would
otherwise take silently from the last table — and names where the C1-file edit
is caught instead.
Both measured here rather than taken from the review. Flipping
`settings-repo-metadata-icons` in C1's file leaves this case green and reds
seven others: the class totals and the exclusion counts in C2's suite and this
one, both cross-series agreements, and C5's own inheritance case, which compares
against an independent literal rather than a spread. Redeclaring
`settings.repo-metadata` in the preview half reds this case along with five
more.
I also said last round that the 127-character line was the only over-length line
these commits introduced. That was wrong: I checked the two files in that commit
rather than the branch. Six lines over the 100-column wrap came in across four
files, including two I had just written in `c3-page-closure.test.ts`. All six are
reflowed, and the check is now over every line the branch adds rather than over
the files I happened to touch last.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state both redeclaration shapes, or neither count
Round 2's low finding. The docstring said redeclaring `settings.repo-metadata`
in the preview half reds this case "along with five more". The reviewer is right
that the number is shape-dependent, and right about both figures.
Re-measured, no truncation this time: one golden under the family's name reds
seven, because it also shrinks the census to 114 and leaves that family with no
byte-identical golden; the family copied verbatim with a single verdict flipped
reds five, the census unmoved at 125. The docstring states both with the reason
they differ, and says plainly that the count is not the claim — the load-bearing
half is that the spread takes the last table's entry and this case is what sees
it, which holds in both shapes.
The six came from reading a `head -6` of the failure list as the whole of it.
That is the same mistake in miniature as the one this file's own comments warn
about: an empty-looking result that was only a truncated one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
76063e7ab1 |
test(mobile): certify the tasks page closure, 70 families and 266 goldens (OTA phase C, C2.6) (#21712)
* test(mobile): read a page closure's run totals through one reader The C5 gate counted the run's classes inline. C2 needs the same count over its own closure, and two spellings of "what the run tallied" can disagree while both stay green, so the loop moves next to `pageClosureTotals` where the table-side count already lives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): certify the tasks page closure, 70 families and 266 goldens C2 moves the tasks screen to the web, so the goldens recorded at a call site inside `app/h/[hostId]/tasks.web.tsx` and `app/h/_layout.tsx` are the ones whose divergence would be this domain's. Each is pinned by id: the suite's own counts run over 787, where one of the other 521 can pay for a closure golden that stopped replaying. C1's 22 families are inherited verbatim rather than re-derived — C2's rule disagrees with them on 10 of the 103 — and the rule decides only the 48 this domain adds. The pin is split at the domain's seam, one work item opened versus choosing which to open, because the table is 409 lines of data and `max-lines` is not a thing to disable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): correct the C2 pin's inheritance count and census scope Two comments overstated what was measured. The rule disagrees with 13 of C1's 103 inherited pins, not 10 — the 10 was copied from C5's file, which carries the same error over the same 22 families — and the breakdown is now named so the number can be re-derived rather than trusted. The census reads the committed table and does not re-derive the closure, so a golden arriving in a pinned family is caught while a new family entering the closure is not. That was true and unsaid, which is the worse of the two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test: derive the tasks page closure's family set instead of trusting the pins Round 2 folds, three. The pin tables walk the families they already hold, so a scenario recorded at a call site the route already imports lands in a family nobody pinned and every assertion stays green. `mobileWebAppRouteClosure` runs in a quarter second and `config/scripts` already imports it, so the derivation is now a test: the family set the closure reaches must equal the union of the three committed tables. C2's inheritance check read the object its own table spreads, which cannot disagree with itself; it now reads C1's file as text. What that does and does not hold is written down, because a verdict edited inside `c1-page-closure.ts` is green there either way — C2 inherits whatever C1 commits. The gate's C1 block gains the run-totals assertion C5 and C2 already had, which is the check that edit does fail. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci: run the page-closure family check in the job that installs mobile deps Its closure half asks `mobileWebAppDependenciesPresent()` first, so outside the `mobile_web_app` job it skips itself and the precondition it exists to be never runs. That job sets `ORCA_MOBILE_WEB_APP_DEPS_REQUIRED`, which turns the same question into a failure. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
62903b7cf6 |
test(mobile): pin the agent-history page closure golden by golden (OTA phase C, C5.2) (#21649)
* test(mobile): pin the C5 page closure golden by golden (OTA phase C, C5.2)
The suite next door proves the corpus replays byte-identically or in a named
class, as counts over 787 goldens. A count cannot tell this domain's regression
from another domain's improvement, so the 134 recorded at a call site the
agent-history page owns are pinned by id, the way C1's 103 are.
Re-derived at this base rather than taken from the design: 3510 modules, 370
local, 16 under `src/agent-history`, 27 families, 134 goldens. The design measured
3508 / 368 at `889c2b562f`; the two new local modules are `omp-draft-launch.ts`
and `omp-fresh-launch.ts`, which arrived on main, and no family or golden moved.
The `.web.tsx` sibling is the entry: the native file reaches `MobileWebShellScreen`
and pulls the shell in, which adds 46 local modules and the two `mobileWeb.*`
families the C1 docstring excludes by name — so measuring the wrong one of the two
gives the right pin table and a wrong module count.
C1's 22 families are inherited verbatim rather than re-derived. Deriving them from
C2's classification rule disagrees with the committed file on 10 of its 103 pins,
in three ways that are each a true statement read too far: `tasks.smart-source
-search` is a `params-undefined` family and the rule's list of those is the five
C2 adds, not C1's one; `host-worktree-refresh` carries `write-ordinal` and
`result-absent-stream-release` goldens, classes the rule does not model because no
family C2 adds carries one; and "a scenario scripting `{ ok: true }` with no
result" is a property of the scenario a golden derives from, not of its family, so
reading it family-wide moves `worktree-catalog-snapshot`. The rule decides the
five families C5 adds and nothing else.
`c5PageClosureTotals` is pinned beside the per-id walk, and the gate asserts the
run's own class counts over this closure against it. A per-id walk agrees with a
table that is wrong the same way twice: deriving this file with a reader that
skipped `c1-page-closure.ts`'s wrapped entries produced an empty mismatch list, 53
`result-absent-settlement` and 0 `result-absent-stream-release` where the truth is
50 and 3. Only the counts showed it, so only the counts can guard it.
Both halves proved red, not assumed. A verdict changed in the pin fails the gate
as "aivault-history-scan-fulfilled: pinned params-undefined, ran identical"; a
golden dropped from the pin fails three ways — the drift names it, the totals move
72 to 71, and the census count fails.
Tallies untouched: 787 goldens, 396 / 341 / 3 / 6 / 33 / 8, unclassified 0. No
golden moved and nothing under `mobile/rpc-foundation/` was written.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give both page closures one drift, not two copies
Round 1 on #21649. C5's pin file arrived with C1's type alias, observation type,
exclusions walk and drift walk copied across, identical but for the names. The
copies are the problem rather than the duplication: these pins are the instrument
two domains argue with when a golden moves between families they share, and C5's
closure contains C1's 22 families entire — so two spellings of "what drifted" can
disagree about the same goldens while both stay green.
`page-closure.ts` now holds the types and four helpers, each taking the pins as an
argument; `pageClosureTotals` moves there too, since it is a general question and
was C5's only real addition. `readPageClosure` joins them: the gate had grown a
local helper to print one domain's line, which is the same shape one step later.
Both pin files are now their table and nothing else, which is the part that is
per series and meant to be read as a diff.
Output asserted rather than assumed. The gate's two closure lines are byte-
identical before and after — "C1 page closure: 103 goldens in 22 families, 54
byte-identical" and "C5 page closure: 134 goldens in 27 families, 72
byte-identical" — diffed as files, and both pin suites report the same 60 cases
with the same verdicts.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read a golden's family through the corpus's own reader
Round 1 on #21649. Both pin tests parsed a golden file and asserted its shape in
order to read one field, each with a `SAFETY` annotation saying every golden
carries `family`. `readGolden` already answers that question properly: it checks
the format version and the value pool and throws a named diagnostic otherwise,
and returns `family` typed.
Both annotations are gone rather than reworded. C1's is fixed in the same commit
because it is the same line — the cast arrived in C5's file by being copied from
it, and leaving the original would have kept the shape that produced the copy.
The assertion is also stronger than it was. Before, a golden whose format version
had moved would still be read and its `family` compared; now that file fails with
the reader's own message instead of being silently accepted.
Checked that it still discriminates rather than assuming: renaming one family in
the pin fails two cases, the corpus-family read among them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
889c2b562f |
feat(mobile): say why a host is unreachable instead of "Connecting via Relay…" (#21566)
* feat(mobile): say why a host is unreachable instead of "Connecting via Relay…"
The home-screen host row and host header showed "Connecting via Relay…" for
as long as the desktop stayed unreachable, even when every relay dial had
ended with the cell's 4404 host-offline close. A user's diagnostics export
showed 25 such dials over 25 hours behind that label, and the diagnostics
report itself said "No single failure cause" because relay dial failures
were not recognised and every app resume emptied the evidence window.
Relay close codes now map to a closed RelayHostReachability verdict
(signed-out, host-offline, credential-refused, unreachable), latched after
two consecutive identical dial failures and cleared only by an authenticated
session. The existing signed-out close reason becomes a member of the same
verdict instead of a parallel boolean. classifyConnection renders each
verdict as a label plus a detail line ("Host 1 is offline" / "Check it's
awake, Orca is running, and you're signed in").
Relay dial failures carry their close code as a structured field on the
connection log entry, so the diagnostics analysis names the cause without
parsing error text, and an app resume no longer hides the last failure: it
is reported with a "Before the app last resumed" qualifier and is never a
sendable incident.
* test(mobile): re-record RPC goldens at the new baseline
Only header lines change: the baseline pin on every golden and the
adapterSha256 on the twelve goldens whose mount adapters gained the
getRelayHostReachability context method. No checkpoint moved, which also
shows the commits between the old and new baseline changed no observed
RPC behaviour.
* fix(mobile): tell a refused relay credential to re-pair, not to find the same network
A direct session also rotates the credential, but telling the user to
connect on the same network once explains the mechanism instead of giving
an action, and re-pairing is the one remedy that works from anywhere.
* fix(mobile): let the newest relay failure win the diagnosis, and name the real stale boundary
Relay-path evidence still outranks a newer direct timeout, but among relay
failures the newest now wins: an older 4404 verdict no longer hides a newer
session close (which was also the sendable incident) or a director refusal.
The stale prefix names a network change when that, not a resume, was the
boundary.
|
||
|
|
f2fd18c820 |
test(mobile): make the bridged recording-corpus replay a CI gate and pin the C1 page closure (OTA phase C, C1.6) (#21533)
* test(mobile): the bridged corpus replay is a gate, not an opt-in flag (OTA phase C, C1.6) `rpc-recording-through-bridge.test.ts` replays all 787 goldens through the page bridge and pins how far that bridge is from byte-identical. It only ran when `RPC_FOUNDATION_BRIDGE=1` was set, which CI did in a step of its own. A gate whose CI job has to remember to set a variable is opt-in, and a branch that widened the divergence with that step edited away would have been measured by nobody. The suite now runs by default and `RPC_FOUNDATION_BRIDGE=0` skips it, for a local run that does not want the three minutes. `BRIDGED_PARITY_OFF` names the one value that skips, so an unset or mistyped variable still runs the gate. The dedicated CI step goes with it: `pnpm test` collects the file already, so keeping the step would have run the same 788 tests twice in one job. Vitest gives the file a worker beside the rest of the suite, so the marginal wall time is a fraction of the ~3 min it takes alone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the bridged verdict of every C1-page-closure golden by id (OTA phase C, C1.6) The bridged replay certifies the corpus in counts over 787 goldens, and a count is the wrong instrument for the claim C1 needs. C1 moves one domain to the web: `app/h/_layout.tsx`, `app/h/[hostId]/index.tsx` and their import closure. A golden recorded at a call site in there that stopped replaying byte-identically is paid for by any of the other 684 that started, and every existing assertion still passes. `c1-page-closure.ts` names the 22 families and 103 goldens of that closure and pins each one to the verdict it gives: 54 byte-identical, 49 in four of the five classes the suite already excludes, all of them recorder observation artifacts whose wire bytes C0.5 and C0.8 proved identical. Membership is checked per family, not against the flat id list, so a golden newly derived into a family this domain owns arrives as a finding instead of being missed for never having been pinned. A closure golden may only be excluded into a class that carries a reason in `BRIDGED_PARITY_EXCLUSIONS`. Two full-corpus assertions go with it. `identical` was a floor; it is now the exact 787 minus the excluded classes. And the run's own size is pinned to the corpus: every class is an upper bound, so without that a corpus that lost goldens outside the identical set satisfied all of them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): move a closure golden to a verdict it is not already pinned to (OTA phase C, C1.6) The drift test took whichever golden the pin lists first and moved it to `params-undefined`. Nothing said that golden was not pinned to that class already, and the day one is, the test asserts a change it never made. Pick the first golden whose verdict is something else, and assert the pair of verdicts the line reports rather than just the new one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin `identical` to its baseline, not to the run's own exclusions (OTA phase C, C1.6) `identical: pinned - excludedCount` took `excludedCount` from the run, and the sum assertion above it already forces `total(counts) === excludedCount`, so the pair reduced to `corpus === pinned`: only the size of the corpus was pinned. With every per-class check an upper bound, a golden moving out of an excluded class into `identical` satisfied all of them. `bridgedParityTallyDrift` pins every number to `BRIDGED_PARITY_BASELINE` exactly, `identical` among them, and its own tests encode the mutation: one `result-absent-settlement` golden reported `identical` is two drift lines, where before it was a green run. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the observation class counts 3, not 7 (OTA phase C, C1.6) The number in the prose predates the baseline it describes; `result-absent-observation` is 3 in `BRIDGED_PARITY_BASELINE` and in the run, and the line above it already says 341 / 3 / 6 / 33 / 8. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the checks that are still there, not the bound that is gone (OTA phase C, C1.6) Three comments described the per-class upper bound the tally replaced. What the exact pin is exact *against* is now `unclassified`, the exclusion sum and the membership pins, so say those. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the closure-exclusion check the cheap suite already makes (OTA phase C, C1.6) `c1PageClosureExclusions()` reads `C1_PAGE_CLOSURE` and nothing the run produced, so asserting on it inside the three-minute replay bought nothing the cheap suite next door does not already buy. `c1-page-closure.test.ts` makes the same assertion with a presence precondition on top, which is the stronger of the two. The comment beside the tally counted two checks above it; there are three, and a golden that moved out of an excluded class into `identical` is invisible to all of them: `result-absent-settlement` is past the nameable cap, so membership drift never looks at it, and lowering it lowers the exclusion sum with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d253dd0e1d |
fix(mobile): the page bridge accepts every reply native accepts and settles what it refuses (OTA phase C, C0.8) (#21511)
* chore(mobile): repin the recording corpus to main's tip (OTA phase C, C0.8) C0.5 pinned `baseline` to its own branch commit, which the squash-merge made unreachable, so `rpc-recording-pin-guard.mts ancestry` fails on main and `--record` refuses to run at all. Repin to main's tip and refresh every header from it. Header-only, and the corpus proves it: across all 787 goldens exactly two distinct lines changed, the old `baseline` and the new one. `recorderSha256` did not move, because nothing under the recorder's own directory did. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): accept at the page bridge every reply the native client accepts `BridgeReplyPayloadSchema` required `_meta` on both arms. The predicate the native client applies to a reply off the wire, `transport/rpc-response-shape.ts`, requires none, and `src/shared/runtime-rpc-envelope.ts` — the envelope clients and runtimes share — makes `_meta` optional on a failure with a nullable `runtimeId`. The page's reader was strictly narrower than the transport it stands in for, so replies the phone accepts today were refused, dropped with a diagnostic, and settled nothing. The reader is now that predicate itself, imported rather than restated: one module owns the shape, and a widened reader is safe in both directions. `{ ok: true }` with no `result` key stays refused, because `isRpcResponse` drops it too. Killer test: `the reply reader is the native acceptance predicate` compares the page's verdict against `isRpcResponse` over eleven payloads; six of them were red before this change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): settle the exchange a refused bridge frame was answering A host frame the page's reader refuses was reported and dropped, and the request it answered stayed pending for the life of the document. A screen has no recovery from that: `sendRequest` never settles, so nothing rerenders and nothing retries. The page now salvages the id out of the refused frame, through the same caps the reader applies, and settles it — but only when it already holds that exchange, so a salvaged id reaches nothing the page did not open itself. A request rejects with `BridgeReplyRefusedError`, which carries the refusal and is now marked delivery-unknown at construction: the shell answered, so the desktop has already run the request and a caller told this was a definite failure would offer to retry what already happened. A subscription ends the way a refused `subscribe` ends today. `bridge-rpc-client.ts` was at the 300-line ceiling, so the inbound routing moved to `bridge-client-inbound-frames.ts` and the diagnostic vocabulary, which both sides raise, to `bridge-client-diagnostics.ts`. No `max-lines` disable. Killer tests: `settles the request a reply it could not read was answering, on the same turn` (hung to a 5s timeout before) and `ends the stream an event it could not read belonged to`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name what the bridged replay still excludes, and ratchet the pin The class this harness was landed to name is closed, so the pin says so: `reply-meta-required` is 0 and has no exclusion reason, which is what `divergence-classes.test.ts` now requires of any class the run is allowed to count. `BRIDGED_PARITY_EXCLUSIONS` gives each remaining class the reason it is a bound on the claim rather than a defect, the run prints the excluded total beside those reasons, and one assertion states the whole claim: nothing diverges that no predicate has named. Measured, not argued, for `params-undefined`: all ten scenarios that send an own property valued `undefined` already record the key as absent from the bytes their request put on the wire — `tw-smart-search-all-providers` records `{"filter":"assigned","limit":50}` with `workspaceId` already gone — so the bridged run sends the identical frame. What differs is the object `ScriptedRpcTransport.complete` matches a scenario step against, one level above any serialization. The projection named as the fix is not where it lives: `projectMobileRpcRequestParams` rewrites `worktree.ps` alone, none of the ten calls it, and the bridge host forwards into the same `StableLogicalRpcClient` the native screens hold, so there is no shell-side copy to move. `divergence-evidence.test.ts` asserted the narrow reader that the `_meta` widening removed; it now pins that the counterfactual changes no verdict, which is what makes it a detector for that reader coming back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say the remaining classes total 391 in one line Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read the throw a bridged golden died on, not just the scenario The `params-undefined` arm named a class from two facts that never met: the run threw, and the scenario somewhere scripts a param key valued `undefined`. Any failure inside those ten scenarios was that class, reported by nothing. A seeded wire bug — one extra own key on every request's params — put 627 goldens in `unclassified` and still left `params-undefined` at exactly 33, all corrupted; scoped to `linear.listIssues` alone it stayed green. The evidence now carries the step the scripted transport refused, the paths the scenario values `undefined` on it, and every path where the params that arrived differ from the ones it scripts — read off the frames the page posted, not off the message. The class needs every path that moved to be one of the scripted `undefined` ones, and at least one to have moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): cancel the stream a refused frame belonged to, not just end it Ending a stream deletes the page's record and posts nothing back. That is right for `end` and `error`, where the shell has already retired it, and wrong for a frame this reader refused: the shell is still serving that stream, so later valid frames on the id were dropped with no diagnostic, no ack went back, and the only release left was the host's overflow backstop, which counts unacked frames and so never fires on a stream that has gone quiet. A newer shell adding a member to a closed list — a new `binary.format`, say — lands exactly there. `abandon` posts the cancel and tells the listener; `failExchange` picks it over `end` for the one path where the shell has not let go. Six goldens move to a new named class for what the cancel changes in the replay. The unsubscribe it publishes is a physical payload the native run has no counterpart for, and it takes the recorder's next occurrence name for that method, so the scenario stops matching before there is a recording. Four came from `result-absent-observation` and two from `write-ordinal`; the sum over the corpus is unchanged and nothing stopped replaying byte-identically. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold the bridged pin to the size of the corpus Each class in the pin is an upper bound and `identical` a lower one, so nothing stopped a single class being loosened on its own: raising `params-undefined` by one passed every assertion the flagged run makes. The comment already claimed the total was fixed at the corpus; this counts the goldens on disk and checks it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the two refusals that can settle nothing at all `oversized` is decided on the raw string and `malformed-json` on a parse that did not finish, so neither frame ever yields an id and neither can settle the exchange it was answering. Nothing on the page settles those: `close` or a shell replacement releases the slot, and otherwise it is held for the life of the document. Neither arises from a host that is behaving — it chunks at the frame cap and answers a body over `BRIDGE_MAX_REPLY_BYTES` with an `error` frame — but the boundary was unstated and untested, which is how it reads as covered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say which way each number in the bridged pin may move The comment said the pin moves down and never up, two lines above a class that went from 338 to 341 when a fix changed which difference a run meets first. Both are true of different halves of it, so say which: `identical` only moves up, a class only moves down, and two excluded classes may trade members in one edit that leaves the sum alone. The sum is now checked, so that trade cannot hide a golden that stopped replaying byte-identically. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin which goldens are in a class, not only how many A count is blind to a trade. Every predicate reads the scenario rather than the frame the page refused, so a golden that started refusing for real can walk into an excluded class while another walks out, and the counts, the sum and the `identical` floor all still hold. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
f24f38bd41 |
test(mobile): certify the golden corpus through the page bridge (OTA phase C, C0.5) (#21485)
* test(mobile): record the golden corpus through the page bridge (OTA phase C, C0.5) `ScriptedRpcTransport` gains one seam: an optional `wrapClient`, applied between the recorder's own instrumentation and the logical client, so a recording can be driven through another transport without the runner learning which one. `runRecording` passes it through. The instrumentation moves outside the seam. `ordinal` orders a logical `sendRequest` against the recording's device writes and physical payloads, and the operation makes that call at the same moment with or without a wrapper; stamping it under one times the wrapper's forwarded send instead, which the unwrapped recording has no counterpart for. Measured: 131 goldens move their sender ordinal under the seam, none above it. The name a physical send is filed by therefore becomes a queue rather than one slot, taken on the inside of the wrapper — a wrapper that forwards on a microtask arrives after the next logical call has been made, and one slot hands both sends the second name. Underflow throws; a wrapper that drops a send is a finding. `rpc-recording-through-bridge.test.ts` is the harness: every golden recorded again with `BridgeRpcClient` over an in-memory port pair to a `createBridgeHost` holding the scripted client, compared body for body against the committed file. One FIFO per direction, delivery on a microtask, and the `init` handshake delivered in place before anything mounts. It is opt-in behind `RPC_FOUNDATION_BRIDGE=1` and does not pass yet. 391 of 787 goldens diverge, for four causes that are findings about the bridge rather than about the corpus; the suite's header names each one with its count, and flipping the gate is one line once they close. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): repin the recording corpus and refresh its headers (OTA phase C, C0.5) The C0.5 seam edits `scripted-rpc-transport.ts` and `run-recording.ts`, both inside `RECORDER_DIRECTORY`, so `recorderSha256` moves and every golden's header names an engine that no longer exists. `baseline` moves with it, from |
||
|
|
60a774c30c |
feat(mobile): client operations and dev probe for the desktop-served mobile web bundle (OTA phase A, 5/5) (#21374)
* chore(rpc-contract): provisional catalog entries for the mobile web bundle methods PROVISIONAL, and the only commit on this branch that must not survive the merge as written. `rpc-params-catalog.generated.ts` is generated from the host method registry, and A5's client operations cannot name `mobileWeb.bundle.manifest` or `mobileWeb.bundle.chunk` until A3 registers them: `defineRpcOperation` constrains `method` to `RpcMethodName`, which is `keyof typeof RPC_PARAMS_BY_METHOD`. These two entries are what the generator emits once A3 lands. After merging A3, run `pnpm run generate:rpc-params-catalog` and keep its output, not this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): checked client operations for the desktop-served mobile web bundle Two `defineRpcOperation` descriptors over the A1 contract, both `require-result-or-throw` at `on-settle`: there is no partial success in a bundle read, and a salvage policy would produce a half-bundle that fails a hash check far from the cause. Readers are hoisted `looseObject`s that require only what this client reads, so a later optional member stays a Rule 1 addition for released phones; the host's own schemas stay strict. `dataBase64` is bounded by the contract's chunk size, so a host that overshoots is refused at the boundary rather than at reassembly. `readMobileWebBundleErrorCode` maps the host's six codes out of the thrown `code: message` diagnostic and answers null for everything else. Membership comes from the contract's own enum, which is built from its `hostUnionArms` record, so the arms here cannot drift from the host's union. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): fetch and verify a whole mobile web bundle over the paired connection `fetchMobileWebBundle` reads the manifest, pages every asset at the chunk size the host advertised, and verifies each reassembled asset against the manifest's sha256 before returning it. Nothing is cached and nothing is rendered: this is Phase A's proof that the pipe carries a bundle intact. Four asset reads run at once and no more, because the host refuses the fifth concurrent read on one connection with `mobile_web_bundle_read_limited`; paging inside an asset stays sequential, since the next offset is only known to be wanted once a reply says it is not the last. Every chunk reply restates its build, path and offset and the whole asset's length and hash, and all five are checked. A desktop that auto-updates mid-download answers a later chunk from a different build, and nothing else in the reply says so. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): dev-only troubleshooting row that fetches the mobile web bundle The Phase A proof that the pipe works on a device. Tapping it fetches the whole bundle from the paired desktop and reports the build, asset count, byte count and elapsed time, or the host's error code. `TroubleshootView` gains a `developerRow` slot and the route fills it only when `__DEV__` is true, so a shipped build mounts nothing: no host lookup, no client acquisition, no request. The row reuses the screen's existing button and check-row styles, so it adds no visual vocabulary. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): recording scenarios for the mobile web bundle operations Two families over the real product modules: `mobileWeb.bundle-manifest` drives the manifest descriptor alone, so the loose reader's verdict on one reply is the whole observation, and `mobileWeb.bundle-fetch` drives the paging flow over a two-asset bundle whose entrypoint spans two chunks. The fetch family's state carries the decoded bytes of every asset rather than a count. A reassembly that misplaces a chunk still has the right length, so only the bytes say so. Goldens land with the repin in the next commit: the recorder fences on the pinned tree, and these modules are not in it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the recording corpus and record the mobile web bundle goldens `--record` refuses on any tree but the pinned one, and the pin predates this branch's product modules, so the corpus is repinned to `bbf8264425` — the last commit here to touch a fenced path — and re-recorded whole, the way `rpc-recording/README.md` prescribes for a product change. The delta is the clean one that repin predicts. All 778 existing goldens move exactly one line, `baseline`, and nothing else: no body moved, no other header key moved, none was deleted. Nine are added, two pilot per family plus the five reply matrices the two families derive. The fetch adapter projects its result rather than returning it whole. The result carries a Map of Uint8Arrays, the observation refuses a non-plain object, and the first recording lost the settlement and filed an unhandled rejection in its place. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the fake host's params through a boxed field read The changed-code casting gate refuses the assertion the fake transport used to type its recorded params. Boxing the value the way `settings-read-operations.ts` does reads the same fields with no assertion, and a non-object params reads as absent instead of throwing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus to this branch's last fenced commit The casting fix landed under `mobile/src`, which is a fenced path, so the pin no longer named the tree `--record` runs on. Repinned to `79c3eed6db` and re-recorded. Every golden moves the `baseline` header and nothing else, which is what a repin with no product change is: the edited file is a test, and no recording loads one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mutation evidence that the fetch projection observes the bytes Writes every chunk at offset 0, so a multi-chunk asset reassembles as its last chunk over a zero-filled buffer. The length still matches the manifest, so only the sha256 check and the decoded bytes in the projection can see it, which is what the fetch family's state exists to show. The mutant is killed. `mutants/` is outside every golden digest, so this moves no recording. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop every worker's chunk reads the moment one asset fails `stopped` was read only between assets, so the other three workers paged their asset to the end after the fetch had already rejected: 121 chunk requests where 4 had been issued at the rejection. Each one holds one of the host's four read slots, so an immediate retry was refused with `mobile_web_bundle_read_limited` that only the abandoned workers caused. An internal AbortController now stands beside the caller's signal and is checked before every chunk request, not just between assets. Also pins the entry abort check, the overrun check with real bytes, the measured byte total, and a schema refusal whose message is prose rather than one of the six codes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the code anchor and both operation descriptors `RPC mobile_web_bundle_unavailable failed` separates the anchored reader from an unanchored one; the prose test that claimed to cover it had its first token at index 0, so the anchor was load-bearing and untested. Also pins that a schema refusal, which the dispatcher raises with zod prose before the bundle handler runs, reads as no code, and that both descriptors stay `require-result-or-throw` / `on-settle`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): dial the host on tap in the dev bundle row, and name it Opening Troubleshoot in a dev build acquired a client at mount, which is what kicks a dial, on a screen that opened no connection before. The probe now acquires only once the row is tapped, and each request owns its AbortController so a re-run, an unmount or StrictMode's second mount abandons the previous fetch and stops its chunk reads instead of holding the host's read slots. The screen carries no host parameter and troubleshoots every paired host, so there is no host it is "on": the row still takes the first paired host but now names it in the result instead of implying it speaks for all of them. The label says whether it is still connecting or already fetching. There is no `__DEV__`-conditional `require` idiom in this repo to trim the row out of a release bundle with, which the route now records. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): refresh the recorder corpus counts 397 scenarios, 787 goldens, 790 tests from the README's own three-file command. The 44 salvage goldens are unchanged; only the total they are quoted against moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin and re-record for the mid-asset stop Baseline moves to |
||
|
|
40b2230508 |
test(mobile): typecheck the test files on a ratchet, and pin the reply enums where tsc looks (#21298)
* fix(mobile): move the last six reply-enum pins where tsc looks mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>` coverage record in a schema test is never typechecked: the two that existed (SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four closed enums beside them had only a doc citation of the host type. Each arm list moves into its schema module as hostUnionArms<Union>(), which #21269 introduced for the same reason, and each test iterates the exported list instead of holding its own copy: - SSH_CONNECTION_STATUS to SshConnectionStatus - PROJECT_OWNER_TYPE to GitHubProjectOwnerType - DETAIL_FILE_STATUS to GitHubPRFile['status'] - PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal arms of MobilePushTestResult and MobilePushRegisterResult - SETUP_RUN_POLICIES to SetupRunPolicy openEnum's parameter widens from a non-empty tuple to `readonly string[]` so a hostUnionArms list can feed it. z.enum already accepts the same, so the tuple constraint only excluded callers zod itself takes; behaviour unchanged. Twelve mutations prove the pins: dropping one arm and adding a bogus one each fail mobile tsc in all six places. Zero goldens move, the schemas' behaviour being unchanged, and the 21 recording suites pass at the existing baseline. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fix the type errors in eighteen test files Found by typechecking the tests for the first time (see the config that follows). All mechanical, none weakens a product type: - 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils where act wants void, so each becomes a block. The async ones await only a genuinely promise-returning call, so no extra microtask tick is introduced. - Four fixtures were stale against a product type that gained a required member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError, the branch-compare summary's errorMessage, and SessionOptionDescriptor's transport, which #20884 added precisely so a producer could not inherit the wrong lane's rendering by omission. - `getLastConnectedAt` on the shared relay fake was typed `() => null`, which refused the timestamp two escalation suites assign to it. - Two holders used before assignment take `!`, one `advance!.kind === ...` becomes `advance?.kind`, one widened status arm takes `as const`, and the Expo notification fixture keeps `data` required because the dismissal cases assign through it. 631 test files pass, 6222 tests, unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): typecheck the test files, on a ratchet mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the release bundle, and vitest transpiles without checking types. Nothing had ever typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written in one proved nothing and why 144 of the 630 test files had drifted. tsconfig.test.json is that program with the tests put back, behind `typecheck:tests`. Four files stay out: they import the desktop main process or src/shared/child-process, which are written against @types/node, and this program's libs are React Native's, where setTimeout answers a number rather than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the desktop rather than about mobile; vitest runs those four under Node, which is where they belong. The CI gate is a ratchet rather than the raw typecheck, modelled on check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that set and fails when a file that checks today stops checking, or when a baseline entry starts checking and was not pruned. The list may only shrink. Why not zero: 180 of the remaining 510 errors are one seam — tests locate mocked react-native components by string name, which `ElementType` does not admit — and closing it means either 180 casts or a global JSX declaration for the mocked names. That is a design decision, not a mechanical fix, so it is left for a follow-up rather than made here. The rest are smaller clusters of the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing, and createElement props fixtures. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile-recorder): correct the corpus counts and the salvage claim The oracle section still quoted the corpus as 368 scenarios and 727 goldens; it is 393 and 778, and the three replay suites report 781 tests. Each number now names the command that measures it. "No golden carries one" was the load-bearing error: 44 goldens carry a recorded `reply-salvage` today, starting with the push-test unknown-reason scenario #21176 added for exactly that purpose. The paragraph claimed the observation pins an absence when on those families it pins a recorded drop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the tests-typecheck ratchet's parser The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail under an error. Counting those as filenames would write unparseable entries into the baseline and leave the gate unprunable, so the parser is pinned on that shape as well as on the added/stale diff. Written against the gate itself: it flagged this file before the directive it carried was removed, which is the end-to-end proof the spawn half works. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): await the timer advances the act() rewrite dropped Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a braced body left the returned promise floating at 27 sites, so the advance was no longer ordered before the assertions that follow it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): unshadow MobileHostCard's .tsx suite A wildcard `include` keeps only the higher-priority extension, so MobileHostCard.test.tsx sat outside every tsc program while MobileHostCard.test.ts existed beside it. Its one error is the same react-test-renderer seam its sibling is baselined for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census every test file into the typecheck program The ratchet diffs only files that error, so a test excluded from tsconfig.test.json or shadowed by a sibling extension left the gate silently. Every *.test.ts(x) on disk must now be in the program or named in TESTS_OUTSIDE_PROGRAM with its reason. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(shared): make the enum helpers refuse the ways they can prove nothing openEnum takes a `const` T so a bare literal keeps its arms rather than widening to string. hostUnionArms blocks inference of U with NoInfer and defaults it to never, so a call that omits the host union — where the record would only pin itself — no longer compiles. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): describe the census and correct the baseline count Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the push fixture cast its SAFETY rationale Widening the pre-existing cast made the changed-code gate attribute it as a new finding. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build the push fixtures as typed notifications Replaces the `as unknown as` cast with Expo's own types, filling FirebaseRemoteMessage and its notification once in two builders, and passes the data payload in rather than mutating through an optional member. Typing the fixture showed one assertion comparing the scheduled content against the whole arriving content, which only held while the cast let the fixture omit the two members the presenter drops; it now names the four members the presenter forwards. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the grouped-question advance read non-optional `advance?.kind` let an absent advance take the null-draft branch instead of failing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run the tests-typecheck ratchet on Windows Spawns tsc's JS entry on this Node instead of the node_modules/.bin shim, which is a POSIX shell script that Windows resolves to tsc.CMD and then appends .exe to. Parsed paths are normalised to POSIX so a Windows run does not read every baseline entry as both stale and added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close the ratchet's @ts-nocheck hole and read tsc once tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed" with one line, pruned, and never checked again; the census now names any program test file whose leading comment carries the directive. `--noEmit --listFiles` answers both questions in one pass, so the gate spawns tsc once rather than twice. Corrects the two stale counts, and states hostUnionArms' real reason for living in the schema module now that tests are typechecked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
4a86b2dc56 |
refactor(mobile): checked reply readers for files, dictation, host-screen and agent-history (step 7) (#21269)
* test(mobile): record main's file-preview and markdown-disk-fallback replies
Four of this branch's read sites had no malformed-reply coverage, so the reader
change would have had nothing to move at them. `familyGoldens` matrixes only the
first scenario of each family, and `files.preview-load`'s base is the grant-refresh
chain while `session.tab-documents`' is the served markdown tab — which left
`files.read` and `files.readPreview` on the worktree preview path, the artifact
image read, and the markdown tab's on-disk fallback recorded on their success path
only. This commit is the before picture, taken from main's own tree with no product
edit in it.
Three new families, five scenarios, ten goldens:
- `files.preview-worktree-text` / `files.preview-worktree-image` — `files.read` and
`files.readPreview` as the preview screen asks them for a worktree file.
- `files.preview-artifact-image` — `files.readTerminalArtifactPreview`.
- `session.markdown-disk-fallback` — the `files.read` leg a headless host's
`renderer_unavailable` sends the markdown tab down. It carries a second scenario
that serves `markdown.readTab`, because a matrix site needs a fulfilled reply
recorded somewhere in its own family to replay as the `normal` partition.
No existing scenario moved to a new family and no adapter changed, so every
pre-existing golden keeps its `adapterSha256` and `scenarioSha256`. Recorded in a
detached worktree at the manifest's pin (`4b876758d3`) with this manifest copied in;
the control is that all 748 pre-existing goldens came back byte-identical to
origin/main's, which `git diff
|
||
|
|
6142657d7a |
refactor(mobile): checked reply readers for the tasks domain's board, runtime, search and create (step 7) (#21246)
* test(mobile): record main's agent.launch create receipt before checking it `agent.launch` is the one read site in the tasks domain's project-board, runtime, source-search and workspace create/source files with no recording family at all, so main's answer to a malformed launch receipt was undocumented and a checked reader would have had nothing to move. One family, one scenario, two goldens: `worktree.agent-launch-create` drives `createWorktreeWithNameRetry` down the `agent.launch` arm instead of `worktree.create`, which needs an `agentLaunch` argument on the existing worktree-create-retry adapter. The agent is a constant there on purpose — which agent is picked changes only the params, and the arm under test is which method the create is issued on. A separate family rather than an eighth `worktree.create-retry` scenario: `familyGoldens` drives its reply matrix over the family's FIRST scenario, so adding to that family would have recorded a pilot golden and left the launch receipt with no partitions. As its own base it gets all eleven. Recorded from a detached worktree at the pinned baseline with this branch's `rpc-recording/` and manifest copied in, per the recipe in the recorder README: `mobile/pnpm-lock.yaml` has drifted past `4b876758d3` on main, so `--record` refuses on this branch's tree even though `mobile/src` and `src/shared` are byte-identical to the pin. Thirty-four existing goldens move on `adapterSha256` and nothing else — the six families mounted through the edited adapter module. No body moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): checked reply readers for the tasks domain's board, runtime, search and create Forty-three unchecked reply readers across five files become checked zod readers, so a malformed host reply surfaces as one readable error naming the method instead of a downstream TypeError, a rendered `undefined`, or a screen left ready over garbage. Deliberately a behaviour change on malformed replies only. Five schema modules, each recording the consumer line behind every requirement and the host handler it was checked against: - `task-project-board-reply-schema.ts` — the sixteen `github.project.*` envelopes. Where a consumer reads a member off BOTH arms unguarded the schema is a union on `ok`; where it guards everything (`result.error?.message ?? '…'`, `result.labels ?? []`) it is a flat passthrough and requires only the container, because a requirement on a member the consumer already defaults would refuse a reply main rendered. - `task-runtime-reply-schema.ts` — the hydration reads. The three preference writes read `z.unknown()`: no call site interprets their body. - `task-source-search-reply-schema.ts` — the provider searches and the pasted single-item lookups. The Linear union replaces the hand reader in linear-mobile-issue-read.ts, whose own copy reached the screen unattributed. - `workspace-source-reply-schema.ts` — SSH state, agent detection, orca.yaml hooks, sparse presets and base-ref search. - `workspace-create-reply-schema.ts` — the create receipt, the launch receipt and the hosted-base union. Requirements are exactly the members a consumer reads unguarded AND a recorded golden shows the host sending. That second half is load-bearing: the recorded GitHub search row is `{ number, title }`, the recorded Linear issue is `{ id }`, the recorded project is missing `id`/`url`/`source` and the recorded sparse preset is missing `repoId`/`createdAt`/`updatedAt` — requiring what the shared types declare would have dropped rows main renders. Where the value therefore stays looser than the screen's own state type, the call site keeps one narrowing cast with that reason on it rather than a default that would fabricate state. Two enum decisions, both pinned: - `ownerType` is CLOSED with no fallback. It is echoed into the next `github.project.listViews` params, and remote-wire-compatibility.md rule 4 forbids a reply-schema fallback from shaping a param; the host's own listing handler answers `validation_error` for any other value. - `ssh` `status` is OPEN and degrades to `disconnected`, main's own answer for a state it did not receive. The readiness gate is an equality test against `connected`, so an arm this build has not heard of can never grant a create, and the record survives with its Connect affordance. - Every other host vocabulary a consumer equality-tests — the project view `layout`, the `setupRunPolicy` — stays `z.string()` for the same rule. Tri-states are preserved, not collapsed: the row detail's `reviewDecision`, a work item's `author` and the SSH record's `error` each keep explicit `null` distinct from absent, with a unit pin on each. `blank-workspace-create.test.ts` splits one `it.each` in two. The two create routes now answer a workspace-less reply differently: `agent.launch` still reports "Failed to create workspace", because its reader guards `worktreeId` itself, while `worktree.create` is named as unreadable, because the create screen reads `result.worktree.id` unguarded into the session route. Both reach the same catch; only the sentence changes. `mobile-tasks-refactor-parity.test.ts` moves four hashes and no count. Hooks hold at 350 with 28 bodies edited and no dependency array moved; statements hold at 417 and declarations at 194; `semantics` loses exactly four lines, all four string literals that lived inside the one deleted inline cast type. No method literal and no `rpc:` call signature moves. The inventory loses its five tasks lines; the boundary test stays green. Goldens are refreshed in the next commit, which is where the disclosed behaviour change is proved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin and re-record the corpus over the tasks domain's checked readers Repins `baseline` to |
||
|
|
3e32b83522 |
refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7) (#21176)
* refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7)
Twenty-one unchecked reply readers across thirteen files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`, or
a card left "proven" over a reply that carried no rows. Deliberately a behaviour
change on malformed replies only.
What each domain required, and why it required no more:
- notifications (5 readers). All four call sites read the payload through `?.`,
so every schema is nullish at the top level and no member is required. The
test-push `reason` and the register `reason` become closed enums, because the
two comparisons against them are the whole of what they decide and an arm this
build does not know took the generic copy on main too. The stream unsubscribe
and the unregister read no body at all.
- components (4). `repo.hooks` requires `source` and nothing else: the drawer
assigns it straight into `SetupHookDetails.source`, whose type is
`string | null`, with no guard in between — nullable so the "no hooks file"
answer keeps its explicit null. `setupTrust` is nullable as well as optional
because the `components-setup-ask` fixture sends an explicit null, and
salvaging that would move a `normal` golden. `ui.get`'s trust record salvages
per repo, so one unreadable repo cannot cost the others their approvals. The
Codex redeem reply stays `z.unknown()`: `decodeResetResult` is a real
scope-and-snapshot validator and splitting it would give one reply two refusal
rules.
- terminal (4). The send verdict and the viewport pair keep main's exact
`=== true` projections. `terminalSendAcceptedSchema` moves here from the
session domain, which now re-exports it: terminal is the lower layer and two
identical copies could drift on what "delivered" means.
`terminal-send-rpc-response.ts` is deleted, its projection now being the
schema's.
- transport (3). `status.get` declares its five members and requires the object;
the three callers disagree about what an unreadable status means, so each keeps
its own verdict behind a named reader — the gate wants the failure, and the
probe and the pairing race must not have it, because both call `interpret`
inside a `.then` fulfilment handler where a throw becomes a detached rejection.
`capabilities` salvages whole rather than per element, which is main's own rule
and what `transport-capability-probe-non-string-capabilities-drop` records.
The two pairing readers are the shared credential contract itself, moved off
the four call sites that each ran `.parse()` on the interpreted value; its
`.strict()` is main's shipped rule for that released surface, not a new one.
- home (2), worktree (2), browser (1). The stats row is checked as an object and
nothing more, `totalHomeStats` being the reader that says so itself; its
per-host slot is now typed as the wire row it holds rather than as the computed
total. `worktree.ps` cannot require `worktrees`: the host answers a union whose
unchanged arm carries `{ unchanged, snapshotId }` and no rows. The twelve
browser commands read no body; `browser.goto`'s settled URL stays nullish
because `navigateToAddress` is inline in `MobileBrowserPane.tsx`, which no
adapter mounts, and a move there would ship unevidenced.
Three fixtures were wrong and are corrected, each disclosed rather than worked
around: the runtime-context test kept a content hash directly under a repo key,
which is not a shape `ui.get` sends; and two snapshot-client tests ran their
reply list dry and handed `fetch` an absent result while claiming to model a
transport failure.
`push-test-envelope` is re-anchored at the same defect's new home, the cast
having been deleted. The boundary test's offender floor comes down from 20 to 10
with the list, which is what its own comment says it is for.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the corpus and re-record step 7's checked reply readers
`baseline` moves to this branch's product commit, which is what `--record`
compares the fenced tree against, and every one of the 758 goldens is
re-recorded from it. The repin is what rewrites the `baseline` header on all of
them; nothing else about the corpus moves except the bodies disclosed below.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mutate the workspace catalog's reader back to unchecked
The step-7 defect evidence needs a scenario whose reply is the one the change
moves. Every pilot scenario in the catalog family scripts a well-formed reply, so
a mutant that only changes how a *malformed* reply reads has nowhere to diverge —
which is why the pilot's own suite passed against an unchecked catalog reader
while its matrix golden failed.
`worktree-catalog-snapshot-unreadable` scripts `worktree.ps` answering
`{ ok: true }` with no result at all, which is what `result-absent` drives at the
matrix site, and records the fetch rejecting with `RpcIncompatibleReplyError`.
`worktree-catalog-unchecked-reader` then swaps the operation's reader for one that
answers `compatible: true` for every payload — main's reader, in one line — and
the recording moves back to a fulfilled fetch carrying
`admission: { kind: 'invalid' }`, which is the answer that let a broken catalog
render as an empty host (STA-3123).
One golden added and none moved: the manifest sits outside the fenced paths, the
family's matrix base is still `worktree-catalog-snapshot`, and the mutation
registry is not part of `recorderSha256`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the push-test reason arms the closed enum constrains
`pushDeliveryTestResultSchema.reason` closes over the four arms of the host's
`MobilePushTestResult` (src/shared/mobile-push-contract.ts:99), but no scenario
carried the member, so the corpus could not have caught a wrong vocabulary.
Three scenarios on the existing display-test mount carry it now: the two arms
the screen branches on and one arm no build knows.
Each golden was recorded first at the main pin
|
||
|
|
01a1b6b024 |
refactor(mobile): checked reply readers for the tasks item and list domain (step 7) (#21169)
* refactor(mobile): checked reply readers for the tasks item and list domain (step 7)
Thirty-eight unchecked reply readers across four tasks files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`,
or a sheet left ready over garbage. Deliberately a behaviour change on malformed
replies only; nothing on the wire moves.
mobile-task-item-state-operations.ts 17
mobile-task-item-detail-operations.ts 8
mobile-task-item-comment-operations.ts 7
mobile-task-list-operations.ts 6
Two rules decide every schema, and both are stated in
task-provider-entity-reply-schema.ts:
1. A member is required only where a tasks consumer reads it with no guard.
Everything reached through `?.`, `??` or a `typeof` test stays optional,
because a reply without it rendered the same fallback then and now.
2. No member is required that the site's own recorded `normal` reply lacks. The
corpus is the only evidence of what a host really sends at each site, and
requiring a member absent from that control would turn a good reply into an
incompatible one.
Rule 2 holds two schemas at the container: `github.prFileContents`, whose
recorded reply is `{ oldContent, newContent, truncated }` where
`getPRFileContents` returns `{ original, modified, ... }`, and `gitlab.todos`,
whose recorded row is not a `GitLabTodo` and whose `normal` partition therefore
records main crashing in `actionName.replace`. Both still gain their container,
which is what names a reply that is not an object or not a list. Correcting
those two scenarios is the follow-up that unlocks narrowing the rows.
Nine writes share one envelope reader and five comment writes share another:
`ok === false` and `error` are one host convention across them, and no input
would make two of them want different answers. The acceptance, the name and the
recorded family stay per operation. Three readers are reused rather than
re-declared — the session domain's boolean confirmation for `setPRFileViewed`
and `resolveReviewThread`, and its salvaged-member combinators throughout.
Three call-site shape tests the reader now answers for are deleted: both
`Array.isArray(payload)` guards on the checks read and the
`typeof count === 'number'` fallback on the item count. `GitHubPRFileContents`
is widened to optional members, which is what the reader can promise, and
`buildGitHubPrFileDiffPreview` takes the widened sides — `splitContentLines`
already treated a falsy side as no content, so no runtime behaviour moves.
The tasks source-parity hashes are refreshed: hook, statement, declaration and
render-token counts are unchanged, the render-token hash does not move at all,
and `semantics` is a pure deletion of ten lines.
Inventory: 137 unchecked readers over 30 files becomes 99 over 26.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the RPC recording corpus and re-record the tasks reply deltas
`baseline` moves to
|
||
|
|
5287c5cdbc |
fix(mobile): stop a created tab from jumping when the host snapshot lands (#20069)
* fix(mobile): stop a created tab from jumping when the host snapshot lands
Creating a tab from the mobile session strip painted the new tab at the end
of the strip and then visibly jumped it to a different slot a beat later.
The client asked the host to insert the tab after the active tab, but then
predicted a different placement for its own optimistic paint:
afterTabId: activeSessionTabId ?? undefined // host: splice(insertAfter + 1)
...
return [...prev, { ...created, isActive: true }] // client: append
Two independent placements that disagree, so the optimistic frame is wrong by
construction and the tab snaps to its real slot on the next published snapshot.
The disagreement dates to
|
||
|
|
4b876758d3 |
refactor(mobile): checked reply readers for the session domain (step 7) (#21089)
* test(mobile): record main's session reply behaviour at every unrecorded read site Step 7 for the session domain changes how 51 RPC readers read a *malformed* reply. Eleven of the session read sites had no recording family, so main's answer to a malformed reply at those sites was undocumented and the reader change would have had nothing to move. This commit is the before picture, taken from main's own tree with no product edit in it. Ten new families, twelve scenarios, twenty-five goldens: - `session.review-file-diff` / `session.review-branch-diff` — `git.diff` and `git.branchDiff` read through the review projection, which the Changes screen's verbatim readers do not cover. - `session.review-git-mutations` — the single-file `git.stage` / `git.discard` and the bulk stage sweep's second `git.stage`. - `session.review-send-sheet` — `session.tabs.list` read for the agent terminals the send sheet lists, the third reader on that method. Needs an `open-send-sheet` action on the review-action adapter, which re-digests that family's eight goldens on `adapterSha256` and nothing else. - `session.browser-tab-create` — `browser.tabCreate`. - `agentSession.structured-create` — `agentSession.create`, whose family base only ever covered the support probe. - `session.tab-rename` / `session.tab-close-session` — `terminal.rename` and `session.tabs.close`. - `settings.new-tab-local-agents` — `preflight.detectAgents`, the arm the new-tab loader takes for a workspace with no connection. `baseline` is repinned to main's tip because two commits (#20659, #21004) touched a fenced path after the pilot's pin, so `--record` refuses on main's own tree until it moves. The repin is what rewrites `baseline` on all 705 existing goldens; nothing else about them moves. Decoded against origin/main through the value pool: 705 header-only (`baseline` on every one, `adapterSha256` on the eight review-action goldens), 0 body-moved, 25 added, 0 deleted. Not covered, with the reason: the chunked clipboard upload's `appendImageUploadChunk`, `commitImageUpload` and `abortImageUpload` cannot be matrixed, because `replyMatrixSites` takes every completion in the base scenario and the chain's later params carry the `uploadId` the start reply named. Driving `clipboard.startImageUpload#1` therefore makes main send an append whose params no scripted step matches, and the recorder raises `Request params mismatch: clipboard.appendImageUploadChunk#1` instead of recording. The two families were written, probed and removed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): checked reply readers for the session domain (step 7) Fifty-one unchecked reply readers across nine files become checked zod readers, so a malformed host reply surfaces as one readable error at the operation boundary instead of a downstream `TypeError`, a rendered `undefined`, or a screen left ready over garbage. Deliberately a behaviour change on malformed replies only. Eight schema modules, one per reply family, each recording the consumer line behind every requirement and the host handler that publishes it: - `clipboard-image-reply-schema.ts` — the upload slot's `uploadId`, the commit and single-frame path strings, and the two legs whose body nothing reads. - `github-pr-mutation-reply-schema.ts` — the `{ ok, error }` status envelope as two variants, and the bare-boolean confirmation. - `github-pr-entity-reply-schema.ts` / `github-pr-read-reply-schema.ts` — the seven PR sidebar reads. Every identity requirement the hand parsers had is kept, so a payload that degraded to null still degrades to null; what changes is a payload that is not the declared container at all. - `diff-review-reply-schema.ts` — the normalized branch compare, the review notes on the worktree record, the three file-diff arms, and the file-level git mutations. - `review-terminal-reply-schema.ts`, `session-launch-reply-schema.ts`, `session-read-reply-schema.ts`, `session-write-reply-schema.ts` — the review send sheet, the launch paths, the session screen's reads and its writes. Requirements are exactly the members a consumer reads unguarded, everything else is a salvaged optional with main's own default applied in the transform, and no schema is `.strict()`: a member a newer host adds passes through untouched. Enum arm sets that a reader compares against pass through or degrade to the arm the reader handles most conservatively; the two closed sets — the committed change status and the diff kind — are closed because main *dropped* an arm it did not know rather than passing it through, and degrading them would draw a row or render a diff main never did. No member is coerced on the way back to the host. `github-pr-parsers.ts`, `github-pr-comment-parsers.ts` and `github-pr-value-readers.ts` are gone; their suite is now the parity record for the schemas that replaced them, with the four cases that refuse rather than degrade marked as such. Twelve call-site casts are deleted, and three dead "response was invalid" branches with them: the reader refuses those replies now, so the error names its method. The nine session files come off `unchecked-rpc-reader-inventory.ts` entirely rather than being lowered. `git show --stat` on this commit touches nothing under `mobile/rpc-foundation`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): unit-pin every session reply schema's decision Three kinds of case, one per kind of decision the schemas encode: a member a consumer reads unguarded is required and its absence refuses, an arm set a reader compares against degrades to the arm that reader handles most conservatively, and a reply whose arms need different members is declared as variants and each arm is read. The last suite is the wire-compatibility claim: a member no reader knows passes straight through, on the markdown document, the upload slot and the terminal inventory alike, so a newer host is never refused for a field mobile does not read. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refresh the corpus for the session domain's checked readers Repins `baseline` to the last commit touching a fenced path and re-records all 730 goldens, which is the disclosed behaviour change taken as an observation. Decoded through the value pool against the pre-refactor tree on this branch: 688 header-only with `baseline` the only key that moved, 42 body-moved, 0 added, 0 deleted. The 42 are seven named scenarios and thirty-five matrix goldens, and every moved checkpoint's own reply is malformed or refused. Three `normal` partitions appear in the list and none of them reads a well-formed reply differently: the review file-diff family's base scenario drives three legs and its third is scripted `{ kind: 'unknown' }`, so that leg's checkpoint moves in every variant, the varied leg included. The same append-only-history effect puts `pr-read-upstream-error`'s `no-pr` checkpoint in the list for the malformed PR recorded before it. What the corpus now records, in one sentence: a property read on null, a V8 destructuring message shown to the user, and four hand-written "response was invalid" strings are replaced by one message that names the method, and four screens that published a malformed payload as ready state now show an error instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): split the expanded check run out of the PR read schemas `github-pr-read-reply-schema.ts` was 328 code lines against the 300-line cap. The expanded check run and the annotations, jobs and steps listed under it are one reply with no reader in common with the other six, so they move to `github-pr-check-reply-schema.ts` whole. A move, not an edit: no schema changes and no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus to the branch's last fenced-path commit The schema-module split touched `mobile/src`, so `--record` refuses on the pin the previous refresh left behind. Repins to that commit and re-records. Decoded against the previous corpus: 730 header-only with `baseline` the only key that moved, 0 body-moved, 0 added, 0 deleted — the split is a move, and the corpus says so. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the worktree display-name cast's type import The live-title read is typed by its schema now, so the cast it annotated is gone and the import it needed with it. oxlint flags the leftover. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus to the branch tip The unused-import removal touched a fenced path, so the pin moves with it. Decoded against the previous corpus: 730 header-only on `baseline` alone, 0 body-moved, 0 added, 0 deleted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): contain a refused prChecks reply to the checks section The checks read was the one phase-1 dependency that could take the whole PR sidebar down. `loadPrSidebarData` routed `!checksOutcome.ok` through `failureState`, so a host whose `github.prChecks` shape drifted cost the user the title, body, comments, reviewers and merge controls — everything they opened the sidebar for — over a section that renders a row of icons. Main never noticed because its unchecked reader answered `[]` for the same reply; this branch's reader refuses it, which is correct, and which is what makes the containment necessary. Contained the way phase 2 already is: a failed read keeps `kind: 'ready'`, empties `checks`, and carries the message in a new `checksError` so the checks section can say what happened. The sidebar can no longer reach `error` or `blocked` on the checks read alone. Also pins the enum departure this PR makes deliberately. The degrading arm sets go through `salvagedOptional(name, z.enum(...))` rather than `openEnum` because `openEnum` refuses a non-string where main mapped it to the conservative arm; nothing held that, and all 2477 tests stayed green against the swap. Six cases now hold both halves: a non-string degrades on the three open sets, and an unknown arm drops the row on the closed ones. Four deletions the reviewer found: a reaction-token alias with no importers, the `errorType`/`fetchedAt` the branch-lookup reader fabricated to satisfy a type whose only consumer reads neither, two bare schema aliases, and a quick-commands pass-through with two callers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus to the containment commit `--record` refuses unless the product tree equals `baseline`, so the fix above moves the pin. The corpus re-recorded in place against it: 730 goldens, every one header-only on `baseline`, no observation moved. No observation moved because no family reaches the code the fix changed. The `github.pr-read` family calls the seven wrapper reads directly and records their `{ ok, error }` outcomes; `loadPrSidebarData` sits a layer above that and no scenario mounts it. The prChecks outcome is identical before and after — what changed is what the sidebar does with it — so the unit suite is the only oracle for the containment. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the PR sidebar's checks containment The containment landed with no golden: no scenario mounted `loadPrSidebarData`, so the row in the delta table rested on unit tests alone. `PrSidebarLoadDeps` is five client-taking functions, so a new adapter drives phase 1 directly and records the `PrSidebarState` it resolves to — no React host, and no edit to an existing adapter, so no recorded golden moves. Two scenarios: a normal load, and one whose checks leg answers a shape the reader refuses. The matrix over the base then drives all eleven partitions at `github.prChecks#1`, and every one of them records `ready` with a `checksError` where main took the whole sidebar to `error`. `pr-sidebar-checks-failure-state` is the mutant that routes the refusal back through `failureState`; it moves both `pr-sidebar-checks-refused` and the prChecks matrix golden. Also pins two closed-and-required enum decisions that were free to become defaults — an unknown check-summary state drops the summary block, an unknown reaction content drops the reaction — deletes four exported type aliases and five enum constants with no reader outside their own file, makes `PRChecksSection`'s `checksError` required so a second caller cannot silently lose the message, and stops the header reading "No checks" when the checks were unreadable rather than absent. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus to the pr-sidebar family commit Six new goldens — two pilots and the four matrix sites the base scenario scripts — and `baseline` on the 730 that already existed. No body moved and no `adapterSha256`: the family is a new adapter module, so nothing recorded through another one re-digests. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the corpus against the merged main Repins `baseline` to the merge commit and re-records all 736 goldens in place. Against `origin/main` the 705 shared goldens move only on `baseline` (672 of them header-only), leaving the same 33 body moves and the same partitions the branch carried before the merge, plus its 31 added goldens. Every body also takes main's recorder shape from #21088: `sent` becomes `ordinal` over one interleaved write counter, subscriptions record a cleanup checkpoint, and a salvaging read now reports a `reply-salvage` effect naming what it dropped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep an explicit null on the two tri-state PR flags `autoMergeAllowed` and `mergeQueueRequired` carry three answers, not two: `null` is GitHub saying auto-merge is not allowed, `undefined` is the host not carrying the member at all. The readers coalesced the null away, so a well-formed reply read differently from the parsers they replaced, which preserved it explicitly. Both shared types already declare `boolean | null`. No consumer separates the two today — `pull-request-auto-merge-availability` compares with `=== true` and `!== false` — so this is parity, not a visible fix, which is exactly why it needed a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the corpus to the tri-state flag commit All 736 goldens move on `baseline` alone: no scenario scripts an explicit null on either flag, so preserving it changes no recorded screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): check the two session-write readers #21083 brought Step 7 empties the session block of the unchecked-reader inventory, and #21083 landed two readers into it after that: the New Tab create's member read of `tab`, and the display-mode toggle's payload. Converting them is what keeps the claim true — a session line reappearing would mean the domain is not migrated. `created-terminal-tab` requires `tab.id` and `tab.type === 'terminal'`, because the strip keys the new tab on the id and spreads the rest into a union whose arm `type` picks. `terminal`, `title` and `terminalTheme` stay optional behind main's own guards, and unknown members pass through. `terminal-display-mode-set` reads nothing, so it takes the same `z.unknown()` the other five unread writes take. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin and re-record over #21083's corpus All 736 goldens this branch already had move on `baseline` alone, and #21083's 22 arrive beside them. One of the 22 moves against main's own recording: `matrix-session.create-terminal-session.tabs.createterminal-1`, where the New Tab create's five malformed partitions read `Cannot read properties of undefined (reading 'tab')` and now read the method's own message. Two of them also stop unsubscribing the terminal the user was watching before the property read threw, so a create that never happened no longer costs the live pane its subscription. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say what carries a refused create reply to the catch Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
ccb4d2044b |
refactor(mobile): send the last session-route raw-port calls as operations (step 6, migration 2) (#21083)
* test(mobile): record the session startup, create and display-mode families
Three mount adapters and ten scenarios for the last raw-port sends in the
session route, recorded at the pinned main baseline before any product edit.
The three hooks were listed as blocked on a WebView-ref substitute. They are
not: none imports the terminal WebView, and all three send with no ref. The
display-mode toggle reads a `{cols, rows}` cell and a device-token cell; the
create path calls scope callbacks; the startup effect drives scope callbacks
only. Each stub is an effect sink, shapes no param and swallows no throw.
One scenario reaches both `worktree.activate` sites the way the product does:
the auto-create clears `created` off the route, the effect re-runs on the same
mount and takes the other branch, so the reply matrix drives both.
The create adapter mounts in its factory rather than as a scripted step. React
draws one `Math.random()` lazily the first time `enqueueTask` runs, and the
runner flushes through `await act` after every step, so a scripted mount would
make `clientMutationId` the second draw of the seeded sequence on the first
recording in a process and the first on every later one. The two determinism
runs caught it.
Recorded through the pinned-baseline worktree recipe, because main has moved
past `a28085adbf` in `src/shared` and this branch does not repin. 705 existing
goldens byte-identical, 15 added, 0 moved, 0 deleted.
Mutation census against the raw-port code, applied and reverted by hand, all
twelve killed: wrong method at each of the four sites; acceptance verdict
swapped at each of the four verdict-reading sites; dropped `unsubscribeTerminal`
on replace; the two activation branches swapped; a delayed `fetchTerminals` pass
dropped; the viewport pair not forwarded on `terminal.setDisplayMode`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): send the last session-route raw-port calls as operations
Four references, three files, no behaviour change. Proven by replay: the
fifteen goldens recorded at the pin before this commit pass unchanged, so no
re-record.
- `use-mobile-session-startup.ts` both `worktree.activate` sends reuse
host-screen's `worktreeActivate`. Its skip verdict was never read before;
the startup effect is its first reader, and it reads exactly what main read
off the envelope — whether an accepted reply says the host is headless.
- `use-mobile-session-terminal-create-actions.ts` `session.tabs.createTerminal`
gets `sessionTabCreateTerminal`, a single-reader operation beside the other
session-screen writes. `require-result-or-throw-message` replaces the
`if (response.ok)` branch because the throw lands in the catch that already
reported the host's message, character for character, including the empty
message falling back to the screen's own copy. The reader stays the unguarded
`.tab` read, because that policy rethrows a reader's exception rather than
converting it, which is what keeps a null or absent result failing where it
failed before.
- `use-mobile-session-terminal-stream-display.ts` `terminal.setDisplayMode` gets
`terminalDisplayModeSet`, a skip whose verdict the caller does not read, the
way `terminalBufferClear` already works: the server does the resize and
reports it on the terminal's existing subscription, so main looked at nothing
in the envelope and only a transport rejection was ever a failure.
The prompt `terminal.send` in the create path stays on the raw port. It is the
only `terminal.send` caller that falls back to its own copy when the host
refuses with an empty message, so no existing operation carries its acceptance
and a new one is a fourth method outside this migration's scope. It is recorded
either way.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): lower the raw-port inventory and refresh the session route pins
Pending raw-port inventory: two entries deleted and one lowered, 12 files / 21
references to 10 / 17. The startup and display-mode entries reach zero; the
create entry keeps the prompt `terminal.send` and states its own reason.
Three stale comments corrected. The startup, create and display-mode entries
claimed a WebView-ref or subscription wall that measurement did not find: none
of the three hooks imports the terminal WebView, the display-mode write is not
gated on an open subscription, and the create path's `subscribeToTerminal` is a
scope callback rather than a `client.subscribe`. The accounts screen's entry
said the runner is request-only, which stopped being true when `ScenarioStep`
gained `frame`; what actually blocks it is that no scenario has been written for
`accounts.subscribe`, so its entry now says that instead.
Unchecked-reader inventory: `mobile-session-write-operations.ts` 8 to 10 for the
two readers the migration added, named in the header the way #20954's three are.
Route parity: four pins refreshed with their reasons — the callback bodies for
the display-mode toggle, the effects for the startup activation pair, the nested
function bodies for the create, and the runtime strings, whose count falls 535 to
531 as four more method literals move to their operations' definitions. The
startup source pins now name `worktreeActivate` and still hold what they held:
the plain activation is fired rather than awaited, and it goes out before the tab
load.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say which part of the display-mode operation no golden holds
Post-refactor census survivor, measured rather than assumed: swapping
`terminalDisplayModeSet`'s acceptance for `require-result-or-throw-message`
moves none of the fifteen goldens. The call site reads no verdict and its own
`catch` swallows a throw either way, so no policy is observable there. The
method, the params and the viewport pair are what the goldens hold at that site.
The six other operation-level mutations all kill.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record the empty cells the session guards are written for
Three session sends are gated on a cell every existing scenario filled: the
display-mode toggle carries `viewport` only once a surface has measured one and
`client` only once the phone holds a device token, and the startup sequence
swallows a refused tab load before loading terminals behind it. Every recording
declared those cells full, so the arm each guard exists for was never on the
wire and dropping the guard moved no golden.
The two device cells become scenario arguments rather than adapter constants, so
a scenario can declare them empty; the tab load may now be declared to reject,
which is the only way a refused scope callback is reachable at all. Declared, not
shaped: the stubs build no param and swallow no throw.
Three scenarios take the empty arm. The token and viewport ones send `auto`,
which is the direction both members ride, and the startup one records that the
terminal loads and the activation timer still run behind a refused tab load.
Recorded at the pinned baseline through the detached-pin worktree recipe, since
this branch may not repin. 705 goldens identical, 0 body moved, 3 added, 0
deleted; the 15 header-only moves are `adapterSha256` on the three edited
families and `scenarioSha256` on the four scenarios that now declare their token.
The create adapter's determinism comment now names the draw it works around:
React's lazy `("require" + Math.random())` in `enqueueTask`, the scheduler line
that seeds the sequence, and the mismatch a misplaced mount reports. #21088
retires the workaround.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): witness the three session guards the recordings had not pinned
Each mutation is the guard deleted: the display-mode send carries `client` with
an empty id, carries `viewport` before anything measured one, and the startup
sequence lets a refused tab load reject it so the terminal loads and activation
timer behind it never run. All three survived the whole suite before the
scenarios above; the witness asserts each is killed by its scenario and that
every other scenario of the same family still cannot see it.
A mutation that changes a param the scenario completes aborts at the transport's
params assertion instead of producing a divergent recording. That is the
scenario detecting it, so the witness reads that one message as a kill, narrowed
to it and taken only after the anchor is proved applied.
The README gains the class as its fifth bounding fact: a value an adapter holds
as a constant is a cell no scenario can empty, so the arm that reads it empty is
unreachable until the constant becomes an argument. Corpus counts refreshed to
what the suite measures.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the terminal-create result type nothing reads
`TerminalCreateResult` wrapped the created tab for the old `sendRequest` reply
shape. The migrated call site reads the tab off the operation and names the tab
type directly, leaving the wrapper with zero readers repo-wide. Using it at the
cast site would have kept the cast and only renamed it, so it goes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): let the create scenarios declare what the create puts on the wire
The terminal-create adapter decided four of the members its own goldens hold:
the worktree, the tab a new one is inserted after, and every launch option but
the prompt and its two toasts. A value an adapter supplies itself is a cell no
scenario can empty, so `afterTabId`'s omission arm — the arm a fresh session and
a last-tab close both take — was unreachable, and the quick-command members were
recorded only as absent. All of it now comes from the scenario, and the mount
moves to the first action so the arguments are in place before the hook reads
them. It stays out of a scripted mount step for the determinism reason above it.
Four scenarios follow the new arguments: a create with no active tab, a shell
quick command, an agent quick command, and a second tap while the host is still
answering the first. The refused scenario stops declaring an `errorToast` the
adapter dropped: forwarding the toast independently of the prompt is what the
product does, so that golden now records the failure toast it always showed.
Recorded at the branch's pin, so 705 goldens stay byte-identical to the merge
base; five headers move on adapter and scenario digests and one body moves, the
refused create's new toast effect.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): witness the three create guards the recordings had not pinned
Each of the three new create scenarios closes a mutation that survived all 853
tests before it: putting the active tab on the wire as `null` instead of
omitting it, swapping the `command` and `agentPrompt` members the host reads,
and dropping the in-flight guard so a second tap opens a terminal nobody asked
for. The witness asserts the hole and the closure together, as the others do.
The params-mismatch abort the witness reads as a kill now rests on an assertion
rather than on an argument: no scenario in the manifest completes a request
after its last checkpoint, so a send whose params stopped matching always
suppressed an observation a golden holds.
Known-open holes loses its prose count and becomes a list that names the site,
the mutant and why no scenario can see it. Two entries join it: the display-mode
acceptance, which no call site reads, and the startup timer's attached-terminal
guard, which needs an adapter that can attach a terminal mid-scenario.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): interpret the activation reply where it is reported
`reportActivationOutcome` took a verdict, which left the timer site hand-building
`{ accepted: false }` for the case where there is no reply to interpret at all.
Taking `RpcResponse | null` and interpreting inside puts the operation's own
policy at both sites and spells the absent reply as absence. Nothing is lost:
`worktreeActivate` reads an unchecked payload and admits every success, so its
`interpret` cannot throw on a reply either site can receive.
No golden moves; the effect digest is repinned.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give the create family its mount step back
The create adapter mounted inside its first action so the create would run
ahead of the flush that made React pay its one lazy `Math.random()` draw.
#21088 pays that draw in the scheduler before it installs the seed, so the
position of the mount no longer decides which seeded value `clientMutationId`
reads, and the family goes back to the shape every other one uses: a declared
`mount` step carrying the cells the hook reads as it renders — the worktree,
the active tab, the device token — and a `create` step carrying the launch
options it passes.
The display-mode family keeps mounting from its `mount` action, which is that
same declared shape and never was the workaround.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the corpus at main's pin
|
||
|
|
9add08bb59 |
test(mobile): recorder follow-ups — write ordinal, teardown streams, context anchor, salvage observation, provider pass-through, React draw (#21088)
* refactor(mobile-recorder): one shared write ordinal for requests, payloads and effects
`sent` stamped each payload and effect with the number of requests sent at
write time, which orders those two lists against sends but never against each
other. A family that sends no requests therefore had every stamp at `0`:
moving `host-worktree-refresh.ts`'s two initial snapshot reads from after
`client.subscribe` to before it moved none of the 705 goldens.
One monotonic counter per recording now stamps requests, payloads and effects
alike at the moment each is written, so the three append-only lists are ordered
against each other. The same reorder now fails five goldens. A request is
stamped at the logical `sendRequest` call rather than when its physical payload
is published, so a send that waited for connected carries two distinct stamps.
Full re-record from the pinned baseline: 699 bodies moved, 6 header-only,
0 added, 0 deleted; the only moved JSON paths are `sent` leaving and `ordinal`
arriving on `sender`, `payloads` and `effects`. Decoding with those two fields
stripped leaves all 705 header-only.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile-recorder): observe streams still registered at teardown
Closing a stream only writes to the wire when its method has an unsubscribe
builder. `notifications.subscribe` has none, so a cleanup that forgets its
local `unsubscribeStream()` leaks a live registry record and nothing on the
wire changes. Until now that class was covered by one hand-written scenario
per method, which stops the stream and cuts over so the leak reappears as a
second subscribe payload.
Teardown now asks each session's `RpcClientStreamRegistry` what it still holds,
after the product's cleanup and before the transport disposes it, and records a
non-empty answer as a `streams-registered-at-teardown` effect carrying each
stream's method, subscribe payload and cancelled flag. The set is read off the
registry's own map: a mirror kept by the recorder would reproduce the product's
bookkeeping rather than observe it. Deleting `unsubscribeStream()` from
`mobile-notifications.ts` fails 7 goldens now, against 1 before.
Re-record: 4 bodies moved, 701 header-only, 0 added, 0 deleted. All four are
the two `runtime.clientEvents.subscribe` matrices, on partitions whose subscribe
reply is not a well-formed `ready`: with no subscription id to unsubscribe with,
the registry deliberately holds the cancelled record, which is why the
observation carries `cancelled`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-recorder): one host-client context exposure, anchored on the product source
Five adapter modules each carried `exports.recorderHostClientContext = Ctx;`
inside a source string appended to `client-context.tsx`. `Ctx` is a
module-private local, so the reference lives in a string no type checker
follows: renaming it typechecks clean and fails a recording with a
`ReferenceError` a hundred seconds in, five times over.
`hostClientContextExposure` and `loadHostClientContext` are the one copy, and
`adapter-seam.test.ts` asserts the declaration the exposure names still exists
exactly once in `client-context.tsx` and refuses a sixth inline copy. A rename
remains invisible to `tsc` — nothing but editing the fenced product module
makes a private local checkable — so the anchor is what turns it into one
failure that says what moved.
Also splits the subscription tests out of `recording-runner.test.ts`, which
items 1 and 2 had pushed past `max-lines`.
Re-record: 705 header-only, 0 bodies moved, 0 added, 0 deleted; `recorderSha256`
on all 705 and `adapterSha256` on the 23 goldens mounted through the five
modules.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile-recorder): record what a checked read salvaged
`collectSalvageDrops` builds a report on every decoded reply — which array
elements a `salvagingArray` threw away, which members a `salvagedOptional`
read as absent — and `classifyRpcReply` puts it on the outcome, where nothing
reads it. Which rows a reply lost was therefore visible nowhere, including in
a golden.
The recorder wraps `classifyRpcReply` on the mounted module, the one seam every
checked read passes through and the only one that knows the operation the drop
happened under, and records a non-empty report as a `reply-salvage` effect. No
product code changes; the report was already being built and discarded.
No golden carries one. All 19,384 checked reads in the corpus decode their reply
whole, because the reply matrix varies the envelope a host sends rather than the
shape of a row inside a result. The observation pins that absence, and moves the
first time a narrowed element or member schema drops a recorded row — including
where nothing downstream reads it. `salvage-observation.test.ts` is what keeps
the observation honest, driving a malformed row and a malformed optional through
the real `git.status` reply schema.
Re-record: 705 header-only on `recorderSha256`, 0 bodies moved, 0 added,
0 deleted.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(source-control): let hostedReview.create carry a provider token this build does not list
`HostedReviewCreate.provider` was a closed `z.enum`, so a client repeating back
a provider a newer host named in its own eligibility reply had its create
rejected at params validation. Mobile worked around it with a SAFETY-annotated
assertion: narrowing to `'unsupported'` before sending would have made the host
refuse its own provider, so the token was cast through instead.
The schema member is now `z.string()`, and both create handlers narrow through
`supportsHostedReviewCreation` before calling the runtime, so an arm this build
does not know answers `unsupported_provider` with readable copy rather than a
params error the client cannot act on. `createHostedReview`'s own refusal is
the single source of that copy. The mobile assertion is deleted.
Product change on a fenced path, so the goldens are not re-recorded: the whole
recording suite replays green against the corpus committed in the previous
commit, 825 passed, zero golden movement.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(source-control): annotate the runtime stub cast in the provider refusal test
The changed-code quality gate counts a new `as unknown as OrcaRuntimeService`
as a finding. A narrower stand-in does not exist: the interface has 1047
members and `Pick` of the three this test uses is not assignable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-recorder): pay React's lazy Math.random draw before the seeded run
React resolves `enqueueTask` by reading `module['require' + Math.random()]` and memoizes the
result, so a process draws exactly one `Math.random()` the first time it awaits `act`. The runner
drains through `act` after every step, so that draw landed inside whichever recording ran first and
ate the seeded sequence's first value: a family recording a `Math.random()`-derived param recorded
one value when it ran alone and a different one when it ran after any other family, and an adapter
could only dodge it by drawing in its factory ahead of the first drain.
The scheduler now pays that draw once per process, before it installs the seeded generator, so the
seeded sequence starts at the same value for every recording. Priming is awaited, which makes
`start` async.
Goldens re-recorded: 705 header-only, `recorderSha256` alone. No golden carried a first-in-process
value, so nothing moved in a body.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-recorder): drain before reading the streams left at teardown
The teardown observation read the registry after `dispose()` returned but before the scheduler
drained, so a cleanup that closes its stream on a due 0ms timer had not run yet and was recorded as
an uncancelled registration — the one shape this observation reserves for a cleanup that never ran.
A deferred close and a stream nobody ever closed were byte-identical.
The drain now runs before the read, with the transport still disposed after it. A second drain stays
after disposal: tearing the registries down rejects what the product still awaited, and an unhandled
rejection is an effect the cleanup checkpoint has to see.
Also: the registry size comparison in `registeredStreams()` could never fire, because `size()`
returns `this.streams.size` on the same object; `RECORDER_HOST_CLIENT_CONTEXT` is used only in its
own module and no longer exported; and `streamPayloads` now says what it holds, which is every frame
the registry publishes rather than only subscribes.
Goldens are stale in this commit and are re-recorded in the next one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the corpus after the baseline repin and the teardown drain
Recorded from a detached worktree pinned at
|
||
|
|
97aa5ff19b |
fix(mobile): open native chat when a new worktree launches a default agent (#19850)
* refactor(agent-launch): make the launch-mode decision surface-neutral
`decideWorkerStartMode` was the only shared answer to "structured chat session
or terminal agent?", but it lived in an orchestration-named module and spoke
orchestration's vocabulary, so the other launch surfaces could not call it.
Move the decision to `main/agent-launch/agent-launch-mode` unchanged and leave
`orchestration-worker-start-mode` as the adapter that supplies the noun.
A worker is not a special kind of launch; it is the same launch with a dispatch
attached. Naming the receipt's subject is the only thing orchestration actually
contributed, so that is the only thing the adapter keeps: "worker" in both
sentences, plus the `--terminal` wording, which reads as nonsense anywhere a
`--terminal` flag does not exist. Both are pinned, because they are asserted.
No behavior change. The receipts are byte-identical for every reachable case,
proven by running the new pin against both implementations.
Also pins the wording, which nothing was holding. The existing suites assert
`toContain` fragments ('terminal agent', 'cannot create') and the CLI suite
asserts a receipt handed to it by a mock rather than one this code produced;
all six files stayed green against a deliberately corrupted vocabulary. A
dispatch receipt is the only place a structured-to-terminal downgrade explains
itself, so the whole sentence is the contract, not a fragment of it.
* feat(agent-launch): add the launch intent and the one executor that runs it
The sequencing around the launch decision was duplicated per surface, and the
duplicate is where the bug lives. A new worktree was created agent-first, so
its startup terminal WAS the agent and the structured branch below it could
never be reached — every new-worktree launch was a PTY regardless of the user's
default. Orchestration fixed that for itself in #19431; mobile and the CLI
still have it.
`executeAgentLaunch` inverts the order once, for everyone. When the preference
is structured the worktree is created with NO startup agent, the executing host
is then asked whether it can host a session for the workspace that now exists,
and only then is a surface created. The host verdict cannot be hoisted above
creation: `agentSession.createSupport` only answers for a workspace it can
resolve, which is why the decision stays in two halves.
Agent-first creation is deliberately preserved for PTY launches — it is what
sequences the agent's startup command behind the setup runner, so wait-for-setup
comes for free there.
What actually differs per surface is only how a surface is built (an
orchestration worker's session takes a dispatch hold and a mailbox a plain
launch must not take), so that is injected as a factory rather than branched on.
The intent also strips the reserved agent fields from a migrated create payload:
a caller moving off `worktree.create` passes its existing params, and a stale
`startupAgent` in there would re-create the very path this replaces.
Tests assert order and arguments, not just the resulting mode. Reintroducing
agent-first creation reddens 4 of 11.
* feat(agent-launch): expose the launch executor as the agent.launch RPC
Adds `agent.launch` — one host-side method that decides structured-vs-terminal and
creates the surface — wired to the real runtime factories: `createManagedWorktree`
for the workspace, forking on `startupAgent` exactly as the orchestration worker
path does; `createStructuredAgentSessionForWorktree` for a chat session; and
`createTerminal` for a PTY agent. Allowlisted for mobile, which is the surface the
routing gap was reported on.
`worktree.create` is untouched. Its `startupAgent` keeps meaning "spawn a PTY agent"
verbatim, because it answers with `agentTerminalHandle` only on that path: a host
that quietly routed it to a structured session would hand every older client a
response with no handle and no error. All new behaviour sits behind
`agent.launch.v1`, which the host now advertises and a remote client must negotiate,
so a client that does not gets today's behaviour unchanged.
* feat(mobile): route workspace creates through agent.launch
Picking an agent on the mobile create sheet always produced a terminal, even
when the user's default was native chat, because all three create paths put
`startupAgent` on `worktree.create`. That means "create the worktree
agent-first", so its startup terminal IS the agent and the structured branch
below it is unreachable — while the same phone's in-workspace "+" button opened
a chat.
The blank, branch and new-branch creates now send the same payload through
`agent.launch` and let the host settle the surface. `worktree.create` is
untouched, and a host that does not advertise `agent.launch.v1` (read from the
existing `status.get` probe) keeps today's path exactly.
Work-item creates stay on `worktree.create`: they pre-fill the issue/PR URL as
an unsent `startupDraft`, which a structured session cannot hold yet, so routing
them would submit the URL as a first turn.
* fix(agent-launch): drop the deleted draft-prompt blocker from the reason map
main removed the draft-prompt blocker in #19681 (a structured session now holds
an unsent draft), so the exhaustive Record no longer typechecks.
* chore(agent-launch): carry a SAFETY rationale on the agent placement cast
The type-assertion gate landed after this branch's base, so the new file's
copy of the worker-start cast is now a changed-code finding.
* chore(agent-launch): carry agent.launch through main's RPC typing and casting gates
The typed-method contract, the generated params catalog and the
`assertionStyle: never` casting scan all landed after this branch's base.
- AGENT_LAUNCH_METHODS kept an `RpcMethod[]` annotation, which widened its
method name to `string` and broke assignability; every sibling infers instead.
- `agent.launch` binds a schema under src/main, so it joins the catalog's
RPC_METHODS_WITHOUT_SHARED_PARAMS and the parity gate's hand-listed twin.
- The now-typed methods make most test casts unnecessary; the few that remain
carry the line-specific SAFETY rationale the casting gate requires.
* test(mobile): supply the agent-launch fixture the create-submit recording needs
The golden RPC recordings landed upstream while this branch was out, so they
first met agent.launch here. Three things had to happen, and only one of them is
a fixture bump.
1. workspace-settings-mounts.ts mounts useNewWorkspaceCreateSubmit against a
fixture model that throws on any member it was not given. This PR added a
required getAgentLaunchSupport, so the submit aborted with "Missing model
fixture" before it ever issued the create, and three cleanup checkpoints
vanished. That read like a product regression and was not one. Supplying the
member restores the recording byte-for-byte; it is pinned false for the same
reason the cutover probe is, so the baseline stays on worktree.create.
2. Editing that adapter moves adapterSha256 for the twelve settings goldens it
mounts. Their recordings are unchanged - header only, by design: the digest
is per-golden so editing a module fails exactly the goldens that mounted it.
3. Five goldens changed behaviourally, and both changes are this PR's:
the capability probe now reports agentLaunch, and a create whose reply
carries no worktree returns "Failed to create workspace" instead of throwing
a TypeError off an unguarded result.worktree read. The launch route needs
that guard, since a receipt can arrive without a worktreeId.
* refactor(mobile): decode the launch receipt instead of asserting its shape
The changed-code quality gate refuses type assertions, and the eight it flagged
were worth removing rather than suppressing.
The production one was the point. readAgentLaunchCreateOutcome asserted the RPC
payload into Partial<AgentLaunchResult> and then runtime-checked it anyway, so
the assertion bought nothing and claimed a contract the host had not proven. It
now narrows with `in` and validates each hop, which is the same nullability
question readCreateResult already answers on the sibling path - a launch receipt
can legitimately arrive without a worktreeId. AgentLaunchCreateOutcome ties
worktreeId to the shared contract so a change there fails this reader's
typecheck rather than passing a differently-typed field through.
The test fakes claimed a whole RpcClient via `as unknown as RpcClient` while
implementing one member. They now build a typed literal, matching the pattern in
use-mobile-structured-agent-options.test.ts. The read sites cast params and then
read one field; they now assert the payload with toMatchObject, which removes
the cast and pins more of the shape than the cast did.
Also pins the warning passthrough, which nothing covered: a terminal launch that
seats the workspace but cannot start the pty reports why, and the absent, blank,
non-string and structured-surface cases report nothing. Writing that test caught
a real drop I had introduced in the reader.
* ci(mobile): re-run Mobile Checks when a shared capability changes
Mobile Checks is path-filtered to mobile/**, but mobile imports the negotiated
capability names straight from src/shared/protocol-version.ts and records the
whole capability read verbatim in its goldens. So a capability added desktop-side
rewrites a mobile fixture while never triggering the suite that would catch it.
That is what happened here: #19849 introduced agent.launch.v1 and Mobile Checks
never ran on it. Verified at the run level rather than by check name - the
window-free check-runs API on
|
||
|
|
a28085adbf |
refactor(mobile): checked reply readers for the source-control domain (step 7 pilot) (#20950)
* test(mobile): ratchet the 201 unchecked RPC reply readers Step 4 moved every call-site cast into an RpcOperation's `read`, but 201 of those readers still answer `compatible: true` for any payload: `rpcUncheckedPayloadReader` (163), `rpcReadUnchecked` (26 outside its own module) and `rpcUncheckedMemberReader` (12), across 42 files. The cast moved; it did not become true. Held as data with an AST boundary test, shaped on the raw-request-port ratchet: a file that is not listed fails, a listed file that no longer has one fails, and a count that rises fails. Only a call counts, so an import is not a reader and prose never is. No behaviour change: this commit adds a list and a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): validate the source-control domain's RPC replies at arrival Replaces all 17 unchecked readers in mobile/src/source-control/ with `rpcResultVariant(variant, schema)`, so a malformed reply is an `RpcIncompatibleReplyError` naming the operation instead of a TypeError three frames downstream. The inventory drops 201 -> 184 and the five source-control operations files leave it entirely. This is a behaviour change, scoped to malformed replies. Six reply-matrix goldens move; every named-scenario golden and every `normal` partition is byte-identical, which is the parity claim. Schemas live one module per reply domain, beside the operations that read them: git-status, git-compare, git-history, hosted-review and worktree-metadata. A member is required only where a consumer reads it unguarded, and each schema records the consumer line that justifies it. Nothing is `.strict()`; every reply a consumer publishes verbatim keeps `z.looseObject` so an undeclared host member still passes through. Six replies have no reader anywhere in mobile and get `z.unknown()`, which is the honest schema for them, not a holdout. Three readers stay total by construction, because their contract is that an unreadable reply is a value rather than an error: the `git.status` projection (a null status three screens route on), the `session.tabs.list` reveal (a null list means poll again) and the generated commit message (a screen's copy, never a decode error in a text field). They gain the salvage report, not a verdict. Consumers take the schema's output type, so `MobileGitStatusResult` and the branch-compare aliases now name what mobile reads rather than the desktop aggregate, and seven call-site casts are gone. Three requirements came from the goldens, not from the host types: `git.history` sends `timestamp: null`, `hostedReview.getCreationEligibility` sends a `reviewLookupOutcome` the shared union does not list, and the `git.status` projection writes an absent member as a present `undefined`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the six source-control reply-matrix goldens step 7 moves Six goldens, all on malformed partitions. Every named-scenario golden and every `normal` partition is unchanged, which is the parity claim for this step. git.history-read / git.history#1 result-absent, result-null, inner-ok-missing, inner-false-string-error, inner-false-object-error: the load rejected with a TypeError reading 'items' or 'map' off undefined/null; it now rejects with `incompatible_reply: git.history-page (git.history)`. hostedReview.eligibility + create-intent / hostedReview.getCreationEligibility result-absent, result-null, inner-ok-*: the fetch fulfilled with the error envelope itself, re-typed as an eligibility and published into the compose prefill; it now rejects, and both callers already route that to the same "eligibility unavailable" state a null answer produced. hostedReview.create-chain + create-intent / hostedReview.create result-absent, result-null, inner-ok-missing, inner-false-object-error: the create form showed the raw TypeError text "Cannot read properties of undefined (reading 'ok')"; it now shows the incompatible-reply message. Every header digest is unchanged -- baseline, recorder, adapter, scenario and lockfile all match -- so the diff is the behaviour and nothing else. Recorded from this branch into a scratch directory and copied in, because there is no scoped honest alternative: scripts/rpc-recording.mts refuses to run unless the product tree equals the pinned baseline, and the README's remedy for an intended behaviour change is to repin, which rewrites the `baseline` header of all 667 goldens. So these six now carry a pin whose tree no longer produces them. That is a real gap in the oracle's design for behaviour changes, not a detail of this step, and it needs a decision before this lands. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the four reply-schema properties the goldens found Each of these cost a reply-matrix golden while writing the source-control schemas, and none of them follows from reading the consumers or the host types: a newer host's undeclared members must still decode, `git.history` sends `timestamp: null`, `hostedReview.getCreationEligibility` sends a `reviewLookupOutcome` the shared union does not list, and the `git.status` projection writes an absent member as a present `undefined`. The `.strict()` case is the one worth stating twice: at the top level it rejects the reply, and on the entry it drops the row, which shows a dirty worktree an empty Changes list. The fifth test pins the salvage report that makes such a drop visible instead of silent. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give an unreadable reply a message a user can read `RpcIncompatibleReplyError` put `incompatible_reply: <op> (<method>)` in `message`, and `message` is what the screens hand to a toast. Step 7 is the first change that can reach this error at all, so the token would have shipped to users as its own error copy. Fixed at the boundary rather than per site: `message` is now plain copy, and the machine token moved to `code` (`incompatible_reply`) and `name` (`RpcIncompatibleReplyError`), both readable by callers. The cross-bundle fallback in `isRpcIncompatibleReplyError` matched on the old message prefix, so it now matches on `name`, which a foreign copy of the module still carries. No existing test pinned the old text. Two new ones pin the copy, the token and the foreign-copy match. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the recording baseline to this branch and re-record Commit |
||
|
|
f78483ec29 |
refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6, migration 1) (#20954)
* test(mobile): record the three step-6 families at the pin, and record a stream listener that dies Step 6 migrates the requests step 4 left behind because they share an effect with a `client.subscribe`. This records them first, from the pinned baseline, so the refactor that follows has a parity oracle. Three new families, one adapter module each: - `session.native-chat-page` — the older-history page. The read is a callback, but only the mount effect's `nativeChat.subscribe` arms what it pages against, so the frames are the setup: the snapshot's `beforeOffset` decides whether the request carries a cursor or asks for a growing tail. A cutover and a second snapshot pin the reconnect replay merging into paged-in history instead of collapsing the window. - `notifications.desktop-stream` — the desktop notification socket: the subscribe, the catch-up read its `ready` arms, the tray dismissals its events drive, and the server unsubscribe the disposer sends. Split in two so the base scenario's matrix sites all have partition-stable params: a variant that answers the second `ready` differently leaves the unsubscribe carrying the first subscription id, which the base's scripted params could not assert. - `session.terminal-gesture-input` — the debounced gesture flush and the menu's clear-buffer. Neither rides a subscription; a mount holding no terminal ref reaches both. The engine change is what makes the first two recordable at all. `ScriptedRpcTransport.frame` now returns what the product listener threw instead of throwing it on, and the runner records it as a `stream-listener-crash` effect. Only the two `runtime.clientEvents` listeners check that a frame payload is an object before reading its `type`; every other subscribing family took the matrix's `result-absent` and `result-null` partitions as an uncaught TypeError, which failed the suite rather than recording what a malformed frame does to a subscription. That is the same rule the crash boundary already holds for a screen and the unhandled-rejection window for a detached effect. The scenario's own faults stay loud: a missing subscribe payload, a params mismatch and a closed stream are all raised outside the caught region. `recorderSha256` therefore moves, so all 679 pre-existing goldens are re-recorded from the pin with this branch's recorder laid over it. Every one of them moves exactly one line and that line is `recorderSha256`: no `adapterSha256`, no `scenarioSha256` and no observation moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6) Five references over four files leave the raw request port. Each was held out of step 4 because a request-only recorder could not mount it; the recordings landed in the previous commit and no golden moves here. - `use-live-worktree-name.ts` — `worktree.show` inside the focus effect that opens `runtime.clientEvents`. It reuses `sessionWorktreeRecordRead`, which is the diff-comment loader's reader renamed: both consumers read the `worktree` member whole and narrow their own field off it, so a second family would have been a second name for the same wire. The resolution still comes off the raw reply, because `selector_not_found` is what proves the worktree is gone and no acceptance policy carries a refusal code; the skip that follows is the same verdict main's `!response.ok` reached, since a refusal is the only reply this policy declines. - `use-mobile-native-chat-session.ts` — `nativeChat.readSession` in the paging callback. The payload stays whole because the reply is a union: an older runtime answers `{ error }` in place of a window, and the caller discriminates before reading a message list. - `mobile-notifications.ts` — `notifications.unsubscribe` in the `ready` branch of the subscription callback, in its own module rather than beside the push-route sends: one is the route this device holds with a gateway, the other the socket the paired connection holds. - `use-mobile-session-terminal-input.ts` — the gesture flush reuses `terminalInputSend`, which already carried the four other terminal-input call sites and the same accepted-verdict, and the menu's clear gets `terminalBufferClear` beside it. The clear is a skip because main never read the envelope: it toasted success on any fulfilled reply, so only a transport rejection reached the failure toast. That is preserved, not repaired. `mobile-session-route-parity.test.ts` refreshes three pins with their reason: the callback bodies and the twelve nested-function bodies moved where those send expressions were rewritten, and the runtime-string count drops by two because `terminal.send` and `terminal.clearBuffer` are now fixed at their operation's definition instead of spelled at the call site. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold the subscription coverage as a checked inventory instead of a README paragraph Every product `client.subscribe` is now an entry in `mobile/src/transport/rpc-subscription-inventory.ts`, classified as recorded (naming its family), an unwritten scenario, or walled with the wall named. `rpc-subscription-boundary.test.ts` fails on a new site with no entry, an entry whose file no longer subscribes, an entry naming a method the file does not open, and a `recorded` entry whose family the scenario manifest does not have. Both the unlisted-site and unresolved-family gates were checked by removing an entry and by misspelling a family; each fails on its own assertion. The paragraph this replaces said nine sites when there were ten. It counted over `mobile/src`, and the host screen's `accounts.subscribe` lives under `app/` — so the scan here covers both roots, the way the raw-port ratchet next door does. Ten sites today: four recorded, two unwritten scenarios, four walled (two on the webview ref, one on the multi-host client context, one on two unsubstituted view members). Unlike the raw-port inventory this list does not count down to zero. A typed operation fixes one method, one acceptance and one reader for one reply; a stream has many, and replacing a subscribe is not what this is asking for. The question it holds is the other one — which stream a golden actually has, and for the rest, what exactly stops it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the notification stream close, which writes nothing to the wire Deleting `unsubscribeStream()` from the notification cleanup — the local close, not the `notifications.unsubscribe` RPC beside it — survived all 810 tests. Neither unsubscribe builder in the stream registry knows `notifications.subscribe`, so closing that stream sends no frame; the mutant leaks a live subscription record instead, and the leak only surfaces when the logical client replays it onto the next session. `notifications-desktop-stream-closed` stops the stream and then cuts over, where the leak becomes a second `notifications.subscribe` payload. Recorded at the pin. No existing golden moves: the new scenario is appended, so it is not the family's matrix base, and every notification matrix site already had a fulfilled reply to replay. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the accounts screen's real wall, which is ScrollView and Alert The entry blamed `expo-router.useFocusEffect`, which is substituted, and the inventory's own `use-live-worktree-name` is recorded while importing it. Probed by mounting the screen through the trap: the first refusal is `Unsubstituted native member: react-native.ScrollView`, and `Alert` refuses too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the terminal-send response reader that lost its last caller `isTerminalSendRpcAccepted` read the verdict off a whole envelope, which is what the raw call site did. Both callers now send through an operation and read the admitted payload, so the response form had only its own test left. The three cases move onto `isTerminalSendResultAccepted`, with the refusal envelope's missing result standing in for the failed response. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): attribute a frame crash to the listener that threw, not to the registry The try wrapped `stream.deliver`, so anything the registry raised on its way to the listener was recorded as a `stream-listener-crash` effect and blamed on the product. A reply like `{ok:false}` with no error object throws reaching for `error.message` before any listener runs, and that is a scenario that stopped matching, not an observation. Only the product's own `onData` is wrapped now. The throw is stashed and rethrown unchanged, so the registry still sees it the way a device's message handler does and what it skips after a dead listener stays recorded rather than invented; `frame` reports it only when the error it caught is the one the listener raised. `FrameListenerCrash` is local to the file again. Engine change, so every golden re-records: 694 files, every changed line the `recorderSha256` header, no body movement. Against main the set is 679 modified header-only and the same 15 added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read the frame listener stash through a method, not a narrowed field `this.listenerCrash = null` before the try narrows the property to `null` for the rest of `frame`, so the catch compared against `never` and mobile's own `tsc --noEmit` failed. A private taker returns the declared type. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): abort a registry throw that stashed nothing, and fold the last native-chat read module in The frame catch compared `crashed?.error !== error`, which is false when nothing was stashed and the registry threw `undefined`, so that abort was swallowed and `frame` reported a clean delivery. It now asks whether a listener crashed at all. Also: `nativeChatSessionPageRead` moves beside the three other `nativeChat.*` reads and its one-export module goes; the session read header names the whole `worktree.show` record rather than review notes; the guarded-listener count is three, not two; the README names the ten subscribing sites blur is unrecorded across; and the gesture flush reads the send verdict as `=== true` like the other four sites. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): drop an oxlint disable the rule never needed `no-throw-literal` is not enabled here, so the directive read as unused and failed the changed-code quality gate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d4c19d5db4 |
test(mobile): let the RPC recorder open a subscription and script its frames (step 6 capability) (#20920)
* test(mobile): let the RPC recorder open a subscription and script its frames The request-only runner threw on `client.subscribe`, which is why seven raw-port holdouts read "the recording runner refuses to open one". It no longer does. `ScriptedRpcTransport` drops the real `RpcClientStreamRegistry` into each physical session, the way it already reuses `RpcClientRequestTracker` for requests, so subscribe params, frame routing and the unsubscribe wire all come from product code. Per session, not shared: a frame is routed by the session that published its subscribe, and after a cutover the retiring registry is what holds a cancelled subscribe long enough to unsubscribe it once its id arrives. A subscribe writes to `payloads` through the same hook a request does, named by per-method occurrence, and frame ids come from the transport's existing counter because the real `DirectRpcClient` shares one counter across requests and streams. New scenario step kind `frame`: it names a subscribe payload, asserts its params the way `complete` does, and hands a whole host response to the real `handleResponse`, so `ready`, a data event, the host's end-of-stream pair and a refusal are one step kind rather than four. Every `payloads` entry now carries `sent`, the request count at write time, the same stamp `effects` already use. Without it, swapping `client.subscribe` and the first `sendRequest` in a product source moves zero bytes: a subscribe publishes synchronously while a request waits for connected, so the payload order is identical either way and only `sent` moves. The reply matrix now drives frames as sites, named by payload and occurrence because one subscribe carries many frames. Nine of the eleven partitions apply; the two transport rejections are what a request promise fails with and a subscription holds none. Success shapes keep the scripted frame's `streaming` flag, which is what routes a response to the open stream. `useFocusEffect` is substituted as `useEffect`, so a route's focus cleanup is recorded at unmount and a blur-triggered unsubscribe stays unrecorded; the README says so rather than a driven focus substitute no recording reads. Four tests, each killing a named mutation: routing a frame through the current session instead of the publisher, delivering a frame to the request tracker, dropping the `sent` stamp, and reading only `'complete' in step`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the two runtime client-event stream consumers Two families, both driven through the new frame step, as the capability proof for the subscription recorder. `session.live-worktree-name` mounts `use-live-worktree-name.ts` end to end: subscribe, `worktree.show`, a `ready` frame, the fulfilled name, a `worktreesChanged` frame, the follow-up `worktree.show`, then unmount and the `runtime.clientEvents.unsubscribe` its focus cleanup sends. `worktree.host-refresh` mounts `startHostWorktreeRefresh`, whose whole output is when it calls the two fetches it is handed. It sends no request of its own, so it is also the family that would have thrown `No scripted reply to drive a matrix over` before a frame was a matrix site. The 3 s foreground poll is driven by an `advance` step, which puts `WORKTREE_REFRESH_MS` under recorded time. Both adapters live in one new module, registered like every other domain, so the two families' goldens are pinned to a file that holds only them. No product source changes and no call site migrated: the seven raw-port holdouts and the `client.subscribe` zero-reference assertion belong to the migration PRs. `accounts.subscribe` in `use-mobile-home-host-connections.ts` is left out. Its snapshot decoder is re-exported through a React Native screen module the loader cannot reach, which is the same wall the accounts read has always been behind, so it needs a substitute beyond what these two read. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record every golden for the subscription recorder Engine files changed, so `recorderSha256` moves and every header re-digests, and `payloads` entries carry a new `sent` key. Nothing recorded moved. Recorded from a detached worktree at the pinned baseline with this branch's recorder laid over it, per the README's awkward case; `baseline` is unchanged. Decoding both sides through the value pool and ignoring `recorderSha256` and the new `sent` key: 641 compared, 6 header-only (the six goldens with no payload at all), 635 sent-only, 0 other, 9 added, 0 deleted. The 9 added are the two new families: a pilot golden each, four reply-matrix sites for the live title (two requests and two frames) and three for the host refresher (three frames, and no request of its own). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the broad object parameter out of the frame partitions `audit:anti-slop`'s no-object-parameters rule fires on a parameter typed `object`, which the frame-partition helper took to spread a success envelope. One function narrowing `unknown` to a spreadable envelope replaces the two that split the check, and the streaming flag is now read as `=== true` rather than by key presence, matching `isStreamingOpenerReply`. An engine edit moves `recorderSha256`, so every golden re-digests again. Decoded through the value pool, all 650 differ on that header alone and on nothing else. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): refresh the recorder's own scenario and golden counts The paragraph still claimed 78 scenarios and 153 goldens over 210 tests, which went stale across the domain additions since. It is 330 scenarios, 650 goldens and 757 tests as of this branch. The figures quoted further down are measurements of the change each one describes, so they stay as written; a line now says so. Prose is excluded from `recorderSha256`, so this moves no golden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the inert optional off a frame, and pin the replay re-read Review of #20920 found four things the first pass got wrong. The `optional` flag on a frame step never gated anything: the registry routes every streaming response to the id that opened the stream, retired or not, so `frame()` only ever throws for a non-streaming reply. Dropping the parameter, the step field and the downstream marking moves the scenario digest of two matrix goldens and no recorded byte. The session comment claimed a mechanism that is not there. The re-send after a cutover comes from the logical client's own subscription replay, not from the registry being per-session; a shared registry is byte-identical. What being per-session buys is a frame routed through the session that published its subscribe, which is what `DirectRpcClient` does too. The host-refresh scenario now cuts over and answers a second `ready`, so the reconnect replay branch is recorded: deleting its re-read moves this family. Before, that branch was source no golden reached. README over-claimed the subscribe port as covered. Nine product call sites subscribe, two are recorded, and the other seven are now named with what stops each. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record for the frame flag removal and the replay cutover 644 goldens move on `recorderSha256` alone, from the engine edit. Two more also move `scenarioSha256`: the live-worktree-name matrix variants that used to carry `optional: true` on a downstream frame. Four bodies move, all in `host-worktree-refresh` — the pilot and its three matrix goldens now record the cutover, the re-subscribe payload, the retiring unsubscribe and the extra worktree/repo read the replay branch does. One golden is added, for the matrix site the second subscribe payload opens. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): count the golden the second subscribe payload adds Prose only; moves no golden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the live-worktree-name replay re-read too The same cutover treatment as host-refresh: the scenario now migrates the logical client, answers a second `ready` on the re-sent subscribe, and answers the title read the replay branch makes. Before this, deleting that re-read from `use-live-worktree-name.ts` moved no golden. No engine file changes, so `recorderSha256` holds and 646 goldens are byte-identical. Five bodies move with their scenario digest, all in this family, and two matrix goldens are added for the sites the second subscribe payload and the third title read open. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say what a request count cannot order, and name the accounts wall `sent` counts requests, so it orders payloads and effects against sends and not against each other. A family that sends none has no ordering at all: `host-worktree-refresh` keeps `sent` at 0 through every checkpoint, and moving its two initial reads across the subscribe moves no golden. The fix is one write ordinal shared by all three lists, which forces a full refresh. The `accounts.subscribe` wall was misdiagnosed. The loader reaches `decodeAccountsSnapshot` and it throws its own domain error; what the runner cannot supply is the multi-host client context `useAllHostClients` reads. Also honest about the record recipe: where a branch must not repin `baseline`, the detached-pin worktree is the only one that runs, merged main or not. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): file only a subscribe as an open stream, and drop three unused seams The registry sends its unsubscribes through the same `sendEncrypted` hook as its subscribes, and the hook filed every payload under `openStreams`. A frame aimed at an unsubscribe name therefore routed at that wire id, matched no stream, recorded nothing and reported success — where the README promises `Missing subscription payload`. A latch around the session's `subscribe` wrapper files only what a subscribe published. Its test fails without the latch. Three seams no caller varies, the same shape as the `optional` flag: `frameReplyPartitions` took a `scripted` reply to copy `streaming` from, but every frame site scripts a streaming reply, so the flag is stamped and a non-streaming unary closer as a base frame is called unsupported; the divergence map's three-deep ternary is early returns, since `index > divergence` already implies `index !== divergence`; and `MatrixSite` is no longer exported. Body-inert: re-recording into a scratch dir at this tree moves all 653 goldens on `recorderSha256` and nothing else, decoded through the value pool. The goldens are left stale for the merge re-record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record every golden after the main merge One record at the pin, with this branch's recorder, scenarios and driver script overlaid on a fresh detached worktree. Decoded through the value pool against `origin/main`: 667 shared goldens, 6 header-only on `recorderSha256`, 661 also gaining the `sent` stamp this branch puts on every payload entry, nothing else moved, and 12 added — the two client-event families and their matrices. No `adapterSha256` moved, so main's adapter work was already recorded against its own goldens. Those 12 are byte-identical to their pre-merge bodies, `recorderSha256` aside, so the merge changed nothing this branch recorded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
b8d4cde09f |
refactor(mobile): send six screen-mounted call sites through typed RpcOperations (step 4, wave 3) (#20919)
* test(mobile): record six screen-mounted call sites before migrating them Five new mount adapters and six scenarios, recorded against the pinned baseline's product code so the goldens are main's behaviour, not the refactor's. Each site is a screen the recorder could not previously mount: - `home.host-accounts` mounts `fetchMobileHomeAccounts`, whose decoder is re-exported through `AccountUsage.tsx`. That module loads under the mount loader, so the inventory's "no recording can load it" was already stale. - `notifications.display-test-screen` mounts the settings push probe and presses its button by reading the handler back off the rendered inert `Pressable`. - `aiVault.history-screen` mounts the history panel, which is where the last `worktree.ps` lives. Split in two: the base stops once the worktree list has seeded the scopes, because a reply partition there changes the scopePaths the downstream `aiVault.listSessions` carries, and a matrix variant cannot assert params it moved. The full chain is a second scenario, driven as a pilot only. - `tasks.route-repo-list` mounts the tasks screen-root hook and calls its own `ensureLoaded`, which is the only thing that fires `repo.list`. - `linear.select-workspace-picker` calls the render helper the tasks surface calls and invokes the `onSelect` on the element it returns. The picker draws inside `BottomDrawer`, whose reanimated timing driver and gesture builder the recorder would have to impersonate for a row to exist; the closure is the same either way, and the workspace a selection carries comes from the scenario. Five substitute members are added, each with the recording that reads it: `react-native-safe-area-context.useSafeAreaInsets` and `expo-router.useLocalSearchParams` for `tasks.route-repo-list`, and `react-native.TextInput`, `.SectionList` and `.RefreshControl` for `aiVault.history-screen` once its list renders. `useLocalSearchParams` answers one pinned route for the same reason the window size is pinned: a screen's own address is not a device reading, and the one screen that reads it sends `repo.list`, which takes no params. Touching the substitute table moves `recorderSha256`, so all 641 existing goldens are re-recorded. Recorded from a detached worktree at the pinned baseline with this branch's recorder laid over it: every pre-existing golden is header-only, verified by resolving both sides through the value pool — 641 header-only, 0 body, 0 deleted, one distinct `recorderSha256`, `baseline` and `lockfileSha256` across all of them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the linear workspace picker's model fixture `mobile/tsconfig.json` covers the recorder, and the fixture's setters were written with the argument the product happens to pass rather than the `SetStateAction` the model declares. Typing them moves `adapterSha256` on the two goldens recorded through this module, so they are re-recorded here rather than in the refactor commit, which must move none. Re-recorded at the pinned baseline: `linear-select-workspace` and its reply matrix, header-only, bodies unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send six screen-mounted call sites through typed RpcOperations Nine references off the raw request port, across six files. Every one is proven against the goldens recorded in the previous commit from the pinned baseline's product code: this commit moves no file under mobile/rpc-foundation/goldens. Reused rather than redefined: - `worktree.ps` in the history panel sends through `worktreeCatalogRead`. Same question, same acceptance — a refused list leaves the screen on what it holds. - `repo.list` in the tasks screen-root hook sends through `newTabRepoListRead`. Its policy raises the host's message and its reader takes `repos` off the payload while preserving the property-read exception a null result used to throw at the cast, which is what this call site did by hand. Its name still says new-tab; a third consumer does not make renaming it this bucket's business. Four operations are new, each because no existing reader on the method takes this consumer's input: - `files.read-directory-or-skip` and `files.legacy-explorer-list-or-skip` for the explorer. Both skip, because neither refusal is the operation's to decide: the readDir refusal code selects the legacy fallback and the list refusal supplies the message. The existing `files.list-or-skip` reads the `files` member alone, and the explorer also needs `truncated` for the "Showing first 5000" note. - `accounts.home-snapshot-or-skip` for the Home card, decoded by `decodeAccountsSnapshot` at the call site as before. - `notifications.test-push-or-skip` for the settings probe, whose `forbidden` and `method_not_found` refusals mean "try the next desktop". - `linear.select-workspace-or-skip` for the filter sheet. Two behaviours are preserved rather than repaired, both recorded: - The workspace switch never read its reply. `.then(() => loadLinearContext())` runs on a refusal exactly as on a success, so only a transport rejection reaches the error copy. Interpreting the operation here would surface a refused switch for the first time; that is a product change with its own re-record. - `app/terminal-settings.tsx` still reads `ms` off the reply envelope instead of off its result, so the value is always undefined. It did not migrate, and the inventory now carries the defect as its own note. Four mutants are added, one per new family that admits a state-only one: the Home snapshot, the push test result and the tasks repo list each decoded one level above the envelope, and the workspace switch with its context reload dropped. `aiVault.history-screen` gets none and says why in the suite: everything `worktree.ps` publishes also moves the `scopePaths` the next scripted completion asserts, so a mutant aborts the sequence instead of diverging from it. Its evidence is the reply matrix at that request. The tasks source-parity ratchet moves with the family it guards: hook, statement, declaration, render and style counts are unchanged, and the semantic source is a pure deletion of four lines — two `rpc:` call signatures and the two method literals they carried. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): matrix the six new screen families' replies One golden per scripted reply, eleven partitions each, recorded at the pinned baseline alongside the pilots. Seven sites: `accounts.list`, `notifications.testPush`, `repo.list`, `linear.selectWorkspace`, and all three of the history screen's — `worktree.ps` and the two `status.get` reads its scan chains off the worktree list. The history matrix is also that family's defect evidence in place of a mutant: every partition at `worktree.ps` changes the `scopePaths` the downstream `aiVault.listSessions` carries, and the sender args are recorded with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct three operation and mutant comments Comment-only, no product behaviour and no golden movement. - `worktreeCatalogRead` says two readers; there are three. Names the third (the agent-history panel's `scopePaths` seed) and drops the stale count from the module header, which described call sites rather than the two operations. - `newTabRepoListRead`'s census counted the two operations over `repo.list`, not its own two callers, and claimed both read a workspace's connection id. The tasks route keeps the whole list for its repo pickers. The split from `nativeChatRepoListRead` stays where it belongs: acceptance. - The `aiVault.history-screen` mutant note pointed at the reply matrix as the accepted-vs-refused oracle. Decoding `matrix-aivault.history-screen-worktree.ps-1.json` through the value pool shows `normal`'s projected state is identical to all seven non-crashing partitions (spinner, two labels, zero rows). The real oracles are the next request's `scopePaths` (`["/repo/feature"]` vs `[]`) and the crash channel the three `inner-*` partitions land in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): give the second files.list reader its real reason Comment-only, no product behaviour and no golden movement. `legacyFileListRead` claimed "the member reader rejects this consumer's input". Nothing rejects: `rpcUncheckedMemberReader` returns the member, and reusing it here would simply drop `truncated`. The reason the explorer declares its own operation is the other direction. Widening `files.list-or-skip` to a payload reader would split the `workspace-files` variant it shares with `nativeChatFileSearchRead` over `files.searchPaths`, whose only caller feeds both through one `extractPaths` in `use-mobile-native-chat-file-search.ts`, so the member read would move into that hook rather than disappear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): indent the six scenario entries spliced during the merge The conflict on `pilot-scenarios.json` was resolved by id rather than by hunk, splicing this branch's six entries into main's text at the array's close. The splice started at the entry's `{` instead of at its line, so those six lines lost their indentation. oxfmt's only change is those six lines; the parsed document is identical, and the recording suite still matches all 667 goldens, so no scenario digest depends on the raw text. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the merged goldens once at the pin One record for the whole merged tree, at the unchanged baseline |
||
|
|
615b1370fb |
refactor(mobile): own the request/cache lifecycle in GenerationScopedRequestOwner, piloted on the legacy file inventory (step 5) (#20914)
* feat(mobile): own the request/cache lifecycle in GenerationScopedRequestOwner (step 5) Hooks guard stale replies with hand-rolled generation counters, `isCurrent` callbacks and latest-wins refs, so the guard is a callback a caller may forget. The owner keeps the cache, the in-flight identity and the generation token private. `read` and `load` are handed the scope and build the key themselves, so a scope the owner has not seen retires everything it held before it answers, and two workspaces cannot share a key. Publication goes only through `commit(lease, value)`: the lease brand is module-private, so no caller can mint one, and a lease whose generation moved is refused. `reset` bumps even when the scope came back to where it started, as in A to B to A. Three epochs may sit in a scope and they are not the same thing: the logical authority epoch, the physical authenticated session and the negotiated capability epoch. Which of them retires a given owner's data is that owner's decision, expressed by what its callers put in the scope. `lifecycle-owner.test.ts` carries one named schedule each for key-reset-cleanup, blur, cutover, reconnect-mid-request and stale-inflight-cleanup, each written as an explicit resolution order. It also fences loader bodies: a `load` callback that writes state it did not declare is rejected by the same kind of source scan that fences raw casts. Compile-time assertions live in a non-test file because mobile's tsconfig excludes tests. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): put the legacy file inventory on the lifecycle owner (step 5) The native-chat file search kept three hand-rolled guards for one request: a generation counter bumped by an effect, a committed-paths ref, and an in-flight ref whose `finally` cleared itself conditionally. The stale-reply check lived in the reply handler, where a caller could forget it. The owner replaces all three. `read` and `load` are handed the scope, so the guard runs before either can answer, and the reply is published only through `commit(lease, value)`. What retires the inventory is named at the call site: this host, this workspace, this logical authority epoch. A reconnect to the same host leaves the files on disk alone, so the physical authenticated-session epoch is deliberately not in the scope. `RpcClient` gains one optional read-only signal, `getGeneration`, so a holder of a bare client can scope cached work to the logical authority epoch that `StableLogicalRpcClient.migrateTo` advances. Nothing else about either client widens. No golden moves: all nine legacy-inventory recordings reproduce byte for byte, including the A-to-B-to-A and cutover schedules. The `race` mutant is re-anchored on the owner's generation compare, which is now the only place that compare exists, and it still dies against b1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): cut the lifecycle owner down to what callers use (step 5 review) Review round 1 on #20914 found three pieces of surface with no product reader and one vacuous assertion. `dispose()` is gone with the `disposed` field, the three guards that read it and the `'disposed'` verdict arm. A React effect cleanup cannot use it: the pilot's cleanup runs on every dep change and the owner outlives it in a ref, so a workspace select would dispose it permanently. Swapping `reset()` for `dispose()` there fails 7 tests across 3 files. `capacity` and its eviction loop are gone too. No caller varied it, so the loop never ran in production, its `if (oldest.done) break` was unreachable, and it evicted in insertion order while its name said capacity. `RequestCommitVerdict` and `RequestParameters` lose their `export` (no importer), as does the `generation` getter and the expect-error assertion that pinned it (test-only reader; `reset` advancing is proven by the verdict a lease from the previous generation gets). `LoadedRequest` keeps its export: it names the value of the public `load` promise, which a helper over that result has to write down. `key-reset-cleanup` now leaves a second request pending across the `reset()` and asserts the post-reset load starts its own, which is the half `inFlight.clear()` actually owns. Proof: deleting that line from `retire()` failed this schedule and `stale-inflight-cleanup`; before the change it failed only the latter. `read`'s doc now says it retires an unseen scope before answering and must not be called from render. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read getGeneration off RpcClient and scope one attempt once (step 5 review) Two call-site findings from review round 1 on #20914. `mobile-session-tabs-stream-health.ts` hand-rolled `RpcClient & { getGeneration?: () => number }` and cast through it with no SAFETY rationale. `RpcClient` declares the member now, so both go and the read is `this.options.client.getGeneration?.() ?? 0`. The file-search pilot built its scope from a function it called twice in one attempt, so a `migrateTo` landing between the cache read and the load would have put one attempt in two scopes. It is a `const` computed once per attempt. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): type the scope, drop the in-flight wrapper (step 5 review) Review round 2 on #20914, owner side. `RequestScope`'s element type now excludes symbol and bigint, so both are compile errors with an assertion each in the fence. The runtime symbol throw is gone with the untested branch it guarded, and the bigint case it never covered (it reached `JSON.stringify` and threw V8's serialize message from two frames down) cannot be written. `InFlightRequest<Value>` existed only so its own `then` callbacks could name the entry they belonged to, which forced a throwaway `Promise.resolve(null)` that the next statement overwrote. The map holds the request promise itself and `settle` compares promise identity. `scopeMember` is inlined into `scopeKey`'s map callback: with symbol gone the member type is the scope's element type, which spells `object`, and anti-slop bans that in a parameter position. Inferred in a callback it is the same type with no annotation to ban. Header: `committed` says the generation still holds, not that the value already in the caller's hand is fresh. The pilot displays `loaded.value` directly and is fenced by the sequence counter it had on main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): gate the epoch in the pilot scope and the in-flight slot identity (step 5 review) Review round 2 on #20914 found two invariants no test held. The pilot's scope: replacing `client.getGeneration?.() ?? 0` with `0` left all 711 tests green. The new schedule pairs a control with the claim. A second query under the same epoch is answered from the inventory already held, and a query after the epoch advances issues a second `files.list` and displays what the new authority's host returned. Same client object, same workspace, so the epoch is the only thing that can retire it. Proof: with the literal `0`, `files.list` count is 1 where 2 is asserted. `settle`'s identity guard: making the delete unconditional left all ten schedules green. `stale-settlement-cleanup` puts a request in flight, resets, starts a live request on the same key, then settles the retired one last, whose cleanup names the slot the live request now holds. A third load must join rather than start. Proof: unconditional delete gives `started` 3 against 2. The fake clients go through one `fakeClient` helper, which is what lets the new case name the two members the hook reaches without a fifth `as unknown as RpcClient` (four deleted, one fenced assertion left with its rationale). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
ea7902cbee |
refactor(mobile): send the device-state holdouts through typed RpcOperations (step 4, wave 3) (#20915)
* test(mobile): record the terminal input surface before migrating it (step 4) Three families the recorder could not reach before, recorded against the pinned baseline's product code so the migration that follows has a parity oracle. The device state these hooks read is real, not declared. The pasteboard is the engine's existing per-recording fixture, so a paste reads the bytes a recorded copy put there one action earlier; the buffered draft store is the product's own useBufferedTerminalDrafts mounted in the same tree. No engine file is touched, so no existing golden moves and no header re-digests: 13 new goldens, 641 unchanged. Only the clipboard's text path is driven. The image path decodes a raster through expo-image-manipulator and stages it on expo-file-system, and recording it would mean inventing image and file-system behaviour. Both paths reach the same send. Two family mutants, one per family whose state() can observe a reply: keeping a refused send's draft cleared, and resolving the first repo's connection instead of the workspace's own. The paste family gets none — the hook returns void and calls onSuccess for an accepted and a refused send alike, so its only reply-dependent behaviour is the takeover report, which lives in the sender list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the device-state holdouts through typed RpcOperations (step 4) Ten references over six files, the last of the raw-port sites whose blocker was that a recording could not reach them. Zero goldens move: every one of the six was recorded first, and the suite replays them against the rewritten code. Two operations are new and four sites reuse one that already fixes their method: - accounts.consumeCodexResetCredit, throw-message, payload unread — the call site's decodeResetResult is one scope-and-snapshot check and splitting it across a reader would put one refusal rule in two places. - notifications.getMissedSince, skip — a background pass with no screen to raise a host message on. The member read stays where the optional chaining was. - repo.list: the accessory's connection lookup joins the new-tab reader, which already threw the host's message; the new-workspace dialog joins the skip reader, which already left the list it had. Same reader, same policies, no new acceptance rule and no third operation on that method. - terminal.send: the composed send, the live keystroke send and the clipboard paste all join terminal.input-send, which the accessory raw send already used and which reads acceptance the same way isTerminalSendRpcAccepted did. The typed contract is stricter than the client's own scope type on the redeem: the catalog pairs each runtime with the distro it may name, while the shared CodexResetCreditExpectedScope does not. The invariant is real and held by the attempt journal's schema, so the narrowing is asserted at the send with that named; the bytes are unchanged. Widening the catalog would be a wire change. Two source-shape ratchets pinned the old call text and move with it. The route parity suite's runtime strings drop from 540 to 537: the three method literals that became operation definitions, and nothing else. Every hook, callback identity, effect, JSX and style pin is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the terminalInputSend and PTY-mode holdout comments `terminalInputSend`'s doc still claimed two call sites. It now has five non-test consumers, all on the same acceptance: the query-reply responder, the live accessory raw send, the session screen's composed draft send and live keystroke send, and the clipboard paste. That comment is where the next person narrowing `object-result-or-null` learns whose lost-ack meaning they are changing, so it names all five and their files. The session inventory block closed with "opens or rides a subscription, or takes its method as a parameter", which no longer covers every holdout below it: `use-mobile-session-terminal-input.ts` is held out for a webview handle. Its own reason also said PTY mode was unavailable in the runner, which this branch's terminal-input adapter contradicts by fixturing the mode map a paste reads. The sentence is narrowed and the holdout restated: PTY mode is recordable, the live webview handle is what is left. Comments only. No product behaviour, no golden, no parity hash moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pair the draft-restore mutant with the refused send `terminal-send-refusal-restores-draft` documents the harm of a refused send that leaves the composed draft cleared, but it was driven by the accepted scenario, where the kill comes from the inverse (a draft restored after a send that landed). The refused scenario shows the documented harm directly: without the restore the input stays empty after the runtime says no. Still one mutant per family, and it kills there — verified by running the suite, `terminal-input-send-refused: kills terminal-send-refusal-restores- draft`. No golden, no product change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the gesture-input holdout and drop a dead repo type Three round-2 corrections, comments and one dead type; no behaviour. The gesture-input holdout claimed a recorder gap that does not exist. The flush path reads refs only — client, connection state, PTY modes, the gesture buckets, active handle and tab type — and the clear-buffer reference optional-chains the webview ref, so a mount with a null terminal ref puts both sends on the wire. The reason now says what is true: those 2 references are migratable as they stand and were out of this PR's bucket. The session summary sentence no longer offers a webview reason. `RuntimeRepoSummary` in mobile-session-route-types.ts lost its last consumer when the accessory hook moved to `MobileRuntimeRepoSummary`; `git grep RuntimeRepoSummary` now finds only the `Mobile`-prefixed type. Deleted. Both refreshed route-parity hashes still credited the `interpretOrThrowRefusalMessage` refresh for their current value. They now state the invariant they pin and this PR's reason for the move: the sends and repo reads inside those bodies name their `RpcOperation` instead of the raw `sendRequest` port. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
0869d997c8 |
refactor(mobile): send the rest of the session domain through typed RpcOperations (step 4) (#20891)
* test(mobile): record the session domain's remaining call sites before migrating them Freezes main's behaviour for what is left of `mobile/src/session/`: the AI Vault resume pair, the clipboard image upload and its two attachment surfaces, the native-chat terminal writes, session tab activation and reconciliation, the terminal-path tap, the structured agent launch, and the session screen's own reads, tab creates/closes, review actions, notes, markdown save and quick commands. 87 scenarios over 22 new families, recorded from the pinned baseline `c6a72169843ececf3a21da370ac50c5c5a4e6462` through a detached worktree, before any product edit. Ten new modules under `adapters/`, so each domain's goldens are pinned by `adapterSha256` and no existing family re-digests. `native-mounting-substitutes.ts` gains `expo-haptics` (inert: every caller is already fire-and-forget), `expo-clipboard` (a per-recording pasteboard cell, because these screens read back what they wrote) and `BackHandler`/`Keyboard`. That is an engine file, so all 509 pre-existing goldens move on `recorderSha256` and on nothing else. Two families cannot be matrixed at their first request: the clipboard upload chain puts the start reply's `uploadId` into the params of every later call, so a partition that answers the start differently changes a downstream assertion rather than a recorded observation. Their base scenarios stop at that first reply instead, and the fallback arm carries the upload family's second site. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the new session mount fixtures against the product model The ten adapter modules added with the session recordings typed several fixtures structurally — loose maps, a local `Terminal`/`Tab` shape, `unknown` for the review screen state — which `pnpm --dir mobile typecheck` rejects: `tsconfig` covers `src/test-support`, so an adapter is checked like product code even though no test file is. Each one now names the product type it stands in for (`MarkdownDocState`, `TerminalRecord`, `MobileSessionTab`, `ReviewScreenState`, `MobileDiffReviewQueueItem`, `DiffComment`) and supplies its members through `mountFixture`. Only one of those changed a recording: a `DiffComment` requires `side`, so the review actions now put it on the wire and the two `review-mark-reviewed-*` goldens carry it. That is the fixture becoming a real subset of the type it claimed, not a behaviour change — no product source moves in this commit, and the parity claim the next one makes is against these bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the session mount adapter and the recording manifest `pnpm exec oxfmt --check .` from `mobile/` flagged both after the previous commit's type fixes. The adapter is pinned by `adapterSha256`, so the eight goldens recorded through it are re-recorded from the same baseline; the manifest is not pinned by its bytes — `scenarioSha256` canonicalises the parsed scenarios — so no golden moves for it. Header-only either way: no observation changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the rest of the session domain through typed RpcOperations Thirty files and 58 references leave the raw request port. Every send now names a declared operation with a fixed method, one acceptance policy and one reader, and no call site casts a reply payload: the AI Vault resume pair, the clipboard image upload with both its attachment surfaces, the native-chat terminal writes, session tab activation and reconciliation, the terminal-path tap, the structured agent launch, and the session screen's reads, tab creates/closes, review actions, notes, markdown save, quick commands and file search. Five new operation modules, plus two readers added to existing ones. Three methods get a second reader, each argued where it is declared: `files.resolveTerminalPath` (the tap branches on five members the grant refresh hands back whole), `files.open` (the tap's miss is silent, the Changes screen raises the host's message) and `session.tabs.list` (the send sheet keeps only terminal tabs, which the reveal poller and the reconciliation controller both drop). `git.stage` carries two acceptances for the same reason `repo.list` does: a tapped file raises its refusal, a bulk sweep counts it. No new acceptance policy. The AI Vault resume launch and the review send sheet now share `mobile-review-terminal-operations.ts` with the PR triage launch instead of re-deriving the same create/send pair, and the file-search hook's `extractPaths` moves into the reader it belongs to. One latent behaviour is preserved rather than fixed and wants a ticket: `mobile-session-tab-activation.ts` decides whether to replay an activation with `error instanceof LogicalClientCutoverError`, not with the message-matching `isLogicalClientCutoverError` that exists because "instanceof can miss across bundle copies". Under a second copy of the module the retry silently does not happen, which the recorder reproduces. No golden scripts a cutover for this family, so none records the wrong behaviour. The offender floor in the port ratchet comes down from 50 to 20: it is an anti-vacuity guard on a list this migration is driving to zero, and 39 files still reach the port. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep main's malformed-reply message and re-anchor two mutants Three findings the reply matrices surfaced that the pilot scenarios could not. The clipboard upload's `uploadId` read goes back to the raw result. A success carrying no result throws a destructuring TypeError there, and V8 puts the destructured expression's source text in the message — which the composer shows. Reading the slot off the interpreted payload rewrote that sentence for every user who hits a malformed reply, on three families' `result-absent` and `result-null` partitions. The cast is the one main made, kept for the message alone. `race` and `new-tab-refusal-order` both anchored in text the migration rewrote, so each matched zero sites. Re-anchored at their new homes; the defect each injects is unchanged, and `probe-hole-witness.test.ts` still shows the probe killing the reorder while every pre-probe scenario survives it. `native-chat-send-delivery-unknown` is added as this domain's own mutant: dropping the delivery-unknown arm of a chat send makes an ack lost after the frame was written read as a definite rejection, which invites the user to send the same message twice. A second candidate — swapping `terminal.list` from skip to throw — survives every golden and is not registered: the inventory hook wraps its whole read in `catch { return false }`, so a refusal and a throw leave the same strip. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): matrix every reply the new session families script Thirty-four reply-matrix goldens, one per scripted reply across the twenty-two families the session migration added, each running the eleven partitions in `reply-matrix.ts`. Recorded from the same pinned baseline as the pilot goldens through the detached worktree, so they freeze main's answer to a result-less success, a null result, an inner refusal envelope, a message-less outer refusal and a transport drop — not the migrated code's. They are laid down after the refactor because they are what found its three remaining divergences, each fixed in the previous commit rather than recorded around: the clipboard upload's destructuring message, and two mutation anchors the rewrite left matching zero sites. Two families matrix only their first request. The clipboard upload chain puts the start reply's `uploadId` into every later call's params, so a partition that answers the start differently moves a downstream assertion instead of a recorded observation; their base scenarios stop there, and the upload family's fallback arm carries its second site. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep the new session readers inside the operation cast fence `rpc-operation-cast-fence.test.ts` refuses a type assertion anywhere in the region reachable from `rpc-operation-contract.ts`, and four of the new operation modules asserted their own result type. Each of those reads goes back to `rpcUncheckedPayloadReader`/`rpcUncheckedMemberReader`, and the shape the call site expects is named at the call site, which is where every migrated domain already puts it. `extractPaths` and `readQuickCommands` return to their hooks for the same reason — both were only movable by carrying a cast with them. No golden moves: the readers hand back the same values, and all 705 recordings still compare clean. The two frozen source-parity suites over the session route family are refreshed for the migrated text: three hashes, one runtime-string count, and the inventory hook's acceptance check, which now reads `!isCurrent() || !response.accepted`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop three assertions the changed-code casting gate rejects `pnpm run check:code-quality:changed` reports `consistent-type-assertions` separately from oxlint's own pass, and three sites had no rationale: the resume preparation's payload read, which gets the standard SAFETY line, and two adapter refs whose `null as string | null` is just an annotation written the wrong way round. The adapter is pinned by `adapterSha256`, so its thirteen goldens are re-recorded from the baseline — header-only, no observation moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): answer round 1 on the session migration Reuses `RpcOperationSender` for the file-tap client instead of respelling it as `Parameters<typeof fileTapPathResolve.request>[0]` at two sites; the type is the same by construction, and the handlers file no longer imports an operation only to name its first parameter. Names why `worktree.set-review-notes` stays separate from source-control's identical `worktree.set-review-link`, and why the new-tab loader's two preflight reads do not share the task drawer's readers on the same methods. Corrects the `callAgentSession` holdout count: five call sites across two hooks plus one inside the module's own mutation wrapper, not seven callers across five files. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state what actually gates the native chat paging read The `nativeChat.readSession` send sits in the paging callback, not in the mount effect. What blocks recording it is that the mount effect's `nativeChat.subscribe` is what arms the offset and generation the callback pages against, and the request-only runner refuses to open a subscription. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): answer round 2 on the session migration The tapped-path resolve is now a re-export of the preview screen's `terminalArtifactPathResolve` rather than a second definition: same method, same skip, same whole-payload read, so the only thing the old comment could claim was a difference that was not there. `fileOwnershipRuntimeStatusRead` already re-exports for this reason. `worktree.show` has four readers, not three. The one the notes read is closest to is `fileOwnershipWorktreeRead`, which reads the same member whole, and acceptance is all that separates them: a file mutation throws rather than write to the wrong host, a session screen without its notes shows none and keeps working. `interpretOrThrowRefusalMessage` is generic, so a caller keeps the interpretation's own type, and eight longhand copies of its try/catch across five files now call it. Three more copies wait on a frozen source hash; see the report. Three operations sit in the module matching their direction: the quick-command save with the writes, the review-notes read and the markdown read with the reads. The quick-command reader is shared across that line, which is what keeps the save from adopting `[]` on a payload the parser rejects. The native-chat readability probe imports `MobileRuntimeRepoSummary` instead of redeclaring it, and a stray mutant comment that described the race entry is gone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): finish the refusal-interpretation helper across the session hooks Twelve try/catch blocks across the session hooks now call the generic interpretOrThrowRefusalMessage instead of rethrowing refusedRpcMessageOrFallback by hand. Each one throws the same message on the same inputs, and the request stays outside the catch, so a transport rejection keeps its delivery-unknown identity. Two frozen parity hashes move for that reason alone: - HEAD_CALLBACK_BODY_SHA256, for the one converted block that sits in a useCallback (use-mobile-session-diff-comments.ts) - HEAD_NESTED_FUNCTION_SHA256, for the three that sit in plain nested functions (use-mobile-session-content-create-actions.ts) Every other parity hash and every count is unchanged: hooks 269, callbacks 77, effects 24, nested functions 12, plus the callback identity, effect, main-hook, hook-binding, content-hook, native registration/removal and timer hashes. Copies that stay longhand, by design: - the action-level try blocks that wrap the request as well as the interpret and surface the failure to the UI, in the diff-review comment, git and send hooks - the two catches that call setActionError or setError and return instead of throwing, in use-mobile-diff-review-interactions.ts and use-quick-commands.ts - the call sites in files this branch does not touch (pr-ai-triage-launch.ts, mobile-diff-review-loaders.ts, github-pr-rpc.ts, github-pr-mutation-outcome.ts) No golden moves: this touches no golden, adapter or recorder engine file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens at the new baseline pin One re-record of all 641 goldens against pin |
||
|
|
2fccacadbe |
test(mobile): mount screens, declared device stores and declared OS state in the RPC recorder (#20884)
* test(mobile): mount screens, declared device stores and declared OS state in the RPC recorder Three recorder capabilities, each with a test, plus four holdout sites recorded against main's product code to prove them. No product source changes. - JSX compiles through the automatic runtime, which is what product sources use; the classic `React.createElement` emit threw `React is not defined` on the first render of every screen. - An unlisted package answers `__esModule` as undefined, so a default import loads and the refusal defers to the first real member read instead of killing the module at load. - The substitutes table gains the inert view packages a screen needs, split into `screen-native-substitutes.ts` behind the same rule: only what a screen reads is listed. - A scenario may declare `deviceStore` and `deviceState.notificationTray`. Reads resolve the declaration or null and never a write; writes are recorded as effects. Undeclared is unchanged. - `screenMount` mounts a component with a crash boundary, so a reply partition that takes a screen down is a recording rather than a suite failure. Every pre-existing golden re-records byte-identical except `recorderSha256`, recorded from a detached worktree at the pinned baseline. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): declare the crash boundary's state type instead of asserting it The changed-code casting gate counts `null as string | null` as a type assertion. Re-records every golden from the pinned baseline, because the edit moves `recorderSha256`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refuse an unlisted native package by the member a mount reads Both emitted interop helpers short-circuit on `__esModule`, so answering `true` hands the refusing trap back unwrapped to `__importDefault` and `__importStar`. All three import forms now load the importer and throw the named refusal at the first member read, instead of a namespace import silently yielding `undefined` and failing later at the call. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): run a task deferred past interactions instead of dropping it An inert `runAfterInteractions` swallows whatever send the screen deferred, and the recording then claims the screen sends nothing. It runs the task on a microtask and returns the RN-shaped handle, so a cancel before the task runs still prevents it. `Alert.alert` stays inert. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record every golden for the two recorder engine changes Only `recorderSha256` moves, from c58067de to a59b30e7. Recorded from a detached worktree at the pinned baseline |
||
|
|
005616171d |
test(mobile): record the real worktree catalog snapshot result in the RPC recorder (#20873)
* test(rpc-recording): record the real worktree catalog snapshot result `worktree.catalog-snapshot`'s action returned `WorktreeCatalogSnapshotClient.fetch`'s raw result. That value nests the live `RpcClient` under `pending.client`, so `captureValue` threw `Unsupported observation: function`: both goldens baked an `unhandled-rejection` effect and left the action's settlement `pending`, proving nothing about what the fetch returns. Project the result through `projectObservable` on the settlement path, the same way `state()` already shows it and the same way #20667 fixed `transport.pairing-race`. Rejections still propagate unchanged, so the two transport-rejection matrix partitions keep their recorded errors. The matrix golden now discriminates all eleven reply partitions: a full admission, six invalid ones, three `request_failed` codes and two rejections. `recorderSha256` does not move; `adapterSha256` moves on the six goldens mounted through this adapter module, four of which have no other change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(rpc-recording): fail the suite when a golden records a recorder failure A projection refusal settles as data, not as a failed run: `captureValue` throws inside the action, the recorder captures it as an `unhandled-rejection` effect, and `--record` writes a green golden whose action never settled. The class has landed twice — `transport.pairing-race` in #20667 and `worktree.catalog-snapshot` in the previous commit — and reverting either one plus a re-record would go green with the broken golden back. Read every golden and fail on an `unhandled-rejection` effect or on either of `captureValue`'s refusal texts in any observation field. A positive control in the same case asserts both detectors fire, so the absence claim is load-bearing rather than vacuous. Not a recording driver, so `recorderSha256` excludes it and no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(rpc-recording): ban only the recorder's own refusals, not product bugs The gate also failed on any effect named `unhandled-rejection`, which bans a real observation: detached-rejection capture exists to pin a main bug in a golden, and `unhandled-recording.test.ts` pins the capture precisely because no golden carries one today. Banning the name would make recording a genuine product failure a test failure. Drop that detector. `refusalText` alone catches all seven checkpoints of the worktree-catalog regression, because the refusal is the message of the captured error rather than the effect's name. The positive control now seeds that shape — the refusal inside a recorded error under `effects` — so the surviving detector is still proven to fire. Vacuity guards on golden and checkpoint counts are unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(rpc-recording): re-record the worktree catalog goldens over main's engine digest #20874 re-digested `recorderSha256` on all 509 goldens, so the six goldens mounted through the worktree catalog adapter had to be recorded again from the pin rather than merged. Bodies and `adapterSha256` are unchanged from the pre-merge recording. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
783d8feabb |
fix(lint): merge duplicate type imports in the mobile RPC recorder adapters (#20895)
* fix(lint): merge duplicate type imports in the mobile RPC recorder adapters The native code-quality audit rejects a module imported twice in one file, so main's static-analysis job is red for every open PR. * test(mobile): re-record RPC goldens against the merged adapters The duplicate-import fix changed two mount adapters, so the nine goldens that pin them by adapterSha256 needed re-recording. The recorder fence requires the pinned baseline to match the product tree, so the baseline moves to current main, which rewrites that header in all 509 goldens. Every recording body is identical, which also shows the commits between the two baselines changed no observed behavior. |
||
|
|
6c03bb6e82 |
fix(lint): replace Reflect.get with typed property access in mounting substitutes (#20874)
* fix(lint): replace Reflect.get with typed property access in native mounting substitutes main's tip fails `pnpm run audit:anti-slop` (the `static analysis` CI gate) on `no-reflect-get` in mobile/src/test-support/rpc-recording/native-mounting-substitutes.ts, blocking every open PR. The Proxy get trap's key is `string | symbol`; branch on that to keep typed bracket access for strings and a symbol-indexed cast for symbols, preserving the existing throw-on-unsubstituted-member behavior exactly. * test(rpc-recording): re-record goldens for the recorderSha256 shift native-mounting-substitutes.ts changed bytes, so recorderSha256 (which pins every non-adapter file under this directory into every golden's header) moved. Re-recorded all 509 goldens; only recorderSha256 differs in any of them, confirming the checkpoint content is unchanged. |
||
|
|
d130347993 |
refactor(mobile): send the dictation, terminal, notification and browser domains through typed RpcOperations (#20702)
* refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's `recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its merge with main conflicted on that one line in 153 files; every future domain PR would collide with every other in flight the same way. Split the directory at a real seam instead of a filename convention: `adapters/` holds one module per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers the engine only. A new `adapterSha256` covers the source of the module that mounts each operation a golden's scenarios drive, read off the same `mounts` calls that build the table the recording runs against, so the pin cannot name a file the runner did not use. Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file, and an adapter importing a sibling each fail. The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new header field; the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the split recorder/adapter digest Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers `adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and recording ran against the same pinned product tree. git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l 0 The seven `adapterSha256` values partition the 153 goldens by the module each was recorded through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory, 9 tasks, 8 workspace settings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop pinning goldens to recorder inputs no recording can read The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the per-family mutant registry beside it, and the probe-hole witness. None can change a recording -- the loader consults a mutant only when a mutant test asks for one, and no suite but the two recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have and charged every domain a full re-record for it. `mutants/` now holds the table, the registry, the reference states, the mutant suites and the probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts` checks exactly that, and fails if an engine file names the directory or anything outside imports from it. `recorderSha256` also pins only the suites in `recording-drivers.ts`, which `scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or writes one to a scratch directory, is no longer provenance for a recorded file. `OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden. Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to `root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed anything. Each call now spells the root differently. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the mutant and driver exclusions Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153 because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that module now carries its own exposure declaration. git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256)":' | wc -l 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restore the preferences actions the merge resolution dropped #20568 added `resume` and `trust` actions to the `settings.task-preferences` adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the `pilot-mount-adapters.ts` conflict in favour of the registry merge silently discarded them and `tw-task-preferences-resume-write` failed to record at all ("Missing or completed request: ui.set#1"). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens at main's tip after the merge All 208 goldens, header-only. `baseline` moves from |